diff --git a/packages/grafana-ui/src/components/Tags/TagList.tsx b/packages/grafana-ui/src/components/Tags/TagList.tsx index bd4b1fa11a5..4d0db762307 100644 --- a/packages/grafana-ui/src/components/Tags/TagList.tsx +++ b/packages/grafana-ui/src/components/Tags/TagList.tsx @@ -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 = ({ tags, onClick, className }) => { +export const TagList: FC = memo(({ tags, onClick, className }) => { const styles = getStyles(); return ( @@ -19,7 +19,7 @@ export const TagList: FC = ({ tags, onClick, className }) => { ))} ); -}; +}); const getStyles = () => { return { diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index 60ccc0cd94d..48580986501 100644 --- a/public/app/core/angular_wrappers.ts +++ b/public/app/core/angular_wrappers.ts @@ -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' }], diff --git a/public/app/core/components/manage_dashboards/manage_dashboards.ts b/public/app/core/components/manage_dashboards/manage_dashboards.ts index de09e3c23e0..46328dbf6fe 100644 --- a/public/app/core/components/manage_dashboards/manage_dashboards.ts +++ b/public/app/core/components/manage_dashboards/manage_dashboards.ts @@ -22,6 +22,7 @@ export interface Section { checked: boolean; hideHeader: boolean; toggle: Function; + type?: string; } export interface FoldersAndDashboardUids { diff --git a/public/app/core/components/search/search.html b/public/app/core/components/search/search.html index d53ab9089fb..66451b10647 100644 --- a/public/app/core/components/search/search.html +++ b/public/app/core/components/search/search.html @@ -12,7 +12,6 @@
-
No dashboards matching your query were found.
; @@ -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 diff --git a/public/app/features/search/components/DashboardSearch.test.tsx b/public/app/features/search/components/DashboardSearch.test.tsx new file mode 100644 index 00000000000..3f7b0d7bdd3 --- /dev/null +++ b/public/app/features/search/components/DashboardSearch.test.tsx @@ -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( {}} />); + 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( {}} />); + 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( {}} />); + 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( {}} />); + 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( {}} />); + 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: [], + }); + }); +}); diff --git a/public/app/features/search/components/DashboardSearch.tsx b/public/app/features/search/components/DashboardSearch.tsx new file mode 100644 index 00000000000..54508604a5c --- /dev/null +++ b/public/app/features/search/components/DashboardSearch.tsx @@ -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 = ({ 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) => { + 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) => { + 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 ( +
+ +
+
+ +
+ +
+
+
+
+
+
+ + Filter by: + {query.tags.length > 0 && ( + + Clear + + )} +
+ + +
+ + {canEdit && ( + + )} +
+ +
+
+ ); +}; + +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; + } + `, + }; +}); diff --git a/public/app/features/search/components/SearchCheckbox.tsx b/public/app/features/search/components/SearchCheckbox.tsx index ddbb6b2c2a6..1fd13f09919 100644 --- a/public/app/features/search/components/SearchCheckbox.tsx +++ b/public/app/features/search/components/SearchCheckbox.tsx @@ -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 = memo(({ checked = false, onClick, editable = false }) => { +export const SearchCheckbox: FC = memo(({ onClick, checked = false, editable = false }) => { const styles = getStyles(); - return ( - editable && ( -
- -
- ) - ); + return editable ? ( +
+ +
+ ) : null; }); const getStyles = stylesFactory(() => ({ diff --git a/public/app/features/search/components/SearchField.tsx b/public/app/features/search/components/SearchField.tsx index 6d57b730be8..2257d356ed2 100644 --- a/public/app/features/search/components/SearchField.tsx +++ b/public/app/features/search/components/SearchField.tsx @@ -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 = Pick>; -interface SearchFieldProps extends Omit, 'onChange'> { +interface SearchFieldProps extends Omit, 'onChange'> { query: SearchQuery; onChange: (query: string) => void; onKeyDown: (e: React.KeyboardEvent) => void; diff --git a/public/app/features/search/components/SearchItem.test.tsx b/public/app/features/search/components/SearchItem.test.tsx index 6ef73e50f69..698b1856577 100644 --- a/public/app/features/search/components/SearchItem.test.tsx +++ b/public/app/features/search/components/SearchItem.test.tsx @@ -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, diff --git a/public/app/features/search/components/SearchItem.tsx b/public/app/features/search/components/SearchItem.tsx index 5ed8eab23ed..c196e7d692c 100644 --- a/public/app/features/search/components/SearchItem.tsx +++ b/public/app/features/search/components/SearchItem.tsx @@ -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 = ({ item, editable, onToggleSelection, onTagSelected }) => { +export const SearchItem: FC = ({ item, editable, onToggleSelection = () => {}, onTagSelected }) => { const theme = useTheme(); const styles = getResultsItemStyles(theme); - const inputEl = useRef(null); + const inputEl = useRef(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) => { + const tagSelected = useCallback((tag: string, event: React.MouseEvent) => { onTagSelected(tag); - }; + }, []); const toggleItem = useCallback( (event: React.MouseEvent) => { diff --git a/public/app/features/search/components/SearchResults.test.tsx b/public/app/features/search/components/SearchResults.test.tsx index bc53da4fd6b..5ace9adf94d 100644 --- a/public/app/features/search/components/SearchResults.test.tsx +++ b/public/app/features/search/components/SearchResults.test.tsx @@ -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, renderMethod = shallow) => { const props: Props = { //@ts-ignore - results: data, + results: searchResults, onSelectionChanged: () => {}, onTagSelected: (name: string) => {}, onFolderExpanding: () => {}, diff --git a/public/app/features/search/components/SearchResults.tsx b/public/app/features/search/components/SearchResults.tsx index af46bcff076..8af8c2fedd0 100644 --- a/public/app/features/search/components/SearchResults.tsx +++ b/public/app/features/search/components/SearchResults.tsx @@ -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; + 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 = ({ - 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 ; + } else if (!results || !results.length) { + return
No dashboards matching your query were found.
; } 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 = ({ section, onSectionClick, onToggleSelection, editable }) => { +const SectionHeader: FC = ({ + section, + onSectionClick, + onToggleSelection = () => {}, + editable = false, +}) => { const theme = useTheme(); const styles = getSectionHeaderStyles(theme, section.selected); @@ -102,7 +120,11 @@ const SectionHeader: FC = ({ section, onSectionClick, onTogg {section.title} {section.url && ( - + appEvents.emit(CoreEvents.hideDashSearch, { target: 'search-item' })} + > )} diff --git a/public/app/features/search/components/SearchWrapper.tsx b/public/app/features/search/components/SearchWrapper.tsx new file mode 100644 index 00000000000..d6fb0dd5dd3 --- /dev/null +++ b/public/app/features/search/components/SearchWrapper.tsx @@ -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 ? ( + <> +
+ setIsOpen(false)} payload={payload} /> + + ) : null; +}; diff --git a/public/app/features/search/components/mocks.ts b/public/app/features/search/components/mocks.ts new file mode 100644 index 00000000000..2035138fbc3 --- /dev/null +++ b/public/app/features/search/components/mocks.ts @@ -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'])) }; + }), + }; +}); diff --git a/public/app/features/search/constants.ts b/public/app/features/search/constants.ts new file mode 100644 index 00000000000..6ad4e794046 --- /dev/null +++ b/public/app/features/search/constants.ts @@ -0,0 +1 @@ +export const NO_ID_SECTIONS = ['Recent', 'Starred']; diff --git a/public/app/features/search/index.ts b/public/app/features/search/index.ts index 9b296c6709a..23422cd5fdb 100644 --- a/public/app/features/search/index.ts +++ b/public/app/features/search/index.ts @@ -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'; diff --git a/public/app/features/search/reducers/actionTypes.ts b/public/app/features/search/reducers/actionTypes.ts new file mode 100644 index 00000000000..d65ceb67c3f --- /dev/null +++ b/public/app/features/search/reducers/actionTypes.ts @@ -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'; diff --git a/public/app/features/search/reducers/dashboardSearch.test.ts b/public/app/features/search/reducers/dashboardSearch.test.ts new file mode 100644 index 00000000000..156df866edf --- /dev/null +++ b/public/app/features/search/reducers/dashboardSearch.test.ts @@ -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(); + }); +}); diff --git a/public/app/features/search/reducers/dashboardSearch.ts b/public/app/features/search/reducers/dashboardSearch.ts new file mode 100644 index 00000000000..bd4cc0a9a52 --- /dev/null +++ b/public/app/features/search/reducers/dashboardSearch.ts @@ -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; + } +}; diff --git a/public/app/features/search/testData.ts b/public/app/features/search/testData.ts new file mode 100644 index 00000000000..4639f945108 --- /dev/null +++ b/public/app/features/search/testData.ts @@ -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, + }, + ], + }, +]; diff --git a/public/app/features/search/types.ts b/public/app/features/search/types.ts index 0572caaef23..830c1d3ec22 100644 --- a/public/app/features/search/types.ts +++ b/public/app/features/search/types.ts @@ -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; 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; +} diff --git a/public/app/features/search/utils.test.ts b/public/app/features/search/utils.test.ts new file mode 100644 index 00000000000..351ae416e34 --- /dev/null +++ b/public/app/features/search/utils.test.ts @@ -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); + }); + }); + }); +}); diff --git a/public/app/features/search/utils.ts b/public/app/features/search/utils.ts new file mode 100644 index 00000000000..f489af7553e --- /dev/null +++ b/public/app/features/search/utils.ts @@ -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; +}; diff --git a/public/app/types/search.ts b/public/app/types/search.ts index 14ddcbb7196..4dc8449a858 100644 --- a/public/app/types/search.ts +++ b/public/app/types/search.ts @@ -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; } diff --git a/public/views/index-template.html b/public/views/index-template.html index e4f72066203..68b0eae1813 100644 --- a/public/views/index-template.html +++ b/public/views/index-template.html @@ -212,7 +212,7 @@ - +