Commit d04dce6a by Alex Khomenko Committed by GitHub

Search/refactor dashboard search (#23274)

* Search: add search wrapper

* Search: add DashboardSearch.tsx

* Search: enable search

* Search: update types

* Search: useReducer for saving search results

* Search: use default query

* Search: add toggle custom action

* Search: add onQueryChange

* Search: debounce search

* Search: pas dispatch as a prop

* Search: add tag filter

* Search: Fix types

* Search: revert changes

* Search: close overlay on esc

* Search: enable tag filtering

* Search: clear query

* Search: add autofocus to search field

* Search: Rename close to closeSearch

* Search: Add no results message

* Search: Add loading state

* Search: Remove Select from Forms namespace

* Remove Add selectedIndex

* Remove Add getFlattenedSections

* Remove Enable selecting items

* Search: add hasId

* Search: preselect first item

* Search: Add utils tests

* Search: Fix moving selection down

* Search: Add findSelected

* Search: Add type to section

* Search: Handle Enter key press on item highlight

* Search: Move reducer et al. to separate files

* Search: Remove redundant render check

* Search: Close overlay on Esc and ArrowLeft press

* Search: Add close button

* Search: Document utils

* Search: use Icon for remove icon

* Search: Add DashboardSearch.test.tsx

* Search: Move test data to a separate file

* Search: Finalise DashboardSearch.test.tsx

* Add search reducer tests

* Search: Add search results loading indicator

* Search: Remove inline function

* Search: Do not mutate item

* Search: Tweak utils

* Search: Do not clear query on tag clear

* Search: Fix folder:current search

* Search: Fix results scroll

* Search: Update tests

* Search: Close overlay on cog icon click

* Add mobile styles for close button

* Search: Use CustomScrollbar

* Search: Memoize TagList.tsx

* Search: Fix type errors

* Search: More strictNullChecks fixes

* Search: Consistent handler names

* Search: Fix search items types in test

* Search: Fix merge conflicts

* Search: Fix strictNullChecks errors
parent dbda5aec
import React, { FC } from 'react'; import React, { FC, memo } from 'react';
import { cx, css } from 'emotion'; import { cx, css } from 'emotion';
import { OnTagClick, Tag } from './Tag'; import { OnTagClick, Tag } from './Tag';
...@@ -9,7 +9,7 @@ export interface Props { ...@@ -9,7 +9,7 @@ export interface Props {
className?: string; className?: string;
} }
export const TagList: FC<Props> = ({ tags, onClick, className }) => { export const TagList: FC<Props> = memo(({ tags, onClick, className }) => {
const styles = getStyles(); const styles = getStyles();
return ( return (
...@@ -19,7 +19,7 @@ export const TagList: FC<Props> = ({ tags, onClick, className }) => { ...@@ -19,7 +19,7 @@ export const TagList: FC<Props> = ({ tags, onClick, className }) => {
))} ))}
</span> </span>
); );
}; });
const getStyles = () => { const getStyles = () => {
return { return {
......
...@@ -29,7 +29,7 @@ import { ...@@ -29,7 +29,7 @@ import {
SaveDashboardButtonConnected, SaveDashboardButtonConnected,
} from '../features/dashboard/components/SaveDashboard/SaveDashboardButton'; } from '../features/dashboard/components/SaveDashboard/SaveDashboardButton';
import { VariableEditorContainer } from '../features/variables/editor/VariableEditorContainer'; import { VariableEditorContainer } from '../features/variables/editor/VariableEditorContainer';
import { SearchField, SearchResults, SearchResultsFilter } from '../features/search'; import { SearchField, SearchResults, SearchWrapper, SearchResultsFilter } from '../features/search';
export function registerAngularDirectives() { export function registerAngularDirectives() {
react2AngularDirective('footer', Footer, []); react2AngularDirective('footer', Footer, []);
...@@ -87,6 +87,7 @@ export function registerAngularDirectives() { ...@@ -87,6 +87,7 @@ export function registerAngularDirectives() {
['onStarredFilterChange', { watchDepth: 'reference' }], ['onStarredFilterChange', { watchDepth: 'reference' }],
['onTagFilterChange', { watchDepth: 'reference' }], ['onTagFilterChange', { watchDepth: 'reference' }],
]); ]);
react2AngularDirective('searchWrapper', SearchWrapper, []);
react2AngularDirective('tagFilter', TagFilter, [ react2AngularDirective('tagFilter', TagFilter, [
'tags', 'tags',
['onChange', { watchDepth: 'reference' }], ['onChange', { watchDepth: 'reference' }],
......
...@@ -22,6 +22,7 @@ export interface Section { ...@@ -22,6 +22,7 @@ export interface Section {
checked: boolean; checked: boolean;
hideHeader: boolean; hideHeader: boolean;
toggle: Function; toggle: Function;
type?: string;
} }
export interface FoldersAndDashboardUids { export interface FoldersAndDashboardUids {
......
...@@ -12,7 +12,6 @@ ...@@ -12,7 +12,6 @@
<div class="search-dropdown__col_1"> <div class="search-dropdown__col_1">
<div class="search-results-scroller"> <div class="search-results-scroller">
<div class="search-results-container" grafana-scrollbar> <div class="search-results-container" grafana-scrollbar>
<h6 ng-show="!ctrl.isLoading && ctrl.results.length === 0">No dashboards matching your query were found.</h6>
<search-results <search-results
results="ctrl.results" results="ctrl.results"
on-tag-selected="ctrl.filterByTag" on-tag-selected="ctrl.filterByTag"
......
...@@ -6,7 +6,7 @@ import store from 'app/core/store'; ...@@ -6,7 +6,7 @@ import store from 'app/core/store';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { backendSrv } from './backend_srv'; import { backendSrv } from './backend_srv';
import { Section } from '../components/manage_dashboards/manage_dashboards'; import { Section } from '../components/manage_dashboards/manage_dashboards';
import { DashboardSearchHit } from 'app/types/search'; import { DashboardSearchHit, DashboardSearchHitType } from 'app/types/search';
interface Sections { interface Sections {
[key: string]: Partial<Section>; [key: string]: Partial<Section>;
...@@ -32,6 +32,7 @@ export class SearchSrv { ...@@ -32,6 +32,7 @@ export class SearchSrv {
expanded: this.recentIsOpen, expanded: this.recentIsOpen,
toggle: this.toggleRecent.bind(this), toggle: this.toggleRecent.bind(this),
items: result, items: result,
type: DashboardSearchHitType.DashHitFolder,
}; };
} }
}); });
...@@ -86,6 +87,7 @@ export class SearchSrv { ...@@ -86,6 +87,7 @@ export class SearchSrv {
expanded: this.starredIsOpen, expanded: this.starredIsOpen,
toggle: this.toggleStarred.bind(this), toggle: this.toggleStarred.bind(this),
items: result, items: result,
type: DashboardSearchHitType.DashHitFolder,
}; };
} }
}); });
...@@ -143,6 +145,7 @@ export class SearchSrv { ...@@ -143,6 +145,7 @@ export class SearchSrv {
url: hit.url, url: hit.url,
icon: 'folder', icon: 'folder',
score: _.keys(sections).length, score: _.keys(sections).length,
type: hit.type,
}; };
} }
} }
...@@ -164,6 +167,7 @@ export class SearchSrv { ...@@ -164,6 +167,7 @@ export class SearchSrv {
icon: 'folder-open', icon: 'folder-open',
toggle: this.toggleFolder.bind(this), toggle: this.toggleFolder.bind(this),
score: _.keys(sections).length, score: _.keys(sections).length,
type: DashboardSearchHitType.DashHitFolder,
}; };
} else { } else {
section = { section = {
...@@ -173,6 +177,7 @@ export class SearchSrv { ...@@ -173,6 +177,7 @@ export class SearchSrv {
icon: 'folder-open', icon: 'folder-open',
toggle: this.toggleFolder.bind(this), toggle: this.toggleFolder.bind(this),
score: _.keys(sections).length, score: _.keys(sections).length,
type: DashboardSearchHitType.DashHitFolder,
}; };
} }
// add section // add section
......
import React from 'react';
import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { mockSearch } from './mocks';
import { DashboardSearch } from './DashboardSearch';
import { searchResults } from '../testData';
beforeEach(() => {
jest.useFakeTimers();
mockSearch.mockClear();
});
afterEach(() => {
jest.useRealTimers();
});
/**
* Need to wrap component render in async act and use jest.runAllTimers to test
* calls inside useDebounce hook
*/
describe('DashboardSearch', () => {
it('should call search api with default query when initialised', async () => {
await act(() => {
mount(<DashboardSearch onCloseSearch={() => {}} />);
jest.runAllTimers();
});
expect(mockSearch).toHaveBeenCalledTimes(1);
expect(mockSearch).toHaveBeenCalledWith({
query: '',
parsedQuery: { text: '' },
tags: [],
tag: [],
starred: false,
folderIds: [],
});
});
it('should call api with updated query on query change', async () => {
let wrapper: any;
await act(() => {
wrapper = mount(<DashboardSearch onCloseSearch={() => {}} />);
jest.runAllTimers();
});
await act(() => {
wrapper.find({ placeholder: 'Search dashboards by name' }).prop('onChange')({ currentTarget: { value: 'Test' } });
jest.runAllTimers();
});
expect(mockSearch).toHaveBeenCalledWith({
query: 'Test',
parsedQuery: { text: 'Test' },
tags: [],
tag: [],
starred: false,
folderIds: [],
});
});
it("should render 'No results' message when there are no dashboards", async () => {
let wrapper: any;
await act(() => {
wrapper = mount(<DashboardSearch onCloseSearch={() => {}} />);
jest.runAllTimers();
});
wrapper.update();
expect(
wrapper.findWhere((c: any) => c.type() === 'h6' && c.text() === 'No dashboards matching your query were found.')
).toHaveLength(1);
});
it('should render search results', async () => {
//@ts-ignore
mockSearch.mockImplementation(() => Promise.resolve(searchResults));
let wrapper: any;
await act(() => {
wrapper = mount(<DashboardSearch onCloseSearch={() => {}} />);
jest.runAllTimers();
});
wrapper.update();
expect(wrapper.find({ 'aria-label': 'Search section' })).toHaveLength(2);
expect(wrapper.find({ 'aria-label': 'Search items' }).children()).toHaveLength(2);
});
it('should call search with selected tags', async () => {
let wrapper: any;
await act(() => {
wrapper = mount(<DashboardSearch onCloseSearch={() => {}} />);
jest.runAllTimers();
});
await act(() => {
wrapper.find('TagFilter').prop('onChange')(['TestTag']);
jest.runAllTimers();
});
expect(mockSearch).toHaveBeenCalledWith({
query: '',
parsedQuery: { text: '' },
tags: ['TestTag'],
tag: ['TestTag'],
starred: false,
folderIds: [],
});
});
});
import React, { FC, useReducer, useState } from 'react';
import { useDebounce } from 'react-use';
import { css } from 'emotion';
import { Icon, useTheme, CustomScrollbar, stylesFactory } from '@grafana/ui';
import { getLocationSrv } from '@grafana/runtime';
import { GrafanaTheme } from '@grafana/data';
import { SearchSrv } from 'app/core/services/search_srv';
import { backendSrv } from 'app/core/services/backend_srv';
import { SearchQuery } from 'app/core/components/search/search';
import { TagFilter } from 'app/core/components/TagFilter/TagFilter';
import { contextSrv } from 'app/core/services/context_srv';
import { DashboardSearchItemType, DashboardSection, OpenSearchParams } from '../types';
import { findSelected, hasId, parseQuery } from '../utils';
import { searchReducer, initialState } from '../reducers/dashboardSearch';
import { getDashboardSrv } from '../../dashboard/services/DashboardSrv';
import {
FETCH_ITEMS,
FETCH_RESULTS,
TOGGLE_SECTION,
MOVE_SELECTION_DOWN,
MOVE_SELECTION_UP,
} from '../reducers/actionTypes';
import { SearchField } from './SearchField';
import { SearchResults } from './SearchResults';
const searchSrv = new SearchSrv();
const defaultQuery: SearchQuery = { query: '', parsedQuery: { text: '' }, tags: [], starred: false };
const { isEditor, hasEditPermissionInFolders } = contextSrv;
const canEdit = isEditor || hasEditPermissionInFolders;
export interface Props {
onCloseSearch: () => void;
payload?: OpenSearchParams;
}
export const DashboardSearch: FC<Props> = ({ onCloseSearch, payload = {} }) => {
const [query, setQuery] = useState({ ...defaultQuery, ...payload, parsedQuery: parseQuery(payload.query) });
const [{ results, loading }, dispatch] = useReducer(searchReducer, initialState);
const theme = useTheme();
const styles = getStyles(theme);
const search = () => {
let folderIds: number[] = [];
if (query.parsedQuery.folder === 'current') {
const { folderId } = getDashboardSrv().getCurrent().meta;
if (folderId) {
folderIds.push(folderId);
}
}
searchSrv.search({ ...query, tag: query.tags, query: query.parsedQuery.text, folderIds }).then(results => {
dispatch({ type: FETCH_RESULTS, payload: results });
});
};
useDebounce(search, 300, [query]);
const onToggleSection = (section: DashboardSection) => {
if (hasId(section.title) && !section.items.length) {
backendSrv.search({ ...defaultQuery, folderIds: [section.id] }).then(items => {
dispatch({ type: FETCH_ITEMS, payload: { section, items } });
dispatch({ type: TOGGLE_SECTION, payload: section });
});
} else {
dispatch({ type: TOGGLE_SECTION, payload: section });
}
};
const onQueryChange = (searchQuery: string) => {
setQuery(q => ({
...q,
parsedQuery: parseQuery(searchQuery),
query: searchQuery,
}));
};
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
switch (event.key) {
case 'Escape':
onCloseSearch();
break;
case 'ArrowUp':
dispatch({ type: MOVE_SELECTION_UP });
break;
case 'ArrowDown':
dispatch({ type: MOVE_SELECTION_DOWN });
break;
case 'Enter':
const selectedItem = findSelected(results);
if (selectedItem) {
if (selectedItem.type === DashboardSearchItemType.DashFolder) {
onToggleSection(selectedItem as DashboardSection);
} else {
getLocationSrv().update({ path: selectedItem.url });
// Delay closing to prevent current page flicker
setTimeout(onCloseSearch, 0);
}
}
}
};
// The main search input has own keydown handler, also TagFilter uses input, so
// clicking Esc when tagFilter is active shouldn't close the whole search overlay
const onClose = (e: React.KeyboardEvent<HTMLElement>) => {
const target = e.target as HTMLElement;
if ((target.tagName as any) !== 'INPUT' && ['Escape', 'ArrowLeft'].includes(e.key)) {
onCloseSearch();
}
};
const onTagFiltersChanged = (tags: string[]) => {
setQuery(q => ({ ...q, tags }));
};
const onTagSelected = (tag: string) => {
if (tag && !query.tags.includes(tag)) {
setQuery(q => ({ ...q, tags: [...q.tags, tag] }));
}
};
const onClearSearchFilters = () => {
setQuery(q => ({ ...q, tags: [] }));
};
return (
<div tabIndex={0} className="search-container" onKeyDown={onClose}>
<SearchField query={query} onChange={onQueryChange} onKeyDown={onKeyDown} autoFocus={true} />
<div className="search-dropdown">
<div className="search-dropdown__col_1">
<CustomScrollbar>
<div className="search-results-container">
<SearchResults
results={results}
loading={loading}
onTagSelected={onTagSelected}
dispatch={dispatch}
editable={false}
onToggleSection={onToggleSection}
/>
</div>
</CustomScrollbar>
</div>
<div className="search-dropdown__col_2">
<div className="search-filter-box">
<div className="search-filter-box__header">
<Icon name="filter" />
Filter by:
{query.tags.length > 0 && (
<a className="pointer pull-right small" onClick={onClearSearchFilters}>
<Icon name="times" /> Clear
</a>
)}
</div>
<TagFilter tags={query.tags} tagOptions={searchSrv.getDashboardTags} onChange={onTagFiltersChanged} />
</div>
{canEdit && (
<div className="search-filter-box" onClick={onCloseSearch}>
<a href="dashboard/new" className="search-filter-box-link">
<i className="gicon gicon-dashboard-new"></i> New dashboard
</a>
{isEditor && (
<a href="dashboards/folder/new" className="search-filter-box-link">
<i className="gicon gicon-folder-new"></i> New folder
</a>
)}
<a href="dashboard/import" className="search-filter-box-link">
<i className="gicon gicon-dashboard-import"></i> Import dashboard
</a>
<a
className="search-filter-box-link"
target="_blank"
href="https://grafana.com/dashboards?utm_source=grafana_search"
>
<img src="public/img/icn-dashboard-tiny.svg" width="20" /> Find dashboards on Grafana.com
</a>
</div>
)}
</div>
<Icon onClick={onCloseSearch} className={styles.closeBtn} name="times" />
</div>
</div>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
closeBtn: css`
font-size: 22px;
margin-top: 14px;
margin-right: 6px;
&:hover {
cursor: pointer;
color: ${theme.colors.white};
}
@media only screen and (max-width: ${theme.breakpoints.md}) {
position: absolute;
right: 15px;
top: 60px;
}
`,
};
});
...@@ -3,21 +3,19 @@ import { css } from 'emotion'; ...@@ -3,21 +3,19 @@ import { css } from 'emotion';
import { Forms, stylesFactory } from '@grafana/ui'; import { Forms, stylesFactory } from '@grafana/ui';
interface Props { interface Props {
checked: boolean; checked?: boolean;
onClick: any; onClick: any;
editable?: boolean; editable?: boolean;
} }
export const SearchCheckbox: FC<Props> = memo(({ checked = false, onClick, editable = false }) => { export const SearchCheckbox: FC<Props> = memo(({ onClick, checked = false, editable = false }) => {
const styles = getStyles(); const styles = getStyles();
return ( return editable ? (
editable && (
<div onClick={onClick} className={styles.wrapper}> <div onClick={onClick} className={styles.wrapper}>
<Forms.Checkbox value={checked} /> <Forms.Checkbox value={checked} />
</div> </div>
) ) : null;
);
}); });
const getStyles = stylesFactory(() => ({ const getStyles = stylesFactory(() => ({
......
import React, { useContext } from 'react'; import React, { useContext } from 'react';
import { css, cx } from 'emotion'; import { css, cx } from 'emotion';
// @ts-ignore
import tinycolor from 'tinycolor2';
import { ThemeContext, Icon } from '@grafana/ui'; import { ThemeContext, Icon } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
import { SearchQuery } from 'app/core/components/search/search'; import { SearchQuery } from 'app/core/components/search/search';
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>; type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
interface SearchFieldProps extends Omit<React.HTMLAttributes<HTMLInputElement>, 'onChange'> { interface SearchFieldProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
query: SearchQuery; query: SearchQuery;
onChange: (query: string) => void; onChange: (query: string) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void; onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
......
import React from 'react'; import React from 'react';
import { shallow, mount } from 'enzyme'; import { shallow, mount } from 'enzyme';
import { SearchItem, Props } from './SearchItem';
import { Tag } from '@grafana/ui'; import { Tag } from '@grafana/ui';
import { SearchItem, Props } from './SearchItem';
import { DashboardSearchItemType } from '../types';
const data = { const data = {
id: 1, id: 1,
...@@ -10,8 +11,7 @@ const data = { ...@@ -10,8 +11,7 @@ const data = {
uri: 'db/test1', uri: 'db/test1',
url: '/d/lBdLINUWk/test1', url: '/d/lBdLINUWk/test1',
slug: '', slug: '',
type: 'dash-db', type: DashboardSearchItemType.DashDB,
//@ts-ignore
tags: ['Tag1', 'Tag2'], tags: ['Tag1', 'Tag2'],
isStarred: false, isStarred: false,
checked: false, checked: false,
......
...@@ -11,34 +11,40 @@ import { SearchCheckbox } from './SearchCheckbox'; ...@@ -11,34 +11,40 @@ import { SearchCheckbox } from './SearchCheckbox';
export interface Props { export interface Props {
item: DashboardSectionItem; item: DashboardSectionItem;
editable?: boolean; editable?: boolean;
onToggleSelection: ItemClickWithEvent; onToggleSelection?: ItemClickWithEvent;
onTagSelected: (name: string) => any; onTagSelected: (name: string) => any;
} }
const { selectors } = e2e.pages.Dashboards; const { selectors } = e2e.pages.Dashboards;
export const SearchItem: FC<Props> = ({ item, editable, onToggleSelection, onTagSelected }) => { export const SearchItem: FC<Props> = ({ item, editable, onToggleSelection = () => {}, onTagSelected }) => {
const theme = useTheme(); const theme = useTheme();
const styles = getResultsItemStyles(theme); const styles = getResultsItemStyles(theme);
const inputEl = useRef(null); const inputEl = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
inputEl.current.addEventListener('click', (event: MouseEvent) => { const preventDef = (event: MouseEvent) => {
// manually prevent default on TagList click, as doing it via normal onClick doesn't work inside angular // manually prevent default on TagList click, as doing it via normal onClick doesn't work inside angular
event.preventDefault(); event.preventDefault();
}); };
if (inputEl.current) {
inputEl.current.addEventListener('click', preventDef);
}
return () => {
inputEl.current!.removeEventListener('click', preventDef);
};
}, []); }, []);
const onItemClick = () => { const onItemClick = () => {
//Check if one string can be found in the other //Check if one string can be found in the other
if (window.location.pathname.includes(item.url) || item.url.includes(window.location.pathname)) { if (window.location.pathname.includes(item.url) || item.url.includes(window.location.pathname)) {
appEvents.emit(CoreEvents.hideDashSearch); appEvents.emit(CoreEvents.hideDashSearch, { target: 'search-item' });
} }
}; };
const tagSelected = (tag: string, event: React.MouseEvent<HTMLElement>) => { const tagSelected = useCallback((tag: string, event: React.MouseEvent<HTMLElement>) => {
onTagSelected(tag); onTagSelected(tag);
}; }, []);
const toggleItem = useCallback( const toggleItem = useCallback(
(event: React.MouseEvent<HTMLElement>) => { (event: React.MouseEvent<HTMLElement>) => {
......
import React from 'react'; import React from 'react';
import { shallow, mount } from 'enzyme'; import { shallow, mount } from 'enzyme';
import { SearchResults, Props } from './SearchResults'; import { SearchResults, Props } from './SearchResults';
import { searchResults } from '../testData';
const data = [
{
id: 2,
uid: 'JB_zdOUWk',
title: 'gdev dashboards',
expanded: false,
//@ts-ignore
items: [],
url: '/dashboards/f/JB_zdOUWk/gdev-dashboards',
icon: 'folder',
score: 0,
checked: false,
},
{
id: 0,
title: 'General',
items: [
{
id: 1,
uid: 'lBdLINUWk',
title: 'Test 1',
uri: 'db/test1',
url: '/d/lBdLINUWk/test1',
slug: '',
type: 'dash-db',
//@ts-ignore
tags: [],
isStarred: false,
checked: false,
},
{
id: 46,
uid: '8DY63kQZk',
title: 'Test 2',
uri: 'db/test2',
url: '/d/8DY63kQZk/test2',
slug: '',
type: 'dash-db',
tags: [],
isStarred: false,
checked: false,
},
],
icon: 'folder-open',
score: 1,
expanded: true,
checked: false,
},
];
const setup = (propOverrides?: Partial<Props>, renderMethod = shallow) => { const setup = (propOverrides?: Partial<Props>, renderMethod = shallow) => {
const props: Props = { const props: Props = {
//@ts-ignore //@ts-ignore
results: data, results: searchResults,
onSelectionChanged: () => {}, onSelectionChanged: () => {},
onTagSelected: (name: string) => {}, onTagSelected: (name: string) => {},
onFolderExpanding: () => {}, onFolderExpanding: () => {},
......
import React, { FC } from 'react'; import React, { FC, Dispatch } from 'react';
import { css, cx } from 'emotion'; import { css, cx } from 'emotion';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
import { Icon, IconName, stylesFactory, useTheme } from '@grafana/ui'; import { Icon, stylesFactory, useTheme, IconName } from '@grafana/ui';
import { DashboardSection, ItemClickWithEvent } from '../types'; import PageLoader from 'app/core/components/PageLoader/PageLoader';
import appEvents from 'app/core/app_events';
import { CoreEvents } from 'app/types';
import { DashboardSection, ItemClickWithEvent, SearchAction } from '../types';
import { SearchItem } from './SearchItem'; import { SearchItem } from './SearchItem';
import { SearchCheckbox } from './SearchCheckbox'; import { SearchCheckbox } from './SearchCheckbox';
export interface Props { export interface Props {
results: DashboardSection[] | undefined; dispatch?: Dispatch<SearchAction>;
onSelectionChanged: () => void; editable?: boolean;
loading?: boolean;
onFolderExpanding?: () => void;
onSelectionChanged?: () => void;
onTagSelected: (name: string) => any; onTagSelected: (name: string) => any;
onFolderExpanding: () => void; onToggleSection?: any;
onToggleSelection: ItemClickWithEvent; onToggleSelection?: ItemClickWithEvent;
editable: boolean; results: DashboardSection[] | undefined;
} }
export const SearchResults: FC<Props> = ({ export const SearchResults: FC<Props> = ({
results, editable,
loading,
onFolderExpanding,
onSelectionChanged, onSelectionChanged,
onTagSelected, onTagSelected,
onFolderExpanding, onToggleSection,
onToggleSelection, onToggleSelection,
editable, results,
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const styles = getSectionStyles(theme); const styles = getSectionStyles(theme);
const toggleFolderExpand = (section: DashboardSection) => { const toggleFolderExpand = (section: DashboardSection) => {
if (onToggleSection) {
onToggleSection(section);
} else {
if (section.toggle) { if (section.toggle) {
if (!section.expanded && onFolderExpanding) { if (!section.expanded && onFolderExpanding) {
onFolderExpanding(); onFolderExpanding();
...@@ -38,11 +49,13 @@ export const SearchResults: FC<Props> = ({ ...@@ -38,11 +49,13 @@ export const SearchResults: FC<Props> = ({
} }
}); });
} }
}
}; };
// TODO display 'No results' messages after manage dashboards is refactored if (loading) {
if (!results) { return <PageLoader />;
return null; } else if (!results || !results.length) {
return <h6>No dashboards matching your query were found.</h6>;
} }
return ( return (
...@@ -79,11 +92,16 @@ const getSectionStyles = stylesFactory((theme: GrafanaTheme) => { ...@@ -79,11 +92,16 @@ const getSectionStyles = stylesFactory((theme: GrafanaTheme) => {
interface SectionHeaderProps { interface SectionHeaderProps {
section: DashboardSection; section: DashboardSection;
onSectionClick: (section: DashboardSection) => void; onSectionClick: (section: DashboardSection) => void;
onToggleSelection: ItemClickWithEvent; onToggleSelection?: ItemClickWithEvent;
editable: boolean; editable?: boolean;
} }
const SectionHeader: FC<SectionHeaderProps> = ({ section, onSectionClick, onToggleSelection, editable }) => { const SectionHeader: FC<SectionHeaderProps> = ({
section,
onSectionClick,
onToggleSelection = () => {},
editable = false,
}) => {
const theme = useTheme(); const theme = useTheme();
const styles = getSectionHeaderStyles(theme, section.selected); const styles = getSectionHeaderStyles(theme, section.selected);
...@@ -102,7 +120,11 @@ const SectionHeader: FC<SectionHeaderProps> = ({ section, onSectionClick, onTogg ...@@ -102,7 +120,11 @@ const SectionHeader: FC<SectionHeaderProps> = ({ section, onSectionClick, onTogg
<span className={styles.text}>{section.title}</span> <span className={styles.text}>{section.title}</span>
{section.url && ( {section.url && (
<a href={section.url} className={styles.link}> <a
href={section.url}
className={styles.link}
onClick={() => appEvents.emit(CoreEvents.hideDashSearch, { target: 'search-item' })}
>
<Icon name="cog" /> <Icon name="cog" />
</a> </a>
)} )}
......
import React, { FC, useState, useEffect } from 'react';
import { appEvents } from 'app/core/core';
import { CoreEvents } from 'app/types';
import { DashboardSearch } from './DashboardSearch';
import { OpenSearchParams } from '../types';
export const SearchWrapper: FC = () => {
const [isOpen, setIsOpen] = useState(false);
const [payload, setPayload] = useState({});
useEffect(() => {
const openSearch = (payload: OpenSearchParams) => {
setIsOpen(true);
setPayload(payload);
};
const closeOnItemClick = (payload: any) => {
// Detect if the event was emitted by clicking on search item
if (payload?.target === 'search-item' && isOpen) {
setIsOpen(false);
}
};
appEvents.on(CoreEvents.showDashSearch, openSearch);
appEvents.on(CoreEvents.hideDashSearch, closeOnItemClick);
return () => {
appEvents.off(CoreEvents.showDashSearch, openSearch);
appEvents.off(CoreEvents.hideDashSearch, closeOnItemClick);
};
}, [isOpen]);
return isOpen ? (
<>
<div className="search-backdrop" />
<DashboardSearch onCloseSearch={() => setIsOpen(false)} payload={payload} />
</>
) : null;
};
export const mockSearch = jest.fn(() => {
return Promise.resolve([]);
});
jest.mock('app/core/services/search_srv', () => {
return {
SearchSrv: jest.fn().mockImplementation(() => {
return { search: mockSearch, getDashboardTags: jest.fn(() => Promise.resolve(['Tag1', 'Tag2'])) };
}),
};
});
export const NO_ID_SECTIONS = ['Recent', 'Starred'];
...@@ -2,5 +2,6 @@ export { SearchResults } from './components/SearchResults'; ...@@ -2,5 +2,6 @@ export { SearchResults } from './components/SearchResults';
export { SearchField } from './components/SearchField'; export { SearchField } from './components/SearchField';
export { SearchItem } from './components/SearchItem'; export { SearchItem } from './components/SearchItem';
export { SearchCheckbox } from './components/SearchCheckbox'; export { SearchCheckbox } from './components/SearchCheckbox';
export { SearchWrapper } from './components/SearchWrapper';
export { SearchResultsFilter } from './components/SearchResultsFilter'; export { SearchResultsFilter } from './components/SearchResultsFilter';
export * from './types'; export * from './types';
export const FETCH_RESULTS = 'FETCH_RESULTS';
export const TOGGLE_SECTION = 'TOGGLE_SECTION';
export const FETCH_ITEMS = 'FETCH_ITEMS';
export const MOVE_SELECTION_UP = 'MOVE_SELECTION_UP';
export const MOVE_SELECTION_DOWN = 'MOVE_SELECTION_DOWN';
import { FETCH_ITEMS, FETCH_RESULTS, TOGGLE_SECTION, MOVE_SELECTION_DOWN, MOVE_SELECTION_UP } from './actionTypes';
import { searchReducer as reducer, initialState } from './dashboardSearch';
import { searchResults, sections } from '../testData';
describe('Dashboard Search reducer', () => {
it('should return the initial state', () => {
expect(reducer(initialState, {} as any)).toEqual(initialState);
});
it('should set the results and mark first item as selected', () => {
const newState = reducer(initialState, { type: FETCH_RESULTS, payload: searchResults });
expect(newState).toEqual({ loading: false, selectedIndex: 0, results: searchResults });
expect(newState.results[0].selected).toBeTruthy();
});
it('should toggle selected section', () => {
const newState = reducer({ loading: false, results: sections }, { type: TOGGLE_SECTION, payload: sections[5] });
expect(newState.results[5].expanded).toBeFalsy();
const newState2 = reducer({ loading: false, results: sections }, { type: TOGGLE_SECTION, payload: sections[1] });
expect(newState2.results[1].expanded).toBeTruthy();
});
it('should handle FETCH_ITEMS', () => {
const items = [
{
id: 4072,
uid: 'OzAIf_rWz',
title: 'New dashboard Copy 3',
type: 'dash-db',
isStarred: false,
},
{
id: 46,
uid: '8DY63kQZk',
title: 'Stocks',
type: 'dash-db',
isStarred: false,
},
];
const newState = reducer(
{ loading: false, results: sections },
{
type: FETCH_ITEMS,
payload: {
section: sections[2],
items,
},
}
);
expect(newState.results[2].items).toEqual(items);
});
it('should handle MOVE_SELECTION_DOWN', () => {
const newState = reducer(
{ loading: false, selectedIndex: 0, results: sections },
{
type: MOVE_SELECTION_DOWN,
}
);
expect(newState.selectedIndex).toEqual(1);
expect(newState.results[0].items[0].selected).toBeTruthy();
const newState2 = reducer(newState, {
type: MOVE_SELECTION_DOWN,
});
expect(newState2.selectedIndex).toEqual(2);
expect(newState2.results[1].selected).toBeTruthy();
// Shouldn't go over the visible results length - 1 (9)
const newState3 = reducer(
{ loading: false, selectedIndex: 9, results: sections },
{
type: MOVE_SELECTION_DOWN,
}
);
expect(newState3.selectedIndex).toEqual(9);
});
it('should handle MOVE_SELECTION_UP', () => {
// shouldn't move beyond 0
const newState = reducer(
{ loading: false, selectedIndex: 0, results: sections },
{
type: MOVE_SELECTION_UP,
}
);
expect(newState.selectedIndex).toEqual(0);
const newState2 = reducer(
{ loading: false, selectedIndex: 3, results: sections },
{
type: MOVE_SELECTION_UP,
}
);
expect(newState2.selectedIndex).toEqual(2);
expect(newState2.results[1].selected).toBeTruthy();
});
});
import { DashboardSection, SearchAction } from '../types';
import { getFlattenedSections, getLookupField, markSelected } from '../utils';
import { FETCH_ITEMS, FETCH_RESULTS, TOGGLE_SECTION, MOVE_SELECTION_DOWN, MOVE_SELECTION_UP } from './actionTypes';
interface State {
results: DashboardSection[];
loading: boolean;
selectedIndex: number;
}
export const initialState: State = {
results: [],
loading: true,
selectedIndex: 0,
};
export const searchReducer = (state: any, action: SearchAction) => {
switch (action.type) {
case FETCH_RESULTS: {
const results = action.payload;
// Highlight the first item ('Starred' folder)
if (results.length) {
results[0].selected = true;
}
return { ...state, results, loading: false };
}
case TOGGLE_SECTION: {
const section = action.payload;
const lookupField = getLookupField(section.title);
return {
...state,
results: state.results.map((result: DashboardSection) => {
if (section[lookupField] === result[lookupField]) {
return { ...result, expanded: !result.expanded };
}
return result;
}),
};
}
case FETCH_ITEMS: {
const { section, items } = action.payload;
return {
...state,
results: state.results.map((result: DashboardSection) => {
if (section.id === result.id) {
return { ...result, items };
}
return result;
}),
};
}
case MOVE_SELECTION_DOWN: {
const flatIds = getFlattenedSections(state.results);
if (state.selectedIndex < flatIds.length - 1) {
const newIndex = state.selectedIndex + 1;
const selectedId = flatIds[newIndex];
return {
...state,
selectedIndex: newIndex,
results: markSelected(state.results, selectedId),
};
}
return state;
}
case MOVE_SELECTION_UP:
if (state.selectedIndex > 0) {
const flatIds = getFlattenedSections(state.results);
const newIndex = state.selectedIndex - 1;
const selectedId = flatIds[newIndex];
return {
...state,
selectedIndex: newIndex,
results: markSelected(state.results, selectedId),
};
}
return state;
default:
return state;
}
};
export const searchResults = [
{
id: 2,
uid: 'JB_zdOUWk',
title: 'gdev dashboards',
expanded: false,
//@ts-ignore
items: [],
url: '/dashboards/f/JB_zdOUWk/gdev-dashboards',
icon: 'folder',
score: 0,
checked: false,
},
{
id: 0,
title: 'General',
items: [
{
id: 1,
uid: 'lBdLINUWk',
title: 'Test 1',
uri: 'db/test1',
url: '/d/lBdLINUWk/test1',
slug: '',
type: 'dash-db',
//@ts-ignore
tags: [],
isStarred: false,
checked: false,
},
{
id: 46,
uid: '8DY63kQZk',
title: 'Test 2',
uri: 'db/test2',
url: '/d/8DY63kQZk/test2',
slug: '',
type: 'dash-db',
tags: [],
isStarred: false,
checked: false,
},
],
icon: 'folder-open',
score: 1,
expanded: true,
checked: false,
},
];
// Search results with more info
export const sections = [
{
title: 'Starred',
score: -2,
expanded: true,
items: [
{
id: 1,
uid: 'lBdLINUWk',
title: 'Prom dash',
type: 'dash-db',
},
],
},
{
title: 'Recent',
icon: 'clock-o',
score: -1,
removable: true,
expanded: false,
items: [
{
id: 4072,
uid: 'OzAIf_rWz',
title: 'New dashboard Copy 3',
type: 'dash-db',
isStarred: false,
},
{
id: 46,
uid: '8DY63kQZk',
title: 'Stocks',
type: 'dash-db',
isStarred: false,
},
{
id: 20,
uid: '7MeksYbmk',
title: 'Alerting with TestData',
type: 'dash-db',
isStarred: false,
folderId: 2,
},
{
id: 4073,
uid: 'j9SHflrWk',
title: 'New dashboard Copy 4',
type: 'dash-db',
isStarred: false,
folderId: 2,
},
],
},
{
id: 2,
uid: 'JB_zdOUWk',
title: 'gdev dashboards',
expanded: false,
url: '/dashboards/f/JB_zdOUWk/gdev-dashboards',
icon: 'folder',
score: 2,
//@ts-ignore
items: [],
},
{
id: 2568,
uid: 'search-test-data',
title: 'Search test data folder',
expanded: false,
items: [],
url: '/dashboards/f/search-test-data/search-test-data-folder',
icon: 'folder',
score: 3,
},
{
id: 4074,
uid: 'iN5TFj9Zk',
title: 'Test',
expanded: false,
items: [],
url: '/dashboards/f/iN5TFj9Zk/test',
icon: 'folder',
score: 4,
},
{
id: 0,
title: 'General',
icon: 'folder-open',
score: 5,
expanded: true,
items: [
{
id: 4069,
uid: 'LCFWfl9Zz',
title: 'New dashboard Copy',
uri: 'db/new-dashboard-copy',
url: '/d/LCFWfl9Zz/new-dashboard-copy',
slug: '',
type: 'dash-db',
isStarred: false,
},
{
id: 4072,
uid: 'OzAIf_rWz',
title: 'New dashboard Copy 3',
type: 'dash-db',
isStarred: false,
},
{
id: 1,
uid: 'lBdLINUWk',
title: 'Prom dash',
type: 'dash-db',
isStarred: true,
},
],
},
];
export enum DashboardSearchItemType {
DashDB = 'dash-db',
DashHome = 'dash-home',
DashFolder = 'dash-folder',
}
export interface DashboardSection { export interface DashboardSection {
id: number; id: number;
uid?: string; uid?: string;
title: string; title: string;
expanded: boolean; expanded?: boolean;
url: string; url: string;
icon: string; icon: string;
score: number; score: number;
hideHeader?: boolean; hideHeader?: boolean;
checked: boolean; checked?: boolean;
items: DashboardSectionItem[]; items: DashboardSectionItem[];
toggle?: (section: DashboardSection) => Promise<DashboardSection>; toggle?: (section: DashboardSection) => Promise<DashboardSection>;
selected?: boolean; selected?: boolean;
type: DashboardSearchItemType;
} }
export interface DashboardSectionItem { export interface DashboardSectionItem {
id: number; checked?: boolean;
uid: string;
title: string;
uri: string;
url: string;
type: string;
tags: string[];
isStarred: boolean;
folderId?: number; folderId?: number;
folderUid?: string;
folderTitle?: string; folderTitle?: string;
folderUid?: string;
folderUrl?: string; folderUrl?: string;
checked: boolean; id: number;
isStarred: boolean;
selected?: boolean; selected?: boolean;
tags: string[];
title: string;
type: DashboardSearchItemType;
uid: string;
uri: string;
url: string;
} }
export interface DashboardTag { export interface DashboardTag {
...@@ -52,3 +59,12 @@ export interface SectionsState { ...@@ -52,3 +59,12 @@ export interface SectionsState {
} }
export type ItemClickWithEvent = (item: DashboardSectionItem | DashboardSection, event: any) => void; export type ItemClickWithEvent = (item: DashboardSectionItem | DashboardSection, event: any) => void;
export type SearchAction = {
type: string;
payload?: any;
};
export interface OpenSearchParams {
query?: string;
}
import { findSelected, getFlattenedSections, markSelected } from './utils';
import { DashboardSection } from './types';
import { sections } from './testData';
describe('Search utils', () => {
describe('getFlattenedSections', () => {
it('should return an array of items plus children for expanded items', () => {
const flatSections = getFlattenedSections(sections as DashboardSection[]);
expect(flatSections).toHaveLength(10);
expect(flatSections).toEqual([
'Starred',
'Starred-1',
'Recent',
'2',
'2568',
'4074',
'0',
'0-4069',
'0-4072',
'0-1',
]);
});
describe('markSelected', () => {
it('should correctly mark the section item without id as selected', () => {
const results = markSelected(sections as any, 'Recent');
//@ts-ignore
expect(results[1].selected).toBe(true);
});
it('should correctly mark the section item with id as selected', () => {
const results = markSelected(sections as any, '4074');
//@ts-ignore
expect(results[4].selected).toBe(true);
});
it('should mark all other sections as not selected', () => {
const results = markSelected(sections as any, 'Starred');
const newResults = markSelected(results as any, '0');
//@ts-ignore
expect(newResults[0].selected).toBeFalsy();
expect(newResults[5].selected).toBeTruthy();
});
it('should correctly mark an item of a section as selected', () => {
const results = markSelected(sections as any, '0-4072');
expect(results[5].items[1].selected).toBeTruthy();
});
it('should not mark an item as selected for non-expanded section', () => {
const results = markSelected(sections as any, 'Recent-4072');
expect(results[1].items[0].selected).toBeFalsy();
});
it('should mark all other items as not selected', () => {
const results = markSelected(sections as any, '0-4069');
const newResults = markSelected(results as any, '0-1');
//@ts-ignore
expect(newResults[5].items[0].selected).toBeFalsy();
expect(newResults[5].items[1].selected).toBeFalsy();
expect(newResults[5].items[2].selected).toBeTruthy();
});
it('should correctly select one of the same items in different sections', () => {
const results = markSelected(sections as any, 'Starred-1');
expect(results[0].items[0].selected).toBeTruthy();
// Same item in diff section
expect(results[5].items[2].selected).toBeFalsy();
// Switch order
const newResults = markSelected(sections as any, '0-1');
expect(newResults[0].items[0].selected).toBeFalsy();
// Same item in diff section
expect(newResults[5].items[2].selected).toBeTruthy();
});
});
describe('findSelected', () => {
it('should find selected section', () => {
const results = [...sections, { id: 'Test', selected: true }];
const found = findSelected(results);
expect(found.id).toEqual('Test');
});
it('should find selected item', () => {
const results = [{ expanded: true, id: 'Test', items: [{ id: 1 }, { id: 2, selected: true }, { id: 3 }] }];
const found = findSelected(results);
expect(found.id).toEqual(2);
});
});
});
});
import { DashboardSection, DashboardSectionItem } from './types';
import { NO_ID_SECTIONS } from './constants';
import { parse, SearchParserResult } from 'search-query-parser';
/**
* Check if folder has id. Only Recent and Starred folders are the ones without
* ids so far, as they are created manually after results are fetched from API.
* @param str
*/
export const hasId = (str: string) => {
return !NO_ID_SECTIONS.includes(str);
};
/**
* Return ids for folders concatenated with their items ids, if section is expanded.
* For items the id format is '{folderId}-{itemId}' to allow mapping them to their folders
* @param sections
*/
export const getFlattenedSections = (sections: DashboardSection[]): string[] => {
return sections.flatMap(section => {
const id = hasId(section.title) ? String(section.id) : section.title;
if (section.expanded && section.items.length) {
return [id, ...section.items.map(item => `${id}-${item.id}`)];
}
return id;
});
};
/**
* Since Recent and Starred folders don't have id, title field is used as id
* @param title - title field of the section
*/
export const getLookupField = (title: string) => {
return hasId(title) ? 'id' : 'title';
};
/**
* Go through all the folders and items in expanded folders and toggle their selected
* prop according to currently selected index. Used for item highlighting when navigating
* the search results list using keyboard arrows
* @param sections
* @param selectedId
*/
export const markSelected = (sections: DashboardSection[], selectedId: string) => {
return sections.map((result: DashboardSection) => {
const lookupField = getLookupField(selectedId);
result = { ...result, selected: String(result[lookupField]) === selectedId };
if (result.expanded && result.items.length) {
return {
...result,
items: result.items.map(item => {
const [sectionId, itemId] = selectedId.split('-');
const lookup = getLookupField(sectionId);
return { ...item, selected: String(item.id) === itemId && String(result[lookup]) === sectionId };
}),
};
}
return result;
});
};
/**
* Find items with property 'selected' set true in a list of folders and their items.
* Does recursive search in the items list.
* @param sections
*/
export const findSelected = (sections: any): DashboardSection | DashboardSectionItem | null => {
let found = null;
for (const section of sections) {
if (section.expanded && section.items.length) {
found = findSelected(section.items);
}
if (section.selected) {
found = section;
}
if (found) {
return found;
}
}
return null;
};
// TODO check if there are any use cases where query isn't a string
export const parseQuery = (query: any) => {
const parsedQuery = parse(query, {
keywords: ['folder'],
});
if (typeof parsedQuery === 'string') {
return {
text: parsedQuery,
} as SearchParserResult;
}
return parsedQuery;
};
...@@ -4,17 +4,17 @@ export enum DashboardSearchHitType { ...@@ -4,17 +4,17 @@ export enum DashboardSearchHitType {
DashHitFolder = 'dash-folder', DashHitFolder = 'dash-folder',
} }
export interface DashboardSearchHit { export interface DashboardSearchHit {
folderId?: number;
folderTitle?: string;
folderUid?: string;
folderUrl?: string;
id: number; id: number;
uid: string; isStarred: boolean;
slug: string;
tags: string[];
title: string; title: string;
type: DashboardSearchHitType;
uid: string;
uri: string; uri: string;
url: string; url: string;
slug: string;
type: DashboardSearchHitType;
tags: string[];
isStarred: boolean;
folderId?: number;
folderUid?: string;
folderTitle?: string;
folderUrl?: string;
} }
...@@ -212,7 +212,7 @@ ...@@ -212,7 +212,7 @@
<grafana-app class="grafana-app" ng-cloak> <grafana-app class="grafana-app" ng-cloak>
<sidemenu class="sidemenu"></sidemenu> <sidemenu class="sidemenu"></sidemenu>
<app-notifications-list class="page-alert-list"></app-notifications-list> <app-notifications-list class="page-alert-list"></app-notifications-list>
<dashboard-search></dashboard-search> <search-wrapper></search-wrapper>
<div class="main-view"> <div class="main-view">
<div ng-view class="scroll-canvas"></div> <div ng-view class="scroll-canvas"></div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment