mirror of https://github.com/grafana/grafana
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 errorspull/23441/head
parent
dbda5aece9
commit
d04dce6a37
@ -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; |
||||
} |
||||
`,
|
||||
}; |
||||
}); |
@ -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']; |
@ -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, |
||||
}, |
||||
], |
||||
}, |
||||
]; |
@ -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; |
||||
}; |
Loading…
Reference in new issue