Commit 9771b1c7 by Alex Khomenko Committed by GitHub

Search/fix folder sort (#23893)

* Search: Move layout to query reducer/hook

* Search: Refactor search_srv

* Search: Fix types

* Search: Move extra layout/sort logic to reducer

* Search: Fix Select min-width

* Search: Fix filter by starred

* Search: Update tests

* Search: Simplify query return

* Search: Set width to auto on HorizontalGroup

* Search: Fix tests
parent 410e2a8c
......@@ -334,7 +334,7 @@ export function SelectBase<T>({
bottom,
position,
marginBottom: !!bottom ? '10px' : '0',
'min-width': '100%',
minWidth: '100%',
zIndex: theme.zIndex.dropdown,
}),
container: () => ({
......
......@@ -4,7 +4,7 @@ import impressionSrv from 'app/core/services/impression_srv';
import store from 'app/core/store';
import { contextSrv } from 'app/core/services/context_srv';
import { hasFilters } from 'app/features/search/utils';
import { DashboardSection, DashboardSearchItemType, DashboardSearchHit } from 'app/features/search/types';
import { DashboardSection, DashboardSearchItemType, DashboardSearchHit, SearchLayout } from 'app/features/search/types';
import { backendSrv } from './backend_srv';
interface Sections {
......@@ -43,14 +43,12 @@ export class SearchSrv {
return backendSrv.search({ dashboardIds: dashIds }).then(result => {
return dashIds
.map(orderId => {
return _.find(result, { id: orderId });
})
.map(orderId => result.find(result => result.id === orderId))
.filter(hit => hit && !hit.isStarred) as DashboardSearchHit[];
});
}
private getStarred(sections: DashboardSection) {
private getStarred(sections: DashboardSection): Promise<any> {
if (!contextSrv.isSignedIn) {
return Promise.resolve();
}
......@@ -75,6 +73,14 @@ export class SearchSrv {
const query = _.clone(options);
const filters = hasFilters(options) || query.folderIds?.length > 0;
query.folderIds = query.folderIds || [];
if (!filters) {
query.folderIds = [0];
}
if (query.layout === SearchLayout.List) {
return backendSrv.search({ ...query, type: DashboardSearchItemType.DashDB });
}
if (!options.skipRecent && !filters) {
promises.push(this.getRecentDashboards(sections));
}
......@@ -83,11 +89,6 @@ export class SearchSrv {
promises.push(this.getStarred(sections));
}
query.folderIds = query.folderIds || [];
if (!filters) {
query.folderIds = [0];
}
promises.push(
backendSrv.search(query).then(results => {
return this.handleSearchResult(sections, results);
......
......@@ -289,7 +289,7 @@ describe('SearchSrv', () => {
searchSrv['getStarred'] = () => {
getStarredCalled = true;
return Promise.resolve();
return Promise.resolve({});
};
return searchSrv.search({ skipStarred: true }).then(() => {});
......
import React, { Dispatch, FC, SetStateAction } from 'react';
import React, { Dispatch, FC, FormEvent, SetStateAction } from 'react';
import { css } from 'emotion';
import { HorizontalGroup, RadioButtonGroup, stylesFactory, useTheme, Checkbox } from '@grafana/ui';
import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { SortPicker } from 'app/core/components/Select/SortPicker';
import { TagFilter } from 'app/core/components/TagFilter/TagFilter';
import { SearchSrv } from 'app/core/services/search_srv';
import { layoutOptions } from '../hooks/useSearchLayout';
import { DashboardQuery } from '../types';
import { DashboardQuery, SearchLayout } from '../types';
export const layoutOptions = [
{ label: 'Folders', value: SearchLayout.Folders, icon: 'folder' },
{ label: 'List', value: SearchLayout.List, icon: 'list-ul' },
];
const searchSrv = new SearchSrv();
type onSelectChange = (value: SelectableValue) => void;
interface Props {
layout: string;
onLayoutChange: Dispatch<SetStateAction<string>>;
onSortChange: onSelectChange;
onStarredFilterChange?: onSelectChange;
onStarredFilterChange?: (event: FormEvent<HTMLInputElement>) => void;
onTagFilterChange: onSelectChange;
query: DashboardQuery;
showStarredFilter?: boolean;
......@@ -23,7 +26,6 @@ interface Props {
}
export const ActionRow: FC<Props> = ({
layout,
onLayoutChange,
onSortChange,
onStarredFilterChange = () => {},
......@@ -37,8 +39,10 @@ export const ActionRow: FC<Props> = ({
return (
<div className={styles.actionRow}>
<HorizontalGroup spacing="md">
{!hideLayout ? <RadioButtonGroup options={layoutOptions} onChange={onLayoutChange} value={layout} /> : null}
<HorizontalGroup spacing="md" width="auto">
{!hideLayout ? (
<RadioButtonGroup options={layoutOptions} onChange={onLayoutChange} value={query.layout} />
) : null}
<SortPicker onChange={onSortChange} value={query.sort} />
</HorizontalGroup>
<HorizontalGroup spacing="md" width="auto">
......
......@@ -4,6 +4,7 @@ import { act } from 'react-dom/test-utils';
import { mockSearch } from './mocks';
import { DashboardSearch } from './DashboardSearch';
import { searchResults } from '../testData';
import { SearchLayout } from '../types';
beforeEach(() => {
jest.useFakeTimers();
......@@ -43,6 +44,8 @@ describe('DashboardSearch', () => {
skipStarred: false,
starred: false,
folderIds: [],
layout: SearchLayout.Folders,
sort: undefined,
});
});
......@@ -68,6 +71,8 @@ describe('DashboardSearch', () => {
tag: [],
starred: false,
folderIds: [],
layout: SearchLayout.Folders,
sort: undefined,
});
});
......@@ -108,6 +113,8 @@ describe('DashboardSearch', () => {
tag: ['TestTag'],
starred: false,
folderIds: [],
layout: SearchLayout.Folders,
sort: undefined,
});
});
});
......@@ -4,7 +4,6 @@ import { useTheme, CustomScrollbar, stylesFactory, IconButton } from '@grafana/u
import { GrafanaTheme } from '@grafana/data';
import { useSearchQuery } from '../hooks/useSearchQuery';
import { useDashboardSearch } from '../hooks/useDashboardSearch';
import { useSearchLayout } from '../hooks/useSearchLayout';
import { SearchField } from './SearchField';
import { SearchResults } from './SearchResults';
import { ActionRow } from './ActionRow';
......@@ -16,9 +15,8 @@ export interface Props {
export const DashboardSearch: FC<Props> = memo(({ onCloseSearch, folder }) => {
const payload = folder ? { query: `folder:${folder}` } : {};
const { query, onQueryChange, onTagFilterChange, onTagAdd, onSortChange } = useSearchQuery(payload);
const { query, onQueryChange, onTagFilterChange, onTagAdd, onSortChange, onLayoutChange } = useSearchQuery(payload);
const { results, loading, onToggleSection, onKeyDown } = useDashboardSearch(query, onCloseSearch);
const { layout, setLayout } = useSearchLayout(query);
const theme = useTheme();
const styles = getStyles(theme);
......@@ -28,13 +26,6 @@ export const DashboardSearch: FC<Props> = memo(({ onCloseSearch, folder }) => {
onCloseSearch();
};
const onLayoutChange = (layout: string) => {
setLayout(layout);
if (query.sort) {
onSortChange(null);
}
};
return (
<div tabIndex={0} className={styles.overlay}>
<div className={styles.container}>
......@@ -47,7 +38,6 @@ export const DashboardSearch: FC<Props> = memo(({ onCloseSearch, folder }) => {
<div className={styles.search}>
<ActionRow
{...{
layout,
onLayoutChange,
onSortChange,
onTagFilterChange,
......@@ -61,7 +51,7 @@ export const DashboardSearch: FC<Props> = memo(({ onCloseSearch, folder }) => {
onTagSelected={onTagAdd}
editable={false}
onToggleSection={onToggleSection}
layout={layout}
layout={query.layout}
/>
</CustomScrollbar>
</div>
......
......@@ -11,7 +11,6 @@ import { useManageDashboards } from '../hooks/useManageDashboards';
import { SearchResultsFilter } from './SearchResultsFilter';
import { SearchResults } from './SearchResults';
import { DashboardActions } from './DashboardActions';
import { useSearchLayout } from '../hooks/useSearchLayout';
import { SearchLayout } from '../types';
import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
......@@ -27,7 +26,13 @@ export const ManageDashboards: FC<Props> = memo(({ folderId, folderUid }) => {
const styles = getStyles(theme);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isMoveModalOpen, setIsMoveModalOpen] = useState(false);
const queryParams = { skipRecent: true, skipStarred: true, folderIds: folderId ? [folderId] : [] };
const defaultLayout = folderId ? SearchLayout.List : SearchLayout.Folders;
const queryParams = {
skipRecent: true,
skipStarred: true,
folderIds: folderId ? [folderId] : [],
layout: defaultLayout,
};
const {
query,
hasFilters,
......@@ -36,6 +41,7 @@ export const ManageDashboards: FC<Props> = memo(({ folderId, folderUid }) => {
onStarredFilterChange,
onTagAdd,
onSortChange,
onLayoutChange,
} = useSearchQuery(queryParams);
const {
......@@ -53,9 +59,6 @@ export const ManageDashboards: FC<Props> = memo(({ folderId, folderUid }) => {
onMoveItems,
} = useManageDashboards(query, { hasEditPermissionInFolders: contextSrv.hasEditPermissionInFolders }, folderUid);
const defaultLayout = folderId ? SearchLayout.List : SearchLayout.Folders;
const { layout, setLayout } = useSearchLayout(query, defaultLayout);
const onMoveTo = () => {
setIsMoveModalOpen(true);
};
......@@ -64,13 +67,6 @@ export const ManageDashboards: FC<Props> = memo(({ folderId, folderUid }) => {
setIsDeleteModalOpen(true);
};
const onLayoutChange = (layout: string) => {
setLayout(layout);
if (query.sort) {
onSortChange(null);
}
};
if (canSave && folderId && !hasFilters && results.length === 0) {
return (
<EmptyListCTA
......@@ -113,7 +109,6 @@ export const ManageDashboards: FC<Props> = memo(({ folderId, folderUid }) => {
onSortChange={onSortChange}
onTagFilterChange={onTagFilterChange}
query={query}
layout={layout}
hideLayout={!!folderUid}
onLayoutChange={onLayoutChange}
/>
......@@ -124,7 +119,7 @@ export const ManageDashboards: FC<Props> = memo(({ folderId, folderUid }) => {
onTagSelected={onTagAdd}
onToggleSection={onToggleSection}
onToggleChecked={onToggleChecked}
layout={layout}
layout={query.layout}
/>
</div>
<ConfirmDeleteModal
......
......@@ -2,6 +2,7 @@ import React from 'react';
import { shallow, mount } from 'enzyme';
import { SearchResults, Props } from './SearchResults';
import { searchResults } from '../testData';
import { SearchLayout } from '../types';
const setup = (propOverrides?: Partial<Props>, renderMethod = shallow) => {
const props: Props = {
......@@ -12,6 +13,7 @@ const setup = (propOverrides?: Partial<Props>, renderMethod = shallow) => {
onFolderExpanding: () => {},
onToggleSelection: () => {},
editable: false,
layout: SearchLayout.Folders,
};
Object.assign(props, propOverrides);
......
......@@ -4,8 +4,7 @@ import { FixedSizeList } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme } from '@grafana/data';
import { stylesFactory, useTheme, Spinner } from '@grafana/ui';
import { DashboardSection, OnToggleChecked, SearchLayout } from '../types';
import { getVisibleItems } from '../utils';
import { DashboardSection, OnToggleChecked, SearchLayout, DashboardSearchHit } from '../types';
import { SEARCH_ITEM_HEIGHT, SEARCH_ITEM_MARGIN } from '../constants';
import { SearchItem } from './SearchItem';
import { SectionHeader } from './SectionHeader';
......@@ -16,7 +15,7 @@ export interface Props {
onTagSelected: (name: string) => any;
onToggleChecked?: OnToggleChecked;
onToggleSection: (section: DashboardSection) => void;
results: DashboardSection[];
results: DashboardSearchHit[];
layout?: string;
}
......@@ -32,7 +31,6 @@ export const SearchResults: FC<Props> = ({
const theme = useTheme();
const styles = getSectionStyles(theme);
const itemProps = { editable, onToggleChecked, onTagSelected };
const renderFolders = () => {
return (
<div className={styles.wrapper}>
......@@ -50,8 +48,6 @@ export const SearchResults: FC<Props> = ({
);
};
const items = getVisibleItems(results);
const renderDashboards = () => {
return (
<div className={styles.listModeWrapper}>
......@@ -63,11 +59,11 @@ export const SearchResults: FC<Props> = ({
innerElementType="ul"
itemSize={SEARCH_ITEM_HEIGHT + SEARCH_ITEM_MARGIN}
height={height}
itemCount={items.length}
itemCount={results.length}
width="100%"
>
{({ index, style }) => {
const item = items[index];
const item = results[index];
// The wrapper div is needed as the inner SearchItem has margin-bottom spacing
// And without this wrapper there is no room for that margin
return (
......@@ -90,7 +86,9 @@ export const SearchResults: FC<Props> = ({
}
return (
<div className={styles.resultsContainer}>{layout !== SearchLayout.List ? renderFolders() : renderDashboards()}</div>
<div className={styles.resultsContainer}>
{layout === SearchLayout.Folders ? renderFolders() : renderDashboards()}
</div>
);
};
......
......@@ -62,6 +62,7 @@ describe('SearchResultsFilter', () => {
const option = { value: true, label: 'Yes' };
//@ts-ignore
const { wrapper } = setup({ onStarredFilterChange: mockFilterStarred }, mount);
//@ts-ignore
wrapper
.find('Checkbox')
.at(1)
......
......@@ -13,7 +13,6 @@ export interface Props {
canMove?: boolean;
deleteItem: () => void;
hideLayout?: boolean;
layout: string;
moveTo: () => void;
onLayoutChange: Dispatch<SetStateAction<string>>;
onSortChange: onSelectChange;
......@@ -29,7 +28,6 @@ export const SearchResultsFilter: FC<Props> = ({
canMove,
deleteItem,
hideLayout,
layout,
moveTo,
onLayoutChange,
onSortChange,
......@@ -57,7 +55,6 @@ export const SearchResultsFilter: FC<Props> = ({
) : (
<ActionRow
{...{
layout,
hideLayout,
onLayoutChange,
onSortChange,
......
......@@ -55,9 +55,10 @@ export const useManageDashboards = (
dispatch({ type: MOVE_ITEMS, payload: { dashboards: selectedDashboards, folder } });
};
const canMove = useMemo(() => results.some((result: DashboardSection) => result.items.some(item => item.checked)), [
results,
]);
const canMove = useMemo(
() => results.some((result: DashboardSection) => result.items && result.items.some(item => item.checked)),
[results]
);
const canDelete = useMemo(() => canMove || results.some((result: DashboardSection) => result.checked), [
canMove,
results,
......
......@@ -35,7 +35,7 @@ export const useSearch: UseSearch = (query, reducer, params) => {
// Set loading state before debounced search
useEffect(() => {
dispatch({ type: SEARCH_START });
}, [query.tag, query.sort, query.starred]);
}, [query.tag, query.sort, query.starred, query.layout]);
useDebounce(search, 300, [query, folderUid, queryParsing]);
......
import { useEffect, useState } from 'react';
import { DashboardQuery, SearchLayout } from '../types';
export const layoutOptions = [
{ label: 'Folders', value: SearchLayout.Folders, icon: 'folder' },
{ label: 'List', value: SearchLayout.List, icon: 'list-ul' },
];
export const useSearchLayout = (query: DashboardQuery, defaultLayout = SearchLayout.Folders) => {
const [layout, setLayout] = useState<string>(defaultLayout);
useEffect(() => {
if (query.sort) {
const list = layoutOptions.find(opt => opt.value === SearchLayout.List);
setLayout(list!.value);
}
}, [query]);
return { layout, setLayout };
};
import { useReducer } from 'react';
import { FormEvent, useReducer } from 'react';
import { SelectableValue } from '@grafana/data';
import { defaultQuery, queryReducer } from '../reducers/searchQueryReducer';
import { ADD_TAG, CLEAR_FILTERS, QUERY_CHANGE, SET_TAGS, TOGGLE_SORT, TOGGLE_STARRED } from '../reducers/actionTypes';
import { DashboardQuery } from '../types';
import {
ADD_TAG,
CLEAR_FILTERS,
LAYOUT_CHANGE,
QUERY_CHANGE,
SET_TAGS,
TOGGLE_SORT,
TOGGLE_STARRED,
} from '../reducers/actionTypes';
import { DashboardQuery, SearchLayout } from '../types';
import { hasFilters } from '../utils';
export const useSearchQuery = (queryParams: Partial<DashboardQuery>) => {
......@@ -25,14 +33,18 @@ export const useSearchQuery = (queryParams: Partial<DashboardQuery>) => {
dispatch({ type: CLEAR_FILTERS });
};
const onStarredFilterChange = (filter: SelectableValue) => {
dispatch({ type: TOGGLE_STARRED, payload: filter.value });
const onStarredFilterChange = (e: FormEvent<HTMLInputElement>) => {
dispatch({ type: TOGGLE_STARRED, payload: (e.target as HTMLInputElement).checked });
};
const onSortChange = (sort: SelectableValue | null) => {
dispatch({ type: TOGGLE_SORT, payload: sort });
};
const onLayoutChange = (layout: SearchLayout) => {
dispatch({ type: LAYOUT_CHANGE, payload: layout });
};
return {
query,
hasFilters: hasFilters(query),
......@@ -42,5 +54,6 @@ export const useSearchQuery = (queryParams: Partial<DashboardQuery>) => {
onStarredFilterChange,
onTagAdd,
onSortChange,
onLayoutChange,
};
};
......@@ -22,3 +22,4 @@ export const CLEAR_FILTERS = 'CLEAR_FILTERS';
export const SET_TAGS = 'SET_TAGS';
export const ADD_TAG = 'ADD_TAG';
export const TOGGLE_SORT = 'TOGGLE_SORT';
export const LAYOUT_CHANGE = 'LAYOUT_CHANGE';
import { DashboardSection, SearchAction } from '../types';
import { DashboardSearchHit, DashboardSection, SearchAction } from '../types';
import { getFlattenedSections, getLookupField, markSelected } from '../utils';
import {
FETCH_ITEMS,
......@@ -10,7 +10,7 @@ import {
} from './actionTypes';
export interface DashboardsSearchState {
results: DashboardSection[];
results: DashboardSearchHit[];
loading: boolean;
selectedIndex: number;
}
......@@ -31,7 +31,7 @@ export const searchReducer = (state: DashboardsSearchState, action: SearchAction
case FETCH_RESULTS: {
const results = action.payload;
// Highlight the first item ('Starred' folder)
if (results.length) {
if (results.length > 0) {
results[0].selected = true;
}
return { ...state, results, loading: false };
......
import { SearchAction, DashboardQuery } from '../types';
import { DashboardQuery, SearchAction, SearchLayout } from '../types';
import {
ADD_TAG,
CLEAR_FILTERS,
LAYOUT_CHANGE,
QUERY_CHANGE,
REMOVE_STARRED,
REMOVE_TAG,
SET_TAGS,
TOGGLE_STARRED,
TOGGLE_SORT,
TOGGLE_STARRED,
} from './actionTypes';
export const defaultQuery: DashboardQuery = {
......@@ -18,6 +19,7 @@ export const defaultQuery: DashboardQuery = {
skipStarred: false,
folderIds: [],
sort: null,
layout: SearchLayout.Folders,
};
export const queryReducer = (state: DashboardQuery, action: SearchAction) => {
......@@ -38,8 +40,20 @@ export const queryReducer = (state: DashboardQuery, action: SearchAction) => {
return { ...state, starred: false };
case CLEAR_FILTERS:
return { ...state, query: '', tag: [], starred: false, sort: null };
case TOGGLE_SORT:
return { ...state, sort: action.payload };
case TOGGLE_SORT: {
const sort = action.payload;
if (state.layout === SearchLayout.Folders) {
return { ...state, sort, layout: SearchLayout.List };
}
return { ...state, sort };
}
case LAYOUT_CHANGE: {
const layout = action.payload;
if (state.sort && layout === SearchLayout.Folders) {
return { ...state, layout, sort: null };
}
return { ...state, layout };
}
default:
return state;
}
......
......@@ -70,6 +70,7 @@ export interface DashboardQuery {
skipStarred: boolean;
folderIds: number[];
sort: SelectableValue | null;
layout: SearchLayout;
}
export type SearchReducer<S> = [S, Dispatch<SearchAction>];
......
......@@ -136,7 +136,7 @@ export const getCheckedDashboards = (sections: DashboardSection[]): DashboardSec
}
return sections.reduce((uids, section) => {
return [...uids, ...section.items.filter(item => item.checked)];
return section.items ? [...uids, ...section.items.filter(item => item.checked)] : uids;
}, []);
};
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment