mirror of https://github.com/grafana/grafana.git
				
				
				
			Search/refactor dashboard search (#23274)
* Search: add search wrapper * Search: add DashboardSearch.tsx * Search: enable search * Search: update types * Search: useReducer for saving search results * Search: use default query * Search: add toggle custom action * Search: add onQueryChange * Search: debounce search * Search: pas dispatch as a prop * Search: add tag filter * Search: Fix types * Search: revert changes * Search: close overlay on esc * Search: enable tag filtering * Search: clear query * Search: add autofocus to search field * Search: Rename close to closeSearch * Search: Add no results message * Search: Add loading state * Search: Remove Select from Forms namespace * Remove Add selectedIndex * Remove Add getFlattenedSections * Remove Enable selecting items * Search: add hasId * Search: preselect first item * Search: Add utils tests * Search: Fix moving selection down * Search: Add findSelected * Search: Add type to section * Search: Handle Enter key press on item highlight * Search: Move reducer et al. to separate files * Search: Remove redundant render check * Search: Close overlay on Esc and ArrowLeft press * Search: Add close button * Search: Document utils * Search: use Icon for remove icon * Search: Add DashboardSearch.test.tsx * Search: Move test data to a separate file * Search: Finalise DashboardSearch.test.tsx * Add search reducer tests * Search: Add search results loading indicator * Search: Remove inline function * Search: Do not mutate item * Search: Tweak utils * Search: Do not clear query on tag clear * Search: Fix folder:current search * Search: Fix results scroll * Search: Update tests * Search: Close overlay on cog icon click * Add mobile styles for close button * Search: Use CustomScrollbar * Search: Memoize TagList.tsx * Search: Fix type errors * Search: More strictNullChecks fixes * Search: Consistent handler names * Search: Fix search items types in test * Search: Fix merge conflicts * Search: Fix strictNullChecks errors
This commit is contained in:
		
							parent
							
								
									dbda5aece9
								
							
						
					
					
						commit
						d04dce6a37
					
				|  | @ -1,4 +1,4 @@ | |||
| import React, { FC } from 'react'; | ||||
| import React, { FC, memo } from 'react'; | ||||
| import { cx, css } from 'emotion'; | ||||
| import { OnTagClick, Tag } from './Tag'; | ||||
| 
 | ||||
|  | @ -9,7 +9,7 @@ export interface Props { | |||
|   className?: string; | ||||
| } | ||||
| 
 | ||||
| export const TagList: FC<Props> = ({ tags, onClick, className }) => { | ||||
| export const TagList: FC<Props> = memo(({ tags, onClick, className }) => { | ||||
|   const styles = getStyles(); | ||||
| 
 | ||||
|   return ( | ||||
|  | @ -19,7 +19,7 @@ export const TagList: FC<Props> = ({ tags, onClick, className }) => { | |||
|       ))} | ||||
|     </span> | ||||
|   ); | ||||
| }; | ||||
| }); | ||||
| 
 | ||||
| const getStyles = () => { | ||||
|   return { | ||||
|  |  | |||
|  | @ -29,7 +29,7 @@ import { | |||
|   SaveDashboardButtonConnected, | ||||
| } from '../features/dashboard/components/SaveDashboard/SaveDashboardButton'; | ||||
| import { VariableEditorContainer } from '../features/variables/editor/VariableEditorContainer'; | ||||
| import { SearchField, SearchResults, SearchResultsFilter } from '../features/search'; | ||||
| import { SearchField, SearchResults, SearchWrapper, SearchResultsFilter } from '../features/search'; | ||||
| 
 | ||||
| export function registerAngularDirectives() { | ||||
|   react2AngularDirective('footer', Footer, []); | ||||
|  | @ -87,6 +87,7 @@ export function registerAngularDirectives() { | |||
|     ['onStarredFilterChange', { watchDepth: 'reference' }], | ||||
|     ['onTagFilterChange', { watchDepth: 'reference' }], | ||||
|   ]); | ||||
|   react2AngularDirective('searchWrapper', SearchWrapper, []); | ||||
|   react2AngularDirective('tagFilter', TagFilter, [ | ||||
|     'tags', | ||||
|     ['onChange', { watchDepth: 'reference' }], | ||||
|  |  | |||
|  | @ -22,6 +22,7 @@ export interface Section { | |||
|   checked: boolean; | ||||
|   hideHeader: boolean; | ||||
|   toggle: Function; | ||||
|   type?: string; | ||||
| } | ||||
| 
 | ||||
| export interface FoldersAndDashboardUids { | ||||
|  |  | |||
|  | @ -12,7 +12,6 @@ | |||
|     <div class="search-dropdown__col_1"> | ||||
|       <div class="search-results-scroller"> | ||||
|         <div class="search-results-container" grafana-scrollbar> | ||||
|           <h6 ng-show="!ctrl.isLoading && ctrl.results.length === 0">No dashboards matching your query were found.</h6> | ||||
|           <search-results | ||||
|             results="ctrl.results" | ||||
|             on-tag-selected="ctrl.filterByTag" | ||||
|  |  | |||
|  | @ -6,7 +6,7 @@ import store from 'app/core/store'; | |||
| import { contextSrv } from 'app/core/services/context_srv'; | ||||
| import { backendSrv } from './backend_srv'; | ||||
| import { Section } from '../components/manage_dashboards/manage_dashboards'; | ||||
| import { DashboardSearchHit } from 'app/types/search'; | ||||
| import { DashboardSearchHit, DashboardSearchHitType } from 'app/types/search'; | ||||
| 
 | ||||
| interface Sections { | ||||
|   [key: string]: Partial<Section>; | ||||
|  | @ -32,6 +32,7 @@ export class SearchSrv { | |||
|           expanded: this.recentIsOpen, | ||||
|           toggle: this.toggleRecent.bind(this), | ||||
|           items: result, | ||||
|           type: DashboardSearchHitType.DashHitFolder, | ||||
|         }; | ||||
|       } | ||||
|     }); | ||||
|  | @ -86,6 +87,7 @@ export class SearchSrv { | |||
|           expanded: this.starredIsOpen, | ||||
|           toggle: this.toggleStarred.bind(this), | ||||
|           items: result, | ||||
|           type: DashboardSearchHitType.DashHitFolder, | ||||
|         }; | ||||
|       } | ||||
|     }); | ||||
|  | @ -143,6 +145,7 @@ export class SearchSrv { | |||
|           url: hit.url, | ||||
|           icon: 'folder', | ||||
|           score: _.keys(sections).length, | ||||
|           type: hit.type, | ||||
|         }; | ||||
|       } | ||||
|     } | ||||
|  | @ -164,6 +167,7 @@ export class SearchSrv { | |||
|             icon: 'folder-open', | ||||
|             toggle: this.toggleFolder.bind(this), | ||||
|             score: _.keys(sections).length, | ||||
|             type: DashboardSearchHitType.DashHitFolder, | ||||
|           }; | ||||
|         } else { | ||||
|           section = { | ||||
|  | @ -173,6 +177,7 @@ export class SearchSrv { | |||
|             icon: 'folder-open', | ||||
|             toggle: this.toggleFolder.bind(this), | ||||
|             score: _.keys(sections).length, | ||||
|             type: DashboardSearchHitType.DashHitFolder, | ||||
|           }; | ||||
|         } | ||||
|         // add section
 | ||||
|  |  | |||
|  | @ -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: [], | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | @ -0,0 +1,206 @@ | |||
| import React, { FC, useReducer, useState } from 'react'; | ||||
| import { useDebounce } from 'react-use'; | ||||
| import { css } from 'emotion'; | ||||
| import { Icon, useTheme, CustomScrollbar, stylesFactory } from '@grafana/ui'; | ||||
| import { getLocationSrv } from '@grafana/runtime'; | ||||
| import { GrafanaTheme } from '@grafana/data'; | ||||
| import { SearchSrv } from 'app/core/services/search_srv'; | ||||
| import { backendSrv } from 'app/core/services/backend_srv'; | ||||
| import { SearchQuery } from 'app/core/components/search/search'; | ||||
| import { TagFilter } from 'app/core/components/TagFilter/TagFilter'; | ||||
| import { contextSrv } from 'app/core/services/context_srv'; | ||||
| import { DashboardSearchItemType, DashboardSection, OpenSearchParams } from '../types'; | ||||
| import { findSelected, hasId, parseQuery } from '../utils'; | ||||
| import { searchReducer, initialState } from '../reducers/dashboardSearch'; | ||||
| import { getDashboardSrv } from '../../dashboard/services/DashboardSrv'; | ||||
| import { | ||||
|   FETCH_ITEMS, | ||||
|   FETCH_RESULTS, | ||||
|   TOGGLE_SECTION, | ||||
|   MOVE_SELECTION_DOWN, | ||||
|   MOVE_SELECTION_UP, | ||||
| } from '../reducers/actionTypes'; | ||||
| import { SearchField } from './SearchField'; | ||||
| import { SearchResults } from './SearchResults'; | ||||
| 
 | ||||
| const searchSrv = new SearchSrv(); | ||||
| 
 | ||||
| const defaultQuery: SearchQuery = { query: '', parsedQuery: { text: '' }, tags: [], starred: false }; | ||||
| const { isEditor, hasEditPermissionInFolders } = contextSrv; | ||||
| const canEdit = isEditor || hasEditPermissionInFolders; | ||||
| 
 | ||||
| export interface Props { | ||||
|   onCloseSearch: () => void; | ||||
|   payload?: OpenSearchParams; | ||||
| } | ||||
| 
 | ||||
| export const DashboardSearch: FC<Props> = ({ onCloseSearch, payload = {} }) => { | ||||
|   const [query, setQuery] = useState({ ...defaultQuery, ...payload, parsedQuery: parseQuery(payload.query) }); | ||||
|   const [{ results, loading }, dispatch] = useReducer(searchReducer, initialState); | ||||
|   const theme = useTheme(); | ||||
|   const styles = getStyles(theme); | ||||
| 
 | ||||
|   const search = () => { | ||||
|     let folderIds: number[] = []; | ||||
|     if (query.parsedQuery.folder === 'current') { | ||||
|       const { folderId } = getDashboardSrv().getCurrent().meta; | ||||
|       if (folderId) { | ||||
|         folderIds.push(folderId); | ||||
|       } | ||||
|     } | ||||
|     searchSrv.search({ ...query, tag: query.tags, query: query.parsedQuery.text, folderIds }).then(results => { | ||||
|       dispatch({ type: FETCH_RESULTS, payload: results }); | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   useDebounce(search, 300, [query]); | ||||
| 
 | ||||
|   const onToggleSection = (section: DashboardSection) => { | ||||
|     if (hasId(section.title) && !section.items.length) { | ||||
|       backendSrv.search({ ...defaultQuery, folderIds: [section.id] }).then(items => { | ||||
|         dispatch({ type: FETCH_ITEMS, payload: { section, items } }); | ||||
|         dispatch({ type: TOGGLE_SECTION, payload: section }); | ||||
|       }); | ||||
|     } else { | ||||
|       dispatch({ type: TOGGLE_SECTION, payload: section }); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const onQueryChange = (searchQuery: string) => { | ||||
|     setQuery(q => ({ | ||||
|       ...q, | ||||
|       parsedQuery: parseQuery(searchQuery), | ||||
|       query: searchQuery, | ||||
|     })); | ||||
|   }; | ||||
| 
 | ||||
|   const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { | ||||
|     switch (event.key) { | ||||
|       case 'Escape': | ||||
|         onCloseSearch(); | ||||
|         break; | ||||
|       case 'ArrowUp': | ||||
|         dispatch({ type: MOVE_SELECTION_UP }); | ||||
|         break; | ||||
|       case 'ArrowDown': | ||||
|         dispatch({ type: MOVE_SELECTION_DOWN }); | ||||
|         break; | ||||
|       case 'Enter': | ||||
|         const selectedItem = findSelected(results); | ||||
|         if (selectedItem) { | ||||
|           if (selectedItem.type === DashboardSearchItemType.DashFolder) { | ||||
|             onToggleSection(selectedItem as DashboardSection); | ||||
|           } else { | ||||
|             getLocationSrv().update({ path: selectedItem.url }); | ||||
|             // Delay closing to prevent current page flicker
 | ||||
|             setTimeout(onCloseSearch, 0); | ||||
|           } | ||||
|         } | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   // The main search input has own keydown handler, also TagFilter uses input, so
 | ||||
|   // clicking Esc when tagFilter is active shouldn't close the whole search overlay
 | ||||
|   const onClose = (e: React.KeyboardEvent<HTMLElement>) => { | ||||
|     const target = e.target as HTMLElement; | ||||
|     if ((target.tagName as any) !== 'INPUT' && ['Escape', 'ArrowLeft'].includes(e.key)) { | ||||
|       onCloseSearch(); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const onTagFiltersChanged = (tags: string[]) => { | ||||
|     setQuery(q => ({ ...q, tags })); | ||||
|   }; | ||||
| 
 | ||||
|   const onTagSelected = (tag: string) => { | ||||
|     if (tag && !query.tags.includes(tag)) { | ||||
|       setQuery(q => ({ ...q, tags: [...q.tags, tag] })); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const onClearSearchFilters = () => { | ||||
|     setQuery(q => ({ ...q, tags: [] })); | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <div tabIndex={0} className="search-container" onKeyDown={onClose}> | ||||
|       <SearchField query={query} onChange={onQueryChange} onKeyDown={onKeyDown} autoFocus={true} /> | ||||
|       <div className="search-dropdown"> | ||||
|         <div className="search-dropdown__col_1"> | ||||
|           <CustomScrollbar> | ||||
|             <div className="search-results-container"> | ||||
|               <SearchResults | ||||
|                 results={results} | ||||
|                 loading={loading} | ||||
|                 onTagSelected={onTagSelected} | ||||
|                 dispatch={dispatch} | ||||
|                 editable={false} | ||||
|                 onToggleSection={onToggleSection} | ||||
|               /> | ||||
|             </div> | ||||
|           </CustomScrollbar> | ||||
|         </div> | ||||
|         <div className="search-dropdown__col_2"> | ||||
|           <div className="search-filter-box"> | ||||
|             <div className="search-filter-box__header"> | ||||
|               <Icon name="filter" /> | ||||
|               Filter by: | ||||
|               {query.tags.length > 0 && ( | ||||
|                 <a className="pointer pull-right small" onClick={onClearSearchFilters}> | ||||
|                   <Icon name="times" /> Clear | ||||
|                 </a> | ||||
|               )} | ||||
|             </div> | ||||
| 
 | ||||
|             <TagFilter tags={query.tags} tagOptions={searchSrv.getDashboardTags} onChange={onTagFiltersChanged} /> | ||||
|           </div> | ||||
| 
 | ||||
|           {canEdit && ( | ||||
|             <div className="search-filter-box" onClick={onCloseSearch}> | ||||
|               <a href="dashboard/new" className="search-filter-box-link"> | ||||
|                 <i className="gicon gicon-dashboard-new"></i> New dashboard | ||||
|               </a> | ||||
|               {isEditor && ( | ||||
|                 <a href="dashboards/folder/new" className="search-filter-box-link"> | ||||
|                   <i className="gicon gicon-folder-new"></i> New folder | ||||
|                 </a> | ||||
|               )} | ||||
|               <a href="dashboard/import" className="search-filter-box-link"> | ||||
|                 <i className="gicon gicon-dashboard-import"></i> Import dashboard | ||||
|               </a> | ||||
|               <a | ||||
|                 className="search-filter-box-link" | ||||
|                 target="_blank" | ||||
|                 href="https://grafana.com/dashboards?utm_source=grafana_search" | ||||
|               > | ||||
|                 <img src="public/img/icn-dashboard-tiny.svg" width="20" /> Find dashboards on Grafana.com | ||||
|               </a> | ||||
|             </div> | ||||
|           )} | ||||
|         </div> | ||||
|         <Icon onClick={onCloseSearch} className={styles.closeBtn} name="times" /> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const getStyles = stylesFactory((theme: GrafanaTheme) => { | ||||
|   return { | ||||
|     closeBtn: css` | ||||
|       font-size: 22px; | ||||
|       margin-top: 14px; | ||||
|       margin-right: 6px; | ||||
| 
 | ||||
|       &:hover { | ||||
|         cursor: pointer; | ||||
|         color: ${theme.colors.white}; | ||||
|       } | ||||
| 
 | ||||
|       @media only screen and (max-width: ${theme.breakpoints.md}) { | ||||
|         position: absolute; | ||||
|         right: 15px; | ||||
|         top: 60px; | ||||
|       } | ||||
|     `,
 | ||||
|   }; | ||||
| }); | ||||
|  | @ -3,21 +3,19 @@ import { css } from 'emotion'; | |||
| import { Forms, stylesFactory } from '@grafana/ui'; | ||||
| 
 | ||||
| interface Props { | ||||
|   checked: boolean; | ||||
|   checked?: boolean; | ||||
|   onClick: any; | ||||
|   editable?: boolean; | ||||
| } | ||||
| 
 | ||||
| export const SearchCheckbox: FC<Props> = memo(({ checked = false, onClick, editable = false }) => { | ||||
| export const SearchCheckbox: FC<Props> = memo(({ onClick, checked = false, editable = false }) => { | ||||
|   const styles = getStyles(); | ||||
| 
 | ||||
|   return ( | ||||
|     editable && ( | ||||
|   return editable ? ( | ||||
|     <div onClick={onClick} className={styles.wrapper}> | ||||
|       <Forms.Checkbox value={checked} /> | ||||
|     </div> | ||||
|     ) | ||||
|   ); | ||||
|   ) : null; | ||||
| }); | ||||
| 
 | ||||
| const getStyles = stylesFactory(() => ({ | ||||
|  |  | |||
|  | @ -1,14 +1,12 @@ | |||
| import React, { useContext } from 'react'; | ||||
| import { css, cx } from 'emotion'; | ||||
| // @ts-ignore
 | ||||
| import tinycolor from 'tinycolor2'; | ||||
| import { ThemeContext, Icon } from '@grafana/ui'; | ||||
| import { GrafanaTheme } from '@grafana/data'; | ||||
| import { SearchQuery } from 'app/core/components/search/search'; | ||||
| 
 | ||||
| type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>; | ||||
| 
 | ||||
| interface SearchFieldProps extends Omit<React.HTMLAttributes<HTMLInputElement>, 'onChange'> { | ||||
| interface SearchFieldProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> { | ||||
|   query: SearchQuery; | ||||
|   onChange: (query: string) => void; | ||||
|   onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void; | ||||
|  |  | |||
|  | @ -1,7 +1,8 @@ | |||
| import React from 'react'; | ||||
| import { shallow, mount } from 'enzyme'; | ||||
| import { SearchItem, Props } from './SearchItem'; | ||||
| import { Tag } from '@grafana/ui'; | ||||
| import { SearchItem, Props } from './SearchItem'; | ||||
| import { DashboardSearchItemType } from '../types'; | ||||
| 
 | ||||
| const data = { | ||||
|   id: 1, | ||||
|  | @ -10,8 +11,7 @@ const data = { | |||
|   uri: 'db/test1', | ||||
|   url: '/d/lBdLINUWk/test1', | ||||
|   slug: '', | ||||
|   type: 'dash-db', | ||||
|   //@ts-ignore
 | ||||
|   type: DashboardSearchItemType.DashDB, | ||||
|   tags: ['Tag1', 'Tag2'], | ||||
|   isStarred: false, | ||||
|   checked: false, | ||||
|  |  | |||
|  | @ -11,34 +11,40 @@ import { SearchCheckbox } from './SearchCheckbox'; | |||
| export interface Props { | ||||
|   item: DashboardSectionItem; | ||||
|   editable?: boolean; | ||||
|   onToggleSelection: ItemClickWithEvent; | ||||
|   onToggleSelection?: ItemClickWithEvent; | ||||
|   onTagSelected: (name: string) => any; | ||||
| } | ||||
| 
 | ||||
| const { selectors } = e2e.pages.Dashboards; | ||||
| 
 | ||||
| export const SearchItem: FC<Props> = ({ item, editable, onToggleSelection, onTagSelected }) => { | ||||
| export const SearchItem: FC<Props> = ({ item, editable, onToggleSelection = () => {}, onTagSelected }) => { | ||||
|   const theme = useTheme(); | ||||
|   const styles = getResultsItemStyles(theme); | ||||
|   const inputEl = useRef(null); | ||||
|   const inputEl = useRef<HTMLInputElement>(null); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     inputEl.current.addEventListener('click', (event: MouseEvent) => { | ||||
|     const preventDef = (event: MouseEvent) => { | ||||
|       // manually prevent default on TagList click, as doing it via normal onClick doesn't work inside angular
 | ||||
|       event.preventDefault(); | ||||
|     }); | ||||
|     }; | ||||
|     if (inputEl.current) { | ||||
|       inputEl.current.addEventListener('click', preventDef); | ||||
|     } | ||||
|     return () => { | ||||
|       inputEl.current!.removeEventListener('click', preventDef); | ||||
|     }; | ||||
|   }, []); | ||||
| 
 | ||||
|   const onItemClick = () => { | ||||
|     //Check if one string can be found in the other
 | ||||
|     if (window.location.pathname.includes(item.url) || item.url.includes(window.location.pathname)) { | ||||
|       appEvents.emit(CoreEvents.hideDashSearch); | ||||
|       appEvents.emit(CoreEvents.hideDashSearch, { target: 'search-item' }); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const tagSelected = (tag: string, event: React.MouseEvent<HTMLElement>) => { | ||||
|   const tagSelected = useCallback((tag: string, event: React.MouseEvent<HTMLElement>) => { | ||||
|     onTagSelected(tag); | ||||
|   }; | ||||
|   }, []); | ||||
| 
 | ||||
|   const toggleItem = useCallback( | ||||
|     (event: React.MouseEvent<HTMLElement>) => { | ||||
|  |  | |||
|  | @ -1,61 +1,12 @@ | |||
| import React from 'react'; | ||||
| import { shallow, mount } from 'enzyme'; | ||||
| import { SearchResults, Props } from './SearchResults'; | ||||
| 
 | ||||
| const data = [ | ||||
|   { | ||||
|     id: 2, | ||||
|     uid: 'JB_zdOUWk', | ||||
|     title: 'gdev dashboards', | ||||
|     expanded: false, | ||||
|     //@ts-ignore
 | ||||
|     items: [], | ||||
|     url: '/dashboards/f/JB_zdOUWk/gdev-dashboards', | ||||
|     icon: 'folder', | ||||
|     score: 0, | ||||
|     checked: false, | ||||
|   }, | ||||
|   { | ||||
|     id: 0, | ||||
|     title: 'General', | ||||
|     items: [ | ||||
|       { | ||||
|         id: 1, | ||||
|         uid: 'lBdLINUWk', | ||||
|         title: 'Test 1', | ||||
|         uri: 'db/test1', | ||||
|         url: '/d/lBdLINUWk/test1', | ||||
|         slug: '', | ||||
|         type: 'dash-db', | ||||
|         //@ts-ignore
 | ||||
|         tags: [], | ||||
|         isStarred: false, | ||||
|         checked: false, | ||||
|       }, | ||||
|       { | ||||
|         id: 46, | ||||
|         uid: '8DY63kQZk', | ||||
|         title: 'Test 2', | ||||
|         uri: 'db/test2', | ||||
|         url: '/d/8DY63kQZk/test2', | ||||
|         slug: '', | ||||
|         type: 'dash-db', | ||||
|         tags: [], | ||||
|         isStarred: false, | ||||
|         checked: false, | ||||
|       }, | ||||
|     ], | ||||
|     icon: 'folder-open', | ||||
|     score: 1, | ||||
|     expanded: true, | ||||
|     checked: false, | ||||
|   }, | ||||
| ]; | ||||
| import { searchResults } from '../testData'; | ||||
| 
 | ||||
| const setup = (propOverrides?: Partial<Props>, renderMethod = shallow) => { | ||||
|   const props: Props = { | ||||
|     //@ts-ignore
 | ||||
|     results: data, | ||||
|     results: searchResults, | ||||
|     onSelectionChanged: () => {}, | ||||
|     onTagSelected: (name: string) => {}, | ||||
|     onFolderExpanding: () => {}, | ||||
|  |  | |||
|  | @ -1,32 +1,43 @@ | |||
| import React, { FC } from 'react'; | ||||
| import React, { FC, Dispatch } from 'react'; | ||||
| import { css, cx } from 'emotion'; | ||||
| import { GrafanaTheme } from '@grafana/data'; | ||||
| import { Icon, IconName, stylesFactory, useTheme } from '@grafana/ui'; | ||||
| import { DashboardSection, ItemClickWithEvent } from '../types'; | ||||
| import { Icon, stylesFactory, useTheme, IconName } from '@grafana/ui'; | ||||
| import PageLoader from 'app/core/components/PageLoader/PageLoader'; | ||||
| import appEvents from 'app/core/app_events'; | ||||
| import { CoreEvents } from 'app/types'; | ||||
| import { DashboardSection, ItemClickWithEvent, SearchAction } from '../types'; | ||||
| import { SearchItem } from './SearchItem'; | ||||
| import { SearchCheckbox } from './SearchCheckbox'; | ||||
| 
 | ||||
| export interface Props { | ||||
|   results: DashboardSection[] | undefined; | ||||
|   onSelectionChanged: () => void; | ||||
|   dispatch?: Dispatch<SearchAction>; | ||||
|   editable?: boolean; | ||||
|   loading?: boolean; | ||||
|   onFolderExpanding?: () => void; | ||||
|   onSelectionChanged?: () => void; | ||||
|   onTagSelected: (name: string) => any; | ||||
|   onFolderExpanding: () => void; | ||||
|   onToggleSelection: ItemClickWithEvent; | ||||
|   editable: boolean; | ||||
|   onToggleSection?: any; | ||||
|   onToggleSelection?: ItemClickWithEvent; | ||||
|   results: DashboardSection[] | undefined; | ||||
| } | ||||
| 
 | ||||
| export const SearchResults: FC<Props> = ({ | ||||
|   results, | ||||
|   editable, | ||||
|   loading, | ||||
|   onFolderExpanding, | ||||
|   onSelectionChanged, | ||||
|   onTagSelected, | ||||
|   onFolderExpanding, | ||||
|   onToggleSection, | ||||
|   onToggleSelection, | ||||
|   editable, | ||||
|   results, | ||||
| }) => { | ||||
|   const theme = useTheme(); | ||||
|   const styles = getSectionStyles(theme); | ||||
| 
 | ||||
|   const toggleFolderExpand = (section: DashboardSection) => { | ||||
|     if (onToggleSection) { | ||||
|       onToggleSection(section); | ||||
|     } else { | ||||
|       if (section.toggle) { | ||||
|         if (!section.expanded && onFolderExpanding) { | ||||
|           onFolderExpanding(); | ||||
|  | @ -38,11 +49,13 @@ export const SearchResults: FC<Props> = ({ | |||
|           } | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   // TODO display 'No results' messages after manage dashboards is refactored
 | ||||
|   if (!results) { | ||||
|     return null; | ||||
|   if (loading) { | ||||
|     return <PageLoader />; | ||||
|   } else if (!results || !results.length) { | ||||
|     return <h6>No dashboards matching your query were found.</h6>; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|  | @ -79,11 +92,16 @@ const getSectionStyles = stylesFactory((theme: GrafanaTheme) => { | |||
| interface SectionHeaderProps { | ||||
|   section: DashboardSection; | ||||
|   onSectionClick: (section: DashboardSection) => void; | ||||
|   onToggleSelection: ItemClickWithEvent; | ||||
|   editable: boolean; | ||||
|   onToggleSelection?: ItemClickWithEvent; | ||||
|   editable?: boolean; | ||||
| } | ||||
| 
 | ||||
| const SectionHeader: FC<SectionHeaderProps> = ({ section, onSectionClick, onToggleSelection, editable }) => { | ||||
| const SectionHeader: FC<SectionHeaderProps> = ({ | ||||
|   section, | ||||
|   onSectionClick, | ||||
|   onToggleSelection = () => {}, | ||||
|   editable = false, | ||||
| }) => { | ||||
|   const theme = useTheme(); | ||||
|   const styles = getSectionHeaderStyles(theme, section.selected); | ||||
| 
 | ||||
|  | @ -102,7 +120,11 @@ const SectionHeader: FC<SectionHeaderProps> = ({ section, onSectionClick, onTogg | |||
| 
 | ||||
|       <span className={styles.text}>{section.title}</span> | ||||
|       {section.url && ( | ||||
|         <a href={section.url} className={styles.link}> | ||||
|         <a | ||||
|           href={section.url} | ||||
|           className={styles.link} | ||||
|           onClick={() => appEvents.emit(CoreEvents.hideDashSearch, { target: 'search-item' })} | ||||
|         > | ||||
|           <Icon name="cog" /> | ||||
|         </a> | ||||
|       )} | ||||
|  |  | |||
|  | @ -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; | ||||
| }; | ||||
|  | @ -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'])) }; | ||||
|     }), | ||||
|   }; | ||||
| }); | ||||
|  | @ -0,0 +1 @@ | |||
| export const NO_ID_SECTIONS = ['Recent', 'Starred']; | ||||
|  | @ -2,5 +2,6 @@ export { SearchResults } from './components/SearchResults'; | |||
| export { SearchField } from './components/SearchField'; | ||||
| export { SearchItem } from './components/SearchItem'; | ||||
| export { SearchCheckbox } from './components/SearchCheckbox'; | ||||
| export { SearchWrapper } from './components/SearchWrapper'; | ||||
| export { SearchResultsFilter } from './components/SearchResultsFilter'; | ||||
| export * from './types'; | ||||
|  |  | |||
|  | @ -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'; | ||||
|  | @ -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(); | ||||
|   }); | ||||
| }); | ||||
|  | @ -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; | ||||
|   } | ||||
| }; | ||||
|  | @ -0,0 +1,170 @@ | |||
| export const searchResults = [ | ||||
|   { | ||||
|     id: 2, | ||||
|     uid: 'JB_zdOUWk', | ||||
|     title: 'gdev dashboards', | ||||
|     expanded: false, | ||||
|     //@ts-ignore
 | ||||
|     items: [], | ||||
|     url: '/dashboards/f/JB_zdOUWk/gdev-dashboards', | ||||
|     icon: 'folder', | ||||
|     score: 0, | ||||
|     checked: false, | ||||
|   }, | ||||
|   { | ||||
|     id: 0, | ||||
|     title: 'General', | ||||
|     items: [ | ||||
|       { | ||||
|         id: 1, | ||||
|         uid: 'lBdLINUWk', | ||||
|         title: 'Test 1', | ||||
|         uri: 'db/test1', | ||||
|         url: '/d/lBdLINUWk/test1', | ||||
|         slug: '', | ||||
|         type: 'dash-db', | ||||
|         //@ts-ignore
 | ||||
|         tags: [], | ||||
|         isStarred: false, | ||||
|         checked: false, | ||||
|       }, | ||||
|       { | ||||
|         id: 46, | ||||
|         uid: '8DY63kQZk', | ||||
|         title: 'Test 2', | ||||
|         uri: 'db/test2', | ||||
|         url: '/d/8DY63kQZk/test2', | ||||
|         slug: '', | ||||
|         type: 'dash-db', | ||||
|         tags: [], | ||||
|         isStarred: false, | ||||
|         checked: false, | ||||
|       }, | ||||
|     ], | ||||
|     icon: 'folder-open', | ||||
|     score: 1, | ||||
|     expanded: true, | ||||
|     checked: false, | ||||
|   }, | ||||
| ]; | ||||
| 
 | ||||
| // Search results with more info
 | ||||
| export const sections = [ | ||||
|   { | ||||
|     title: 'Starred', | ||||
|     score: -2, | ||||
|     expanded: true, | ||||
|     items: [ | ||||
|       { | ||||
|         id: 1, | ||||
|         uid: 'lBdLINUWk', | ||||
|         title: 'Prom dash', | ||||
|         type: 'dash-db', | ||||
|       }, | ||||
|     ], | ||||
|   }, | ||||
|   { | ||||
|     title: 'Recent', | ||||
|     icon: 'clock-o', | ||||
|     score: -1, | ||||
|     removable: true, | ||||
|     expanded: false, | ||||
|     items: [ | ||||
|       { | ||||
|         id: 4072, | ||||
|         uid: 'OzAIf_rWz', | ||||
|         title: 'New dashboard Copy 3', | ||||
| 
 | ||||
|         type: 'dash-db', | ||||
|         isStarred: false, | ||||
|       }, | ||||
|       { | ||||
|         id: 46, | ||||
|         uid: '8DY63kQZk', | ||||
|         title: 'Stocks', | ||||
|         type: 'dash-db', | ||||
|         isStarred: false, | ||||
|       }, | ||||
|       { | ||||
|         id: 20, | ||||
|         uid: '7MeksYbmk', | ||||
|         title: 'Alerting with TestData', | ||||
|         type: 'dash-db', | ||||
|         isStarred: false, | ||||
|         folderId: 2, | ||||
|       }, | ||||
|       { | ||||
|         id: 4073, | ||||
|         uid: 'j9SHflrWk', | ||||
|         title: 'New dashboard Copy 4', | ||||
|         type: 'dash-db', | ||||
|         isStarred: false, | ||||
|         folderId: 2, | ||||
|       }, | ||||
|     ], | ||||
|   }, | ||||
|   { | ||||
|     id: 2, | ||||
|     uid: 'JB_zdOUWk', | ||||
|     title: 'gdev dashboards', | ||||
|     expanded: false, | ||||
|     url: '/dashboards/f/JB_zdOUWk/gdev-dashboards', | ||||
|     icon: 'folder', | ||||
|     score: 2, | ||||
|     //@ts-ignore
 | ||||
|     items: [], | ||||
|   }, | ||||
|   { | ||||
|     id: 2568, | ||||
|     uid: 'search-test-data', | ||||
|     title: 'Search test data folder', | ||||
|     expanded: false, | ||||
|     items: [], | ||||
|     url: '/dashboards/f/search-test-data/search-test-data-folder', | ||||
|     icon: 'folder', | ||||
|     score: 3, | ||||
|   }, | ||||
|   { | ||||
|     id: 4074, | ||||
|     uid: 'iN5TFj9Zk', | ||||
|     title: 'Test', | ||||
|     expanded: false, | ||||
|     items: [], | ||||
|     url: '/dashboards/f/iN5TFj9Zk/test', | ||||
|     icon: 'folder', | ||||
|     score: 4, | ||||
|   }, | ||||
|   { | ||||
|     id: 0, | ||||
|     title: 'General', | ||||
|     icon: 'folder-open', | ||||
|     score: 5, | ||||
|     expanded: true, | ||||
|     items: [ | ||||
|       { | ||||
|         id: 4069, | ||||
|         uid: 'LCFWfl9Zz', | ||||
|         title: 'New dashboard Copy', | ||||
|         uri: 'db/new-dashboard-copy', | ||||
|         url: '/d/LCFWfl9Zz/new-dashboard-copy', | ||||
|         slug: '', | ||||
|         type: 'dash-db', | ||||
|         isStarred: false, | ||||
|       }, | ||||
|       { | ||||
|         id: 4072, | ||||
|         uid: 'OzAIf_rWz', | ||||
|         title: 'New dashboard Copy 3', | ||||
|         type: 'dash-db', | ||||
|         isStarred: false, | ||||
|       }, | ||||
|       { | ||||
|         id: 1, | ||||
|         uid: 'lBdLINUWk', | ||||
|         title: 'Prom dash', | ||||
|         type: 'dash-db', | ||||
|         isStarred: true, | ||||
|       }, | ||||
|     ], | ||||
|   }, | ||||
| ]; | ||||
|  | @ -1,33 +1,40 @@ | |||
| export enum DashboardSearchItemType { | ||||
|   DashDB = 'dash-db', | ||||
|   DashHome = 'dash-home', | ||||
|   DashFolder = 'dash-folder', | ||||
| } | ||||
| 
 | ||||
| export interface DashboardSection { | ||||
|   id: number; | ||||
|   uid?: string; | ||||
|   title: string; | ||||
|   expanded: boolean; | ||||
|   expanded?: boolean; | ||||
|   url: string; | ||||
|   icon: string; | ||||
|   score: number; | ||||
|   hideHeader?: boolean; | ||||
|   checked: boolean; | ||||
|   checked?: boolean; | ||||
|   items: DashboardSectionItem[]; | ||||
|   toggle?: (section: DashboardSection) => Promise<DashboardSection>; | ||||
|   selected?: boolean; | ||||
|   type: DashboardSearchItemType; | ||||
| } | ||||
| 
 | ||||
| export interface DashboardSectionItem { | ||||
|   checked?: boolean; | ||||
|   folderId?: number; | ||||
|   folderTitle?: string; | ||||
|   folderUid?: string; | ||||
|   folderUrl?: string; | ||||
|   id: number; | ||||
|   uid: string; | ||||
|   isStarred: boolean; | ||||
|   selected?: boolean; | ||||
|   tags: string[]; | ||||
|   title: string; | ||||
|   type: DashboardSearchItemType; | ||||
|   uid: string; | ||||
|   uri: string; | ||||
|   url: string; | ||||
|   type: string; | ||||
|   tags: string[]; | ||||
|   isStarred: boolean; | ||||
|   folderId?: number; | ||||
|   folderUid?: string; | ||||
|   folderTitle?: string; | ||||
|   folderUrl?: string; | ||||
|   checked: boolean; | ||||
|   selected?: boolean; | ||||
| } | ||||
| 
 | ||||
| export interface DashboardTag { | ||||
|  | @ -52,3 +59,12 @@ export interface SectionsState { | |||
| } | ||||
| 
 | ||||
| export type ItemClickWithEvent = (item: DashboardSectionItem | DashboardSection, event: any) => void; | ||||
| 
 | ||||
| export type SearchAction = { | ||||
|   type: string; | ||||
|   payload?: any; | ||||
| }; | ||||
| 
 | ||||
| export interface OpenSearchParams { | ||||
|   query?: string; | ||||
| } | ||||
|  |  | |||
|  | @ -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); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | @ -0,0 +1,99 @@ | |||
| import { DashboardSection, DashboardSectionItem } from './types'; | ||||
| import { NO_ID_SECTIONS } from './constants'; | ||||
| import { parse, SearchParserResult } from 'search-query-parser'; | ||||
| 
 | ||||
| /** | ||||
|  * Check if folder has id. Only Recent and Starred folders are the ones without | ||||
|  * ids so far, as they are created manually after results are fetched from API. | ||||
|  * @param str | ||||
|  */ | ||||
| export const hasId = (str: string) => { | ||||
|   return !NO_ID_SECTIONS.includes(str); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Return ids for folders concatenated with their items ids, if section is expanded. | ||||
|  * For items the id format is '{folderId}-{itemId}' to allow mapping them to their folders | ||||
|  * @param sections | ||||
|  */ | ||||
| export const getFlattenedSections = (sections: DashboardSection[]): string[] => { | ||||
|   return sections.flatMap(section => { | ||||
|     const id = hasId(section.title) ? String(section.id) : section.title; | ||||
| 
 | ||||
|     if (section.expanded && section.items.length) { | ||||
|       return [id, ...section.items.map(item => `${id}-${item.id}`)]; | ||||
|     } | ||||
|     return id; | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Since Recent and Starred folders don't have id, title field is used as id | ||||
|  * @param title - title field of the section | ||||
|  */ | ||||
| export const getLookupField = (title: string) => { | ||||
|   return hasId(title) ? 'id' : 'title'; | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Go through all the folders and items in expanded folders and toggle their selected | ||||
|  * prop according to currently selected index. Used for item highlighting when navigating | ||||
|  * the search results list using keyboard arrows | ||||
|  * @param sections | ||||
|  * @param selectedId | ||||
|  */ | ||||
| export const markSelected = (sections: DashboardSection[], selectedId: string) => { | ||||
|   return sections.map((result: DashboardSection) => { | ||||
|     const lookupField = getLookupField(selectedId); | ||||
|     result = { ...result, selected: String(result[lookupField]) === selectedId }; | ||||
| 
 | ||||
|     if (result.expanded && result.items.length) { | ||||
|       return { | ||||
|         ...result, | ||||
|         items: result.items.map(item => { | ||||
|           const [sectionId, itemId] = selectedId.split('-'); | ||||
|           const lookup = getLookupField(sectionId); | ||||
|           return { ...item, selected: String(item.id) === itemId && String(result[lookup]) === sectionId }; | ||||
|         }), | ||||
|       }; | ||||
|     } | ||||
|     return result; | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Find items with property 'selected' set true in a list of folders and their items. | ||||
|  * Does recursive search in the items list. | ||||
|  * @param sections | ||||
|  */ | ||||
| export const findSelected = (sections: any): DashboardSection | DashboardSectionItem | null => { | ||||
|   let found = null; | ||||
|   for (const section of sections) { | ||||
|     if (section.expanded && section.items.length) { | ||||
|       found = findSelected(section.items); | ||||
|     } | ||||
|     if (section.selected) { | ||||
|       found = section; | ||||
|     } | ||||
|     if (found) { | ||||
|       return found; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   return null; | ||||
| }; | ||||
| 
 | ||||
| // TODO check if there are any use cases where query isn't a string
 | ||||
| export const parseQuery = (query: any) => { | ||||
|   const parsedQuery = parse(query, { | ||||
|     keywords: ['folder'], | ||||
|   }); | ||||
| 
 | ||||
|   if (typeof parsedQuery === 'string') { | ||||
|     return { | ||||
|       text: parsedQuery, | ||||
|     } as SearchParserResult; | ||||
|   } | ||||
| 
 | ||||
|   return parsedQuery; | ||||
| }; | ||||
|  | @ -4,17 +4,17 @@ export enum DashboardSearchHitType { | |||
|   DashHitFolder = 'dash-folder', | ||||
| } | ||||
| export interface DashboardSearchHit { | ||||
|   folderId?: number; | ||||
|   folderTitle?: string; | ||||
|   folderUid?: string; | ||||
|   folderUrl?: string; | ||||
|   id: number; | ||||
|   uid: string; | ||||
|   isStarred: boolean; | ||||
|   slug: string; | ||||
|   tags: string[]; | ||||
|   title: string; | ||||
|   type: DashboardSearchHitType; | ||||
|   uid: string; | ||||
|   uri: string; | ||||
|   url: string; | ||||
|   slug: string; | ||||
|   type: DashboardSearchHitType; | ||||
|   tags: string[]; | ||||
|   isStarred: boolean; | ||||
|   folderId?: number; | ||||
|   folderUid?: string; | ||||
|   folderTitle?: string; | ||||
|   folderUrl?: string; | ||||
| } | ||||
|  |  | |||
|  | @ -212,7 +212,7 @@ | |||
|     <grafana-app class="grafana-app" ng-cloak> | ||||
|       <sidemenu class="sidemenu"></sidemenu> | ||||
|       <app-notifications-list class="page-alert-list"></app-notifications-list> | ||||
|       <dashboard-search></dashboard-search> | ||||
|       <search-wrapper></search-wrapper> | ||||
| 
 | ||||
|       <div class="main-view"> | ||||
| 				<div ng-view class="scroll-canvas"></div> | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue