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 { cx, css } from 'emotion';
|
||||||
import { OnTagClick, Tag } from './Tag';
|
import { OnTagClick, Tag } from './Tag';
|
||||||
|
|
||||||
@ -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 }) => {
|
|||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
const getStyles = () => {
|
const getStyles = () => {
|
||||||
return {
|
return {
|
||||||
|
@ -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() {
|
|||||||
['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 {
|
|||||||
checked: boolean;
|
checked: boolean;
|
||||||
hideHeader: boolean;
|
hideHeader: boolean;
|
||||||
toggle: Function;
|
toggle: Function;
|
||||||
|
type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FoldersAndDashboardUids {
|
export interface FoldersAndDashboardUids {
|
||||||
|
@ -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';
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
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
|
||||||
|
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';
|
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(() => ({
|
||||||
|
@ -1,14 +1,12 @@
|
|||||||
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;
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
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 = {
|
|||||||
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';
|
|||||||
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>) => {
|
||||||
|
@ -1,61 +1,12 @@
|
|||||||
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: () => {},
|
||||||
|
@ -1,48 +1,61 @@
|
|||||||
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 (section.toggle) {
|
if (onToggleSection) {
|
||||||
if (!section.expanded && onFolderExpanding) {
|
onToggleSection(section);
|
||||||
onFolderExpanding();
|
} else {
|
||||||
}
|
if (section.toggle) {
|
||||||
|
if (!section.expanded && onFolderExpanding) {
|
||||||
section.toggle(section).then(() => {
|
onFolderExpanding();
|
||||||
if (onSelectionChanged) {
|
|
||||||
onSelectionChanged();
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
section.toggle(section).then(() => {
|
||||||
|
if (onSelectionChanged) {
|
||||||
|
onSelectionChanged();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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) => {
|
|||||||
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
|
|||||||
|
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
|
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 { 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';
|
||||||
|
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 {
|
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 {
|
||||||
|
checked?: boolean;
|
||||||
|
folderId?: number;
|
||||||
|
folderTitle?: string;
|
||||||
|
folderUid?: string;
|
||||||
|
folderUrl?: string;
|
||||||
id: number;
|
id: number;
|
||||||
uid: string;
|
isStarred: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
tags: string[];
|
||||||
title: string;
|
title: string;
|
||||||
|
type: DashboardSearchItemType;
|
||||||
|
uid: string;
|
||||||
uri: string;
|
uri: string;
|
||||||
url: string;
|
url: string;
|
||||||
type: string;
|
|
||||||
tags: string[];
|
|
||||||
isStarred: boolean;
|
|
||||||
folderId?: number;
|
|
||||||
folderUid?: string;
|
|
||||||
folderTitle?: string;
|
|
||||||
folderUrl?: string;
|
|
||||||
checked: boolean;
|
|
||||||
selected?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardTag {
|
export interface DashboardTag {
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
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',
|
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 @@
|
|||||||
<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>
|
||||||
|
Loading…
Reference in New Issue
Block a user