mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
dbda5aece9
commit
d04dce6a37
@ -1,4 +1,4 @@
|
||||
import React, { FC } from 'react';
|
||||
import React, { FC, memo } from 'react';
|
||||
import { cx, css } from 'emotion';
|
||||
import { OnTagClick, Tag } from './Tag';
|
||||
|
||||
@ -9,7 +9,7 @@ export interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const TagList: FC<Props> = ({ tags, onClick, className }) => {
|
||||
export const TagList: FC<Props> = memo(({ tags, onClick, className }) => {
|
||||
const styles = getStyles();
|
||||
|
||||
return (
|
||||
@ -19,7 +19,7 @@ export const TagList: FC<Props> = ({ tags, onClick, className }) => {
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const getStyles = () => {
|
||||
return {
|
||||
|
@ -29,7 +29,7 @@ import {
|
||||
SaveDashboardButtonConnected,
|
||||
} from '../features/dashboard/components/SaveDashboard/SaveDashboardButton';
|
||||
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() {
|
||||
react2AngularDirective('footer', Footer, []);
|
||||
@ -87,6 +87,7 @@ export function registerAngularDirectives() {
|
||||
['onStarredFilterChange', { watchDepth: 'reference' }],
|
||||
['onTagFilterChange', { watchDepth: 'reference' }],
|
||||
]);
|
||||
react2AngularDirective('searchWrapper', SearchWrapper, []);
|
||||
react2AngularDirective('tagFilter', TagFilter, [
|
||||
'tags',
|
||||
['onChange', { watchDepth: 'reference' }],
|
||||
|
@ -22,6 +22,7 @@ export interface Section {
|
||||
checked: boolean;
|
||||
hideHeader: boolean;
|
||||
toggle: Function;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface FoldersAndDashboardUids {
|
||||
|
@ -12,7 +12,6 @@
|
||||
<div class="search-dropdown__col_1">
|
||||
<div class="search-results-scroller">
|
||||
<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
|
||||
results="ctrl.results"
|
||||
on-tag-selected="ctrl.filterByTag"
|
||||
|
@ -6,7 +6,7 @@ import store from 'app/core/store';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { backendSrv } from './backend_srv';
|
||||
import { Section } from '../components/manage_dashboards/manage_dashboards';
|
||||
import { DashboardSearchHit } from 'app/types/search';
|
||||
import { DashboardSearchHit, DashboardSearchHitType } from 'app/types/search';
|
||||
|
||||
interface Sections {
|
||||
[key: string]: Partial<Section>;
|
||||
@ -32,6 +32,7 @@ export class SearchSrv {
|
||||
expanded: this.recentIsOpen,
|
||||
toggle: this.toggleRecent.bind(this),
|
||||
items: result,
|
||||
type: DashboardSearchHitType.DashHitFolder,
|
||||
};
|
||||
}
|
||||
});
|
||||
@ -86,6 +87,7 @@ export class SearchSrv {
|
||||
expanded: this.starredIsOpen,
|
||||
toggle: this.toggleStarred.bind(this),
|
||||
items: result,
|
||||
type: DashboardSearchHitType.DashHitFolder,
|
||||
};
|
||||
}
|
||||
});
|
||||
@ -143,6 +145,7 @@ export class SearchSrv {
|
||||
url: hit.url,
|
||||
icon: 'folder',
|
||||
score: _.keys(sections).length,
|
||||
type: hit.type,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -164,6 +167,7 @@ export class SearchSrv {
|
||||
icon: 'folder-open',
|
||||
toggle: this.toggleFolder.bind(this),
|
||||
score: _.keys(sections).length,
|
||||
type: DashboardSearchHitType.DashHitFolder,
|
||||
};
|
||||
} else {
|
||||
section = {
|
||||
@ -173,6 +177,7 @@ export class SearchSrv {
|
||||
icon: 'folder-open',
|
||||
toggle: this.toggleFolder.bind(this),
|
||||
score: _.keys(sections).length,
|
||||
type: DashboardSearchHitType.DashHitFolder,
|
||||
};
|
||||
}
|
||||
// add section
|
||||
|
106
public/app/features/search/components/DashboardSearch.test.tsx
Normal file
106
public/app/features/search/components/DashboardSearch.test.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
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: [],
|
||||
});
|
||||
});
|
||||
});
|
206
public/app/features/search/components/DashboardSearch.tsx
Normal file
206
public/app/features/search/components/DashboardSearch.tsx
Normal file
@ -0,0 +1,206 @@
|
||||
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';
|
||||
import { Forms, stylesFactory } from '@grafana/ui';
|
||||
|
||||
interface Props {
|
||||
checked: boolean;
|
||||
checked?: boolean;
|
||||
onClick: any;
|
||||
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();
|
||||
|
||||
return (
|
||||
editable && (
|
||||
<div onClick={onClick} className={styles.wrapper}>
|
||||
<Forms.Checkbox value={checked} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
return editable ? (
|
||||
<div onClick={onClick} className={styles.wrapper}>
|
||||
<Forms.Checkbox value={checked} />
|
||||
</div>
|
||||
) : null;
|
||||
});
|
||||
|
||||
const getStyles = stylesFactory(() => ({
|
||||
|
@ -1,14 +1,12 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
// @ts-ignore
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { ThemeContext, Icon } from '@grafana/ui';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { SearchQuery } from 'app/core/components/search/search';
|
||||
|
||||
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;
|
||||
onChange: (query: string) => void;
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
|
@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { shallow, mount } from 'enzyme';
|
||||
import { SearchItem, Props } from './SearchItem';
|
||||
import { Tag } from '@grafana/ui';
|
||||
import { SearchItem, Props } from './SearchItem';
|
||||
import { DashboardSearchItemType } from '../types';
|
||||
|
||||
const data = {
|
||||
id: 1,
|
||||
@ -10,8 +11,7 @@ const data = {
|
||||
uri: 'db/test1',
|
||||
url: '/d/lBdLINUWk/test1',
|
||||
slug: '',
|
||||
type: 'dash-db',
|
||||
//@ts-ignore
|
||||
type: DashboardSearchItemType.DashDB,
|
||||
tags: ['Tag1', 'Tag2'],
|
||||
isStarred: false,
|
||||
checked: false,
|
||||
|
@ -11,34 +11,40 @@ import { SearchCheckbox } from './SearchCheckbox';
|
||||
export interface Props {
|
||||
item: DashboardSectionItem;
|
||||
editable?: boolean;
|
||||
onToggleSelection: ItemClickWithEvent;
|
||||
onToggleSelection?: ItemClickWithEvent;
|
||||
onTagSelected: (name: string) => any;
|
||||
}
|
||||
|
||||
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 styles = getResultsItemStyles(theme);
|
||||
const inputEl = useRef(null);
|
||||
const inputEl = useRef<HTMLInputElement>(null);
|
||||
|
||||
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
|
||||
event.preventDefault();
|
||||
});
|
||||
};
|
||||
if (inputEl.current) {
|
||||
inputEl.current.addEventListener('click', preventDef);
|
||||
}
|
||||
return () => {
|
||||
inputEl.current!.removeEventListener('click', preventDef);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onItemClick = () => {
|
||||
//Check if one string can be found in the other
|
||||
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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleItem = useCallback(
|
||||
(event: React.MouseEvent<HTMLElement>) => {
|
||||
|
@ -1,61 +1,12 @@
|
||||
import React from 'react';
|
||||
import { shallow, mount } from 'enzyme';
|
||||
import { SearchResults, Props } from './SearchResults';
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
import { searchResults } from '../testData';
|
||||
|
||||
const setup = (propOverrides?: Partial<Props>, renderMethod = shallow) => {
|
||||
const props: Props = {
|
||||
//@ts-ignore
|
||||
results: data,
|
||||
results: searchResults,
|
||||
onSelectionChanged: () => {},
|
||||
onTagSelected: (name: string) => {},
|
||||
onFolderExpanding: () => {},
|
||||
|
@ -1,48 +1,61 @@
|
||||
import React, { FC } from 'react';
|
||||
import React, { FC, Dispatch } from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { Icon, IconName, stylesFactory, useTheme } from '@grafana/ui';
|
||||
import { DashboardSection, ItemClickWithEvent } from '../types';
|
||||
import { Icon, stylesFactory, useTheme, IconName } from '@grafana/ui';
|
||||
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 { SearchCheckbox } from './SearchCheckbox';
|
||||
|
||||
export interface Props {
|
||||
results: DashboardSection[] | undefined;
|
||||
onSelectionChanged: () => void;
|
||||
dispatch?: Dispatch<SearchAction>;
|
||||
editable?: boolean;
|
||||
loading?: boolean;
|
||||
onFolderExpanding?: () => void;
|
||||
onSelectionChanged?: () => void;
|
||||
onTagSelected: (name: string) => any;
|
||||
onFolderExpanding: () => void;
|
||||
onToggleSelection: ItemClickWithEvent;
|
||||
editable: boolean;
|
||||
onToggleSection?: any;
|
||||
onToggleSelection?: ItemClickWithEvent;
|
||||
results: DashboardSection[] | undefined;
|
||||
}
|
||||
|
||||
export const SearchResults: FC<Props> = ({
|
||||
results,
|
||||
editable,
|
||||
loading,
|
||||
onFolderExpanding,
|
||||
onSelectionChanged,
|
||||
onTagSelected,
|
||||
onFolderExpanding,
|
||||
onToggleSection,
|
||||
onToggleSelection,
|
||||
editable,
|
||||
results,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const styles = getSectionStyles(theme);
|
||||
|
||||
const toggleFolderExpand = (section: DashboardSection) => {
|
||||
if (section.toggle) {
|
||||
if (!section.expanded && onFolderExpanding) {
|
||||
onFolderExpanding();
|
||||
}
|
||||
|
||||
section.toggle(section).then(() => {
|
||||
if (onSelectionChanged) {
|
||||
onSelectionChanged();
|
||||
if (onToggleSection) {
|
||||
onToggleSection(section);
|
||||
} else {
|
||||
if (section.toggle) {
|
||||
if (!section.expanded && onFolderExpanding) {
|
||||
onFolderExpanding();
|
||||
}
|
||||
});
|
||||
|
||||
section.toggle(section).then(() => {
|
||||
if (onSelectionChanged) {
|
||||
onSelectionChanged();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// TODO display 'No results' messages after manage dashboards is refactored
|
||||
if (!results) {
|
||||
return null;
|
||||
if (loading) {
|
||||
return <PageLoader />;
|
||||
} else if (!results || !results.length) {
|
||||
return <h6>No dashboards matching your query were found.</h6>;
|
||||
}
|
||||
|
||||
return (
|
||||
@ -79,11 +92,16 @@ const getSectionStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
interface SectionHeaderProps {
|
||||
section: DashboardSection;
|
||||
onSectionClick: (section: DashboardSection) => void;
|
||||
onToggleSelection: ItemClickWithEvent;
|
||||
editable: boolean;
|
||||
onToggleSelection?: ItemClickWithEvent;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
const SectionHeader: FC<SectionHeaderProps> = ({ section, onSectionClick, onToggleSelection, editable }) => {
|
||||
const SectionHeader: FC<SectionHeaderProps> = ({
|
||||
section,
|
||||
onSectionClick,
|
||||
onToggleSelection = () => {},
|
||||
editable = false,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const styles = getSectionHeaderStyles(theme, section.selected);
|
||||
|
||||
@ -102,7 +120,11 @@ const SectionHeader: FC<SectionHeaderProps> = ({ section, onSectionClick, onTogg
|
||||
|
||||
<span className={styles.text}>{section.title}</span>
|
||||
{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" />
|
||||
</a>
|
||||
)}
|
||||
|
39
public/app/features/search/components/SearchWrapper.tsx
Normal file
39
public/app/features/search/components/SearchWrapper.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
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;
|
||||
};
|
10
public/app/features/search/components/mocks.ts
Normal file
10
public/app/features/search/components/mocks.ts
Normal file
@ -0,0 +1,10 @@
|
||||
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'])) };
|
||||
}),
|
||||
};
|
||||
});
|
1
public/app/features/search/constants.ts
Normal file
1
public/app/features/search/constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const NO_ID_SECTIONS = ['Recent', 'Starred'];
|
@ -2,5 +2,6 @@ export { SearchResults } from './components/SearchResults';
|
||||
export { SearchField } from './components/SearchField';
|
||||
export { SearchItem } from './components/SearchItem';
|
||||
export { SearchCheckbox } from './components/SearchCheckbox';
|
||||
export { SearchWrapper } from './components/SearchWrapper';
|
||||
export { SearchResultsFilter } from './components/SearchResultsFilter';
|
||||
export * from './types';
|
||||
|
5
public/app/features/search/reducers/actionTypes.ts
Normal file
5
public/app/features/search/reducers/actionTypes.ts
Normal file
@ -0,0 +1,5 @@
|
||||
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';
|
100
public/app/features/search/reducers/dashboardSearch.test.ts
Normal file
100
public/app/features/search/reducers/dashboardSearch.test.ts
Normal file
@ -0,0 +1,100 @@
|
||||
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();
|
||||
});
|
||||
});
|
80
public/app/features/search/reducers/dashboardSearch.ts
Normal file
80
public/app/features/search/reducers/dashboardSearch.ts
Normal file
@ -0,0 +1,80 @@
|
||||
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;
|
||||
}
|
||||
};
|
170
public/app/features/search/testData.ts
Normal file
170
public/app/features/search/testData.ts
Normal file
@ -0,0 +1,170 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
@ -1,33 +1,40 @@
|
||||
export enum DashboardSearchItemType {
|
||||
DashDB = 'dash-db',
|
||||
DashHome = 'dash-home',
|
||||
DashFolder = 'dash-folder',
|
||||
}
|
||||
|
||||
export interface DashboardSection {
|
||||
id: number;
|
||||
uid?: string;
|
||||
title: string;
|
||||
expanded: boolean;
|
||||
expanded?: boolean;
|
||||
url: string;
|
||||
icon: string;
|
||||
score: number;
|
||||
hideHeader?: boolean;
|
||||
checked: boolean;
|
||||
checked?: boolean;
|
||||
items: DashboardSectionItem[];
|
||||
toggle?: (section: DashboardSection) => Promise<DashboardSection>;
|
||||
selected?: boolean;
|
||||
type: DashboardSearchItemType;
|
||||
}
|
||||
|
||||
export interface DashboardSectionItem {
|
||||
checked?: boolean;
|
||||
folderId?: number;
|
||||
folderTitle?: string;
|
||||
folderUid?: string;
|
||||
folderUrl?: string;
|
||||
id: number;
|
||||
uid: string;
|
||||
isStarred: boolean;
|
||||
selected?: boolean;
|
||||
tags: string[];
|
||||
title: string;
|
||||
type: DashboardSearchItemType;
|
||||
uid: string;
|
||||
uri: string;
|
||||
url: string;
|
||||
type: string;
|
||||
tags: string[];
|
||||
isStarred: boolean;
|
||||
folderId?: number;
|
||||
folderUid?: string;
|
||||
folderTitle?: string;
|
||||
folderUrl?: string;
|
||||
checked: boolean;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardTag {
|
||||
@ -52,3 +59,12 @@ export interface SectionsState {
|
||||
}
|
||||
|
||||
export type ItemClickWithEvent = (item: DashboardSectionItem | DashboardSection, event: any) => void;
|
||||
|
||||
export type SearchAction = {
|
||||
type: string;
|
||||
payload?: any;
|
||||
};
|
||||
|
||||
export interface OpenSearchParams {
|
||||
query?: string;
|
||||
}
|
||||
|
94
public/app/features/search/utils.test.ts
Normal file
94
public/app/features/search/utils.test.ts
Normal file
@ -0,0 +1,94 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
99
public/app/features/search/utils.ts
Normal file
99
public/app/features/search/utils.ts
Normal file
@ -0,0 +1,99 @@
|
||||
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 {
|
||||
DashHitFolder = 'dash-folder',
|
||||
}
|
||||
export interface DashboardSearchHit {
|
||||
folderId?: number;
|
||||
folderTitle?: string;
|
||||
folderUid?: string;
|
||||
folderUrl?: string;
|
||||
id: number;
|
||||
uid: string;
|
||||
isStarred: boolean;
|
||||
slug: string;
|
||||
tags: string[];
|
||||
title: string;
|
||||
type: DashboardSearchHitType;
|
||||
uid: string;
|
||||
uri: string;
|
||||
url: string;
|
||||
slug: string;
|
||||
type: DashboardSearchHitType;
|
||||
tags: string[];
|
||||
isStarred: boolean;
|
||||
folderId?: number;
|
||||
folderUid?: string;
|
||||
folderTitle?: string;
|
||||
folderUrl?: string;
|
||||
}
|
||||
|
@ -212,7 +212,7 @@
|
||||
<grafana-app class="grafana-app" ng-cloak>
|
||||
<sidemenu class="sidemenu"></sidemenu>
|
||||
<app-notifications-list class="page-alert-list"></app-notifications-list>
|
||||
<dashboard-search></dashboard-search>
|
||||
<search-wrapper></search-wrapper>
|
||||
|
||||
<div class="main-view">
|
||||
<div ng-view class="scroll-canvas"></div>
|
||||
|
Loading…
Reference in New Issue
Block a user