Commit 89c8855f by Alex Khomenko Committed by GitHub

Search: migrate manage dashboards (#23530)

* 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: Add ManageDashboards.tsx

* Search: Add mergeReducers

* Search: Use mergeReducers

* Search: remove default state from reducers

* Search: Fix recent and starred icons

* Search: Enable search

* Search: Add markup

* Search: Separate manageDashboardsReducer

* Search: Add DashboardActions.tsx

* Use new Select for TagFilter

* Search: Use TagFilter for search filters

* Search: Use TagList

* Search: Add toggleSection

* Search: Add more actions

* Search add manageDashboards.test.ts

* Search: Add getCheckedUids

* Search: Add modify and toggle checked actions

* Search: Update tests

* Search: Update component template

* Search: Enable section toggle

* Search: Derive canMove and canDelete

* Search: Handle delete items

* Search: Fix tests

* Search: Enable toggle items

* Search: Add confirm modal subtitle

* Search: Use theme vars

* Search: Add getCheckedDashboardsUids

* Search: Add MoveToFolderModal

* Search: Enable moving dashboards

* Search: Fix strict null checks errors

* Search: Fix strict null checks errors[2]

* Search: Enable filters

* Search: Add useSearchQuery.ts

* Search: Toggle items when toggling all

* Search: Update useSearchQuery to accept custom params

* Search: Add useSearchQuery to dashboard search

* Search: use SearchField for manage dashboards

* Search: Remove event param from query change

* Search: Add base search hooks

* Search: refactor useSearch to accept reducer

* Search: use useDashboardSearch hook

* Search: Fix useSearchQuery params

* Search: Enable folder search

* Search: Update tests

* Search: Pass the props to manage-dashboards

* Search: Add search filters margin

* Search: Remove search-field-wrapper class and hide logic for it

* Search: Adjust SearchField styles

* Search: Move search-results-container inside SearchResults

* Search: Fix type errors

* Search: Add EmptyListCTA

* Search: Update move message

* Search: Cleanup

* Search: Add todo

* Search: Fix action type

* Search: Use React wrapper vs FolderDashboardsCtrl and DashboardListCtrl

* Search: DashboardList => DashboardListPage

* Search: Remove ManageDashboards from angular_wrappers

* Minor style tweaks

* Search: Use LinkButton

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
parent 1816ab80
export const componentTpl = ` export const componentTpl = `import React, { FC } from 'react';
import React, { FC } from 'react';
interface Props = {}; interface Props {};
export const <%= name %>: FC<Props> = (props) => { export const <%= name %>: FC<Props> = (props) => {
return ( return (
......
...@@ -43,7 +43,7 @@ export class FolderPicker extends PureComponent<Props, State> { ...@@ -43,7 +43,7 @@ export class FolderPicker extends PureComponent<Props, State> {
enableReset: false, enableReset: false,
initialTitle: '', initialTitle: '',
enableCreateNew: false, enableCreateNew: false,
useInNextGenForms: false, useNewForms: false,
}; };
componentDidMount = async () => { componentDidMount = async () => {
......
import { IScope } from 'angular'; import { IScope } from 'angular';
import _ from 'lodash'; import _ from 'lodash';
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
//@ts-ignore
import coreModule from 'app/core/core_module'; import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { SearchSrv } from 'app/core/services/search_srv'; import { SearchSrv } from 'app/core/services/search_srv';
...@@ -337,26 +338,6 @@ export class ManageDashboardsCtrl { ...@@ -337,26 +338,6 @@ export class ManageDashboardsCtrl {
return url; return url;
} }
// TODO handle this inside SearchResults component
toggleSelection = (item: any, evt: any) => {
if (evt) {
evt.stopPropagation();
evt.preventDefault();
}
item.checked = !item.checked;
if (item.items) {
_.each(item.items, i => {
i.checked = item.checked;
});
}
if (this.selectionChanged) {
this.selectionChanged();
}
};
} }
export function manageDashboardsDirective() { export function manageDashboardsDirective() {
...@@ -373,4 +354,4 @@ export function manageDashboardsDirective() { ...@@ -373,4 +354,4 @@ export function manageDashboardsDirective() {
}; };
} }
coreModule.directive('manageDashboards', manageDashboardsDirective); //coreModule.directive('manageDashboards', manageDashboardsDirective);
...@@ -2,3 +2,4 @@ import { LocationState } from 'app/types'; ...@@ -2,3 +2,4 @@ import { LocationState } from 'app/types';
export const getRouteParamsId = (state: LocationState) => state.routeParams.id; export const getRouteParamsId = (state: LocationState) => state.routeParams.id;
export const getRouteParamsPage = (state: LocationState) => state.routeParams.page; export const getRouteParamsPage = (state: LocationState) => state.routeParams.page;
export const getRouteParams = (state: LocationState) => state.routeParams;
import { ILocationService, IScope } from 'angular';
import { FolderPageLoader } from './services/FolderPageLoader';
import locationUtil from 'app/core/utils/location_util';
import { NavModelSrv } from 'app/core/core';
import { promiseToDigest } from '../../core/utils/promiseToDigest';
export default class FolderDashboardsCtrl {
navModel: any;
folderId: number;
uid: string;
/** @ngInject */
constructor(
navModelSrv: NavModelSrv,
private $routeParams: any,
$location: ILocationService,
private $scope: IScope
) {
if (this.$routeParams.uid) {
this.uid = $routeParams.uid;
const loader = new FolderPageLoader();
promiseToDigest(this.$scope)(
loader.load(this, this.uid, 'manage-folder-dashboards').then((folder: any) => {
const url = locationUtil.stripBaseFromUrl(folder.url);
if (url !== $location.path()) {
$location.path(url).replace();
}
})
);
}
}
}
<page-header ng-if="ctrl.navModel" model="ctrl.navModel"></page-header>
<div class="page-container page-body">
<manage-dashboards ng-if="ctrl.folderId && ctrl.uid" folder-id="ctrl.folderId" folder-uid="ctrl.uid" />
</div>
<footer />
<page-header model="ctrl.navModel"></page-header>
<div class="page-container page-body">
<manage-dashboards />
</div>
<footer />
import React, { FC } from 'react';
import { css } from 'emotion';
import { GrafanaTheme } from '@grafana/data';
import { ConfirmModal, stylesFactory, useTheme } from '@grafana/ui';
import { getLocationSrv } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import { DashboardSection, OnDeleteItems } from '../types';
import { getCheckedUids } from '../utils';
interface Props {
onDeleteItems: OnDeleteItems;
results: DashboardSection[];
isOpen: boolean;
onDismiss: () => void;
}
export const ConfirmDeleteModal: FC<Props> = ({ results, onDeleteItems, isOpen, onDismiss }) => {
const theme = useTheme();
const styles = getStyles(theme);
const uids = getCheckedUids(results);
const { folders, dashboards } = uids;
const folderCount = folders.length;
const dashCount = dashboards.length;
let text = 'Do you want to delete the ';
let subtitle;
const dashEnding = dashCount === 1 ? '' : 's';
const folderEnding = folderCount === 1 ? '' : 's';
if (folderCount > 0 && dashCount > 0) {
text += `selected folder${folderEnding} and dashboard${dashEnding}?\n`;
subtitle = `All dashboards of the selected folder${folderEnding} will also be deleted`;
} else if (folderCount > 0) {
text += `selected folder${folderEnding} and all its dashboards?`;
} else {
text += `selected dashboard${dashEnding}?`;
}
const deleteItems = () => {
backendSrv.deleteFoldersAndDashboards(folders, dashboards).then(() => {
onDismiss();
// Redirect to /dashboard in case folder was deleted from f/:folder.uid
getLocationSrv().update({ path: '/dashboards' });
onDeleteItems(folders, dashboards);
});
};
return (
<ConfirmModal
isOpen={isOpen}
title="Delete"
body={
<>
{text} {subtitle && <div className={styles.subtitle}>{subtitle}</div>}
</>
}
confirmText="Delete"
onConfirm={deleteItems}
onDismiss={onDismiss}
/>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
subtitle: css`
font-size: ${theme.typography.size.base};
padding-top: ${theme.spacing.md};
`,
};
});
import React, { FC } from 'react';
import { HorizontalGroup, LinkButton } from '@grafana/ui';
export interface Props {
folderId?: number;
isEditor: boolean;
canEdit: boolean;
}
export const DashboardActions: FC<Props> = ({ folderId, isEditor, canEdit }) => {
const actionUrl = (type: string) => {
let url = `dashboard/${type}`;
if (folderId) {
url += `?folderId=${folderId}`;
}
return url;
};
return (
<HorizontalGroup spacing="md" align="center">
{canEdit && <LinkButton href={actionUrl('new')}>New Dashboard</LinkButton>}
{!folderId && isEditor && <LinkButton href="dashboards/folder/new">New Folder</LinkButton>}
{canEdit && <LinkButton href={actionUrl('import')}>Import</LinkButton>}
</HorizontalGroup>
);
};
import React, { FC, memo } from 'react';
import { useAsync } from 'react-use';
import { connect, MapStateToProps } from 'react-redux';
import { NavModel } from '@grafana/data';
import { getLocationSrv } from '@grafana/runtime';
import { StoreState } from 'app/types';
import { getNavModel } from 'app/core/selectors/navModel';
import { getRouteParams } from 'app/core/selectors/location';
import Page from 'app/core/components/Page/Page';
import locationUtil from 'app/core/utils/location_util';
import { backendSrv } from 'app/core/services/backend_srv';
import { ManageDashboards } from './ManageDashboards';
interface Props {
navModel: NavModel;
uid?: string;
}
export const DashboardListPage: FC<Props> = memo(({ navModel, uid }) => {
const { loading, value } = useAsync(() => {
if (uid) {
return backendSrv.getFolderByUid(uid).then((folder: any) => {
const url = locationUtil.stripBaseFromUrl(folder.url);
if (url !== location.pathname) {
getLocationSrv().update({ path: url });
}
return folder.id;
});
} else {
return Promise.resolve(undefined);
}
}, [uid]);
return (
<Page navModel={navModel}>
<Page.Contents isLoading={loading}>
<ManageDashboards folderUid={uid} folderId={value} />
</Page.Contents>
</Page>
);
});
const mapStateToProps: MapStateToProps<Props, {}, StoreState> = state => ({
navModel: getNavModel(state.navIndex, 'manage-dashboards'),
uid: getRouteParams(state.location).uid as string | undefined,
});
export default connect(mapStateToProps)(DashboardListPage);
...@@ -14,47 +14,57 @@ afterEach(() => { ...@@ -14,47 +14,57 @@ afterEach(() => {
jest.useRealTimers(); jest.useRealTimers();
}); });
const setup = async (): Promise<any> => {
const props: any = {
onCloseSearch: () => {},
};
let wrapper;
//@ts-ignore
await act(async () => {
wrapper = await mount(<DashboardSearch {...props} />);
jest.runAllTimers();
});
return wrapper;
};
/** /**
* Need to wrap component render in async act and use jest.runAllTimers to test * Need to wrap component render in async act and use jest.runAllTimers to test
* calls inside useDebounce hook * calls inside useDebounce hook
*/ */
describe('DashboardSearch', () => { describe('DashboardSearch', () => {
it('should call search api with default query when initialised', async () => { it('should call search api with default query when initialised', async () => {
await act(() => { await setup();
mount(<DashboardSearch onCloseSearch={() => {}} />);
jest.runAllTimers();
});
expect(mockSearch).toHaveBeenCalledTimes(1); expect(mockSearch).toHaveBeenCalledTimes(1);
expect(mockSearch).toHaveBeenCalledWith({ expect(mockSearch).toHaveBeenCalledWith({
query: '', query: '',
parsedQuery: { text: '' },
tags: [],
tag: [], tag: [],
skipRecent: false,
skipStarred: false,
starred: false, starred: false,
folderIds: [], folderIds: [],
}); });
}); });
it('should call api with updated query on query change', async () => { it('should call api with updated query on query change', async () => {
let wrapper: any; let wrapper = await setup();
await act(() => {
wrapper = mount(<DashboardSearch onCloseSearch={() => {}} />);
jest.runAllTimers();
});
await act(() => { //@ts-ignore
wrapper await act(async () => {
// @ts-ignore
await wrapper
.find({ placeholder: 'Search dashboards by name' }) .find({ placeholder: 'Search dashboards by name' })
.hostNodes() .hostNodes()
//@ts-ignore
.prop('onChange')({ currentTarget: { value: 'Test' } }); .prop('onChange')({ currentTarget: { value: 'Test' } });
jest.runAllTimers(); jest.runAllTimers();
}); });
expect(mockSearch).toHaveBeenCalledWith({ expect(mockSearch).toHaveBeenCalledWith({
query: 'Test', query: 'Test',
parsedQuery: { text: 'Test' }, skipRecent: false,
tags: [], skipStarred: false,
tag: [], tag: [],
starred: false, starred: false,
folderIds: [], folderIds: [],
...@@ -62,11 +72,8 @@ describe('DashboardSearch', () => { ...@@ -62,11 +72,8 @@ describe('DashboardSearch', () => {
}); });
it("should render 'No results' message when there are no dashboards", async () => { it("should render 'No results' message when there are no dashboards", async () => {
let wrapper: any; let wrapper = await setup();
await act(() => {
wrapper = mount(<DashboardSearch onCloseSearch={() => {}} />);
jest.runAllTimers();
});
wrapper.update(); wrapper.update();
expect( expect(
wrapper.findWhere((c: any) => c.type() === 'h6' && c.text() === 'No dashboards matching your query were found.') wrapper.findWhere((c: any) => c.type() === 'h6' && c.text() === 'No dashboards matching your query were found.')
...@@ -76,31 +83,26 @@ describe('DashboardSearch', () => { ...@@ -76,31 +83,26 @@ describe('DashboardSearch', () => {
it('should render search results', async () => { it('should render search results', async () => {
//@ts-ignore //@ts-ignore
mockSearch.mockImplementation(() => Promise.resolve(searchResults)); mockSearch.mockImplementation(() => Promise.resolve(searchResults));
let wrapper: any; let wrapper = await setup();
await act(() => {
wrapper = mount(<DashboardSearch onCloseSearch={() => {}} />);
jest.runAllTimers();
});
wrapper.update(); wrapper.update();
expect(wrapper.find({ 'aria-label': 'Search section' })).toHaveLength(2); expect(wrapper.find({ 'aria-label': 'Search section' })).toHaveLength(2);
expect(wrapper.find({ 'aria-label': 'Search items' }).children()).toHaveLength(2); expect(wrapper.find({ 'aria-label': 'Search items' }).children()).toHaveLength(2);
}); });
it('should call search with selected tags', async () => { it('should call search with selected tags', async () => {
let wrapper: any; let wrapper = await setup();
await act(() => {
wrapper = mount(<DashboardSearch onCloseSearch={() => {}} />);
jest.runAllTimers();
});
await act(() => { //@ts-ignore
wrapper.find('TagFilter').prop('onChange')(['TestTag']); await act(async () => {
//@ts-ignore
await wrapper.find('TagFilter').prop('onChange')(['TestTag']);
jest.runAllTimers(); jest.runAllTimers();
}); });
expect(mockSearch).toHaveBeenCalledWith({ expect(mockSearch).toHaveBeenCalledWith({
query: '', query: '',
parsedQuery: { text: '' }, skipRecent: false,
tags: ['TestTag'], skipStarred: false,
tag: ['TestTag'], tag: ['TestTag'],
starred: false, starred: false,
folderIds: [], folderIds: [],
......
import React, { FC, useReducer, useState } from 'react'; import React, { FC } from 'react';
import { useDebounce } from 'react-use';
import { css } from 'emotion'; import { css } from 'emotion';
import { Icon, useTheme, CustomScrollbar, stylesFactory } from '@grafana/ui'; import { Icon, useTheme, CustomScrollbar, stylesFactory, Button } from '@grafana/ui';
import { getLocationSrv } from '@grafana/runtime';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
import { SearchSrv } from 'app/core/services/search_srv'; 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 { TagFilter } from 'app/core/components/TagFilter/TagFilter';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { DashboardSearchItemType, DashboardSection, OpenSearchParams } from '../types'; import { OpenSearchParams } from '../types';
import { findSelected, hasId, parseQuery } from '../utils'; import { useSearchQuery } from '../hooks/useSearchQuery';
import { searchReducer, initialState } from '../reducers/dashboardSearch'; import { useDashboardSearch } from '../hooks/useDashboardSearch';
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 { SearchField } from './SearchField';
import { SearchResults } from './SearchResults'; import { SearchResults } from './SearchResults';
const searchSrv = new SearchSrv(); const searchSrv = new SearchSrv();
const defaultQuery: SearchQuery = { query: '', parsedQuery: { text: '' }, tags: [], starred: false };
const { isEditor, hasEditPermissionInFolders } = contextSrv; const { isEditor, hasEditPermissionInFolders } = contextSrv;
const canEdit = isEditor || hasEditPermissionInFolders; const canEdit = isEditor || hasEditPermissionInFolders;
...@@ -35,70 +22,11 @@ export interface Props { ...@@ -35,70 +22,11 @@ export interface Props {
} }
export const DashboardSearch: FC<Props> = ({ onCloseSearch, payload = {} }) => { export const DashboardSearch: FC<Props> = ({ onCloseSearch, payload = {} }) => {
const [query, setQuery] = useState({ ...defaultQuery, ...payload, parsedQuery: parseQuery(payload.query) }); const { query, onQueryChange, onClearFilters, onTagFilterChange, onTagAdd } = useSearchQuery(payload);
const [{ results, loading }, dispatch] = useReducer(searchReducer, initialState); const { results, loading, onToggleSection, onKeyDown } = useDashboardSearch(query, onCloseSearch);
const theme = useTheme(); const theme = useTheme();
const styles = getStyles(theme); 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 // 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 // clicking Esc when tagFilter is active shouldn't close the whole search overlay
const onClose = (e: React.KeyboardEvent<HTMLElement>) => { const onClose = (e: React.KeyboardEvent<HTMLElement>) => {
...@@ -108,36 +36,26 @@ export const DashboardSearch: FC<Props> = ({ onCloseSearch, payload = {} }) => { ...@@ -108,36 +36,26 @@ export const DashboardSearch: FC<Props> = ({ onCloseSearch, payload = {} }) => {
} }
}; };
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 ( return (
<div tabIndex={0} className="search-container" onKeyDown={onClose}> <div tabIndex={0} className="search-container" onKeyDown={onClose}>
<SearchField query={query} onChange={onQueryChange} onKeyDown={onKeyDown} autoFocus={true} /> <SearchField
query={query}
onChange={onQueryChange}
onKeyDown={onKeyDown}
autoFocus
clearable
className={styles.searchField}
/>
<div className="search-dropdown"> <div className="search-dropdown">
<div className="search-dropdown__col_1"> <div className="search-dropdown__col_1">
<CustomScrollbar> <CustomScrollbar>
<div className="search-results-container">
<SearchResults <SearchResults
results={results} results={results}
loading={loading} loading={loading}
onTagSelected={onTagSelected} onTagSelected={onTagAdd}
dispatch={dispatch}
editable={false} editable={false}
onToggleSection={onToggleSection} onToggleSection={onToggleSection}
/> />
</div>
</CustomScrollbar> </CustomScrollbar>
</div> </div>
<div className="search-dropdown__col_2"> <div className="search-dropdown__col_2">
...@@ -145,14 +63,14 @@ export const DashboardSearch: FC<Props> = ({ onCloseSearch, payload = {} }) => { ...@@ -145,14 +63,14 @@ export const DashboardSearch: FC<Props> = ({ onCloseSearch, payload = {} }) => {
<div className="search-filter-box__header"> <div className="search-filter-box__header">
<Icon name="filter" className={styles.filter} size="sm" /> <Icon name="filter" className={styles.filter} size="sm" />
Filter by: Filter by:
{query.tags.length > 0 && ( {query.tag.length > 0 && (
<a className="pointer pull-right small" onClick={onClearSearchFilters}> <a className="pointer pull-right small" onClick={onClearFilters}>
<Icon name="times" size="sm" /> Clear <Icon name="times" size="sm" /> Clear
</a> </a>
)} )}
</div> </div>
<TagFilter tags={query.tags} tagOptions={searchSrv.getDashboardTags} onChange={onTagFiltersChanged} /> <TagFilter tags={query.tag} tagOptions={searchSrv.getDashboardTags} onChange={onTagFilterChange} />
</div> </div>
{canEdit && ( {canEdit && (
...@@ -178,9 +96,9 @@ export const DashboardSearch: FC<Props> = ({ onCloseSearch, payload = {} }) => { ...@@ -178,9 +96,9 @@ export const DashboardSearch: FC<Props> = ({ onCloseSearch, payload = {} }) => {
</div> </div>
)} )}
</div> </div>
<div className={styles.closeBtn} onClick={onCloseSearch}> <Button icon="times" className={styles.closeBtn} onClick={onCloseSearch} variant="secondary">
Close search <Icon name="times" className={styles.close} /> Close
</div> </Button>
</div> </div>
</div> </div>
); );
...@@ -189,17 +107,9 @@ export const DashboardSearch: FC<Props> = ({ onCloseSearch, payload = {} }) => { ...@@ -189,17 +107,9 @@ export const DashboardSearch: FC<Props> = ({ onCloseSearch, payload = {} }) => {
const getStyles = stylesFactory((theme: GrafanaTheme) => { const getStyles = stylesFactory((theme: GrafanaTheme) => {
return { return {
closeBtn: css` closeBtn: css`
top: 20px; top: 10px;
right: 8px; right: 8px;
position: absolute; position: absolute;
font-size: ${theme.typography.size.xs};
color: ${theme.colors.link};
display: flex;
align-items: center;
cursor: pointer;
&:hover {
color: ${theme.colors.linkHover};
}
`, `,
icon: css` icon: css`
margin-right: ${theme.spacing.sm}; margin-right: ${theme.spacing.sm};
...@@ -212,5 +122,8 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => { ...@@ -212,5 +122,8 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
margin-left: ${theme.spacing.xs}; margin-left: ${theme.spacing.xs};
margin-bottom: 1px; margin-bottom: 1px;
`, `,
searchField: css`
padding-left: ${theme.spacing.md};
`,
}; };
}); });
import React, { FC, useState } from 'react';
import { css } from 'emotion';
import { Icon, TagList, HorizontalGroup, stylesFactory, useTheme } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
import { contextSrv } from 'app/core/services/context_srv';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { ConfirmDeleteModal } from './ConfirmDeleteModal';
import { MoveToFolderModal } from './MoveToFolderModal';
import { useSearchQuery } from '../hooks/useSearchQuery';
import { useManageDashboards } from '../hooks/useManageDashboards';
import { SearchResultsFilter } from './SearchResultsFilter';
import { SearchResults } from './SearchResults';
import { DashboardActions } from './DashboardActions';
import { SearchField } from './SearchField';
export interface Props {
folderId?: number;
folderUid?: string;
}
const { isEditor } = contextSrv;
export const ManageDashboards: FC<Props> = ({ folderId, folderUid }) => {
const theme = useTheme();
const styles = getStyles(theme);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isMoveModalOpen, setIsMoveModalOpen] = useState(false);
const queryParams = { skipRecent: true, skipStarred: true, folderIds: folderId ? [folderId] : [] };
const {
query,
hasFilters,
onQueryChange,
onRemoveStarred,
onTagRemove,
onClearFilters,
onTagFilterChange,
onStarredFilterChange,
onTagAdd,
} = useSearchQuery(queryParams);
const {
results,
loading,
canSave,
allChecked,
hasEditPermissionInFolders,
canMove,
canDelete,
onToggleSection,
onToggleChecked,
onToggleAllChecked,
onDeleteItems,
onMoveItems,
} = useManageDashboards(query, { hasEditPermissionInFolders: contextSrv.hasEditPermissionInFolders }, folderUid);
const onMoveTo = () => {
setIsMoveModalOpen(true);
};
const onItemDelete = () => {
setIsDeleteModalOpen(true);
};
if (canSave && folderId && !hasFilters && results.length === 0) {
return (
<EmptyListCTA
title="This folder doesn't have any dashboards yet"
buttonIcon="plus"
buttonTitle="Create Dashboard"
buttonLink={`dashboard/new?folderId=${folderId}`}
proTip="Add/move dashboards to your folder at ->"
proTipLink="dashboards"
proTipLinkTitle="Manage dashboards"
proTipTarget=""
/>
);
}
return (
<div className="dashboard-list">
<HorizontalGroup justify="space-between">
<SearchField query={query} onChange={onQueryChange} className={styles.searchField} />
<DashboardActions isEditor={isEditor} canEdit={hasEditPermissionInFolders || canSave} folderId={folderId} />
</HorizontalGroup>
{hasFilters && (
<HorizontalGroup>
<div className="gf-form-inline">
{query.tag.length > 0 && (
<div className="gf-form">
<label className="gf-form-label width-4">Tags</label>
<TagList tags={query.tag} onClick={onTagRemove} />
</div>
)}
{query.starred && (
<div className="gf-form">
<label className="gf-form-label">
<a className="pointer" onClick={onRemoveStarred}>
<Icon name="check" />
Starred
</a>
</label>
</div>
)}
<div className="gf-form">
<label className="gf-form-label">
<a className="pointer" onClick={onClearFilters}>
<Icon name="times" />
&nbsp;Clear
</a>
</label>
</div>
</div>
</HorizontalGroup>
)}
<div className="search-results">
{results?.length > 0 && (
<SearchResultsFilter
allChecked={allChecked}
canDelete={canDelete}
canMove={canMove}
deleteItem={onItemDelete}
moveTo={onMoveTo}
onToggleAllChecked={onToggleAllChecked}
onStarredFilterChange={onStarredFilterChange}
onTagFilterChange={onTagFilterChange}
selectedStarredFilter={query.starred}
selectedTagFilter={query.tag}
/>
)}
<SearchResults
loading={loading}
results={results}
editable
onTagSelected={onTagAdd}
onToggleSection={onToggleSection}
onToggleChecked={onToggleChecked}
/>
</div>
<ConfirmDeleteModal
onDeleteItems={onDeleteItems}
results={results}
isOpen={isDeleteModalOpen}
onDismiss={() => setIsDeleteModalOpen(false)}
/>
<MoveToFolderModal
onMoveItems={onMoveItems}
results={results}
isOpen={isMoveModalOpen}
onDismiss={() => setIsMoveModalOpen(false)}
/>
</div>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
searchField: css`
height: auto;
border-bottom: none;
padding: 0;
margin: 0;
input {
width: 400px;
}
`,
};
});
import React, { FC, useState } from 'react';
import { css } from 'emotion';
import { Button, HorizontalGroup, Modal, stylesFactory, useTheme } from '@grafana/ui';
import { AppEvents, GrafanaTheme } from '@grafana/data';
import { FolderInfo } from 'app/types';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import appEvents from 'app/core/app_events';
import { backendSrv } from 'app/core/services/backend_srv';
import { DashboardSection, OnMoveItems } from '../types';
import { getCheckedDashboards } from '../utils';
interface Props {
onMoveItems: OnMoveItems;
results: DashboardSection[];
isOpen: boolean;
onDismiss: () => void;
}
export const MoveToFolderModal: FC<Props> = ({ results, onMoveItems, isOpen, onDismiss }) => {
const [folder, setFolder] = useState<FolderInfo | null>(null);
const theme = useTheme();
const styles = getStyles(theme);
const selectedDashboards = getCheckedDashboards(results);
const moveTo = () => {
if (folder) {
const folderTitle = folder.title ?? 'General';
backendSrv
.moveDashboards(
selectedDashboards.map(d => d.uid),
folder
)
.then((result: any) => {
if (result.successCount > 0) {
const ending = result.successCount === 1 ? '' : 's';
const header = `Dashboard${ending} Moved`;
const msg = `${result.successCount} dashboard${ending} moved to ${folderTitle}`;
appEvents.emit(AppEvents.alertSuccess, [header, msg]);
}
if (result.totalCount === result.alreadyInFolderCount) {
appEvents.emit(AppEvents.alertError, ['Error', `Dashboard already belongs to folder ${folderTitle}`]);
}
onMoveItems(selectedDashboards, folder);
onDismiss();
});
}
};
return (
<Modal
className={styles.modal}
title="Choose Dashboard Folder"
icon="folder-plus"
isOpen={isOpen}
onDismiss={onDismiss}
>
<>
<div className={styles.content}>
<p>
Move the {selectedDashboards.length} selected dashboard{selectedDashboards.length === 1 ? '' : 's'} to the
following folder:
</p>
<FolderPicker onChange={f => setFolder(f as FolderInfo)} useNewForms />
</div>
<HorizontalGroup justify="center">
<Button variant="primary" onClick={moveTo}>
Move
</Button>
<Button variant="secondary" onClick={onDismiss}>
Cancel
</Button>
</HorizontalGroup>
</>
</Modal>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
modal: css`
width: 500px;
`,
content: css`
margin-bottom: ${theme.spacing.lg};
`,
};
});
import React, { useContext } from 'react'; import React, { FC, useContext } from 'react';
import { css, cx } from 'emotion'; import { css, cx } from 'emotion';
import { ThemeContext, Icon, Input } from '@grafana/ui'; import { ThemeContext, Icon, Input } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
import { SearchQuery } from 'app/core/components/search/search'; import { DashboardQuery } from '../types';
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>; type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
interface SearchFieldProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> { interface SearchFieldProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
query: SearchQuery; query: DashboardQuery;
onChange: (query: string) => void; onChange: (query: string) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void; onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
clearable?: boolean;
} }
const getSearchFieldStyles = (theme: GrafanaTheme) => ({ const getSearchFieldStyles = (theme: GrafanaTheme) => ({
...@@ -24,7 +25,6 @@ const getSearchFieldStyles = (theme: GrafanaTheme) => ({ ...@@ -24,7 +25,6 @@ const getSearchFieldStyles = (theme: GrafanaTheme) => ({
`, `,
input: css` input: css`
max-width: 683px; max-width: 683px;
padding-left: ${theme.spacing.md};
margin-right: 90px; margin-right: 90px;
box-sizing: border-box; box-sizing: border-box;
outline: none; outline: none;
...@@ -45,18 +45,20 @@ const getSearchFieldStyles = (theme: GrafanaTheme) => ({ ...@@ -45,18 +45,20 @@ const getSearchFieldStyles = (theme: GrafanaTheme) => ({
font-size: ${theme.typography.size.sm}; font-size: ${theme.typography.size.sm};
color: ${theme.colors.textWeak}; color: ${theme.colors.textWeak};
text-decoration: underline; text-decoration: underline;
&:hover {
cursor: pointer;
color: ${theme.colors.textStrong};
}
`, `,
}); });
export const SearchField: React.FunctionComponent<SearchFieldProps> = ({ query, onChange, size, ...inputProps }) => { export const SearchField: FC<SearchFieldProps> = ({ query, onChange, size, clearable, className, ...inputProps }) => {
const theme = useContext(ThemeContext); const theme = useContext(ThemeContext);
const styles = getSearchFieldStyles(theme); const styles = getSearchFieldStyles(theme);
return ( return (
<> <div className={cx(styles.wrapper, className)}>
{/* search-field-wrapper class name left on purpose until we migrate entire search to React */}
{/* based on it GrafanaCtrl (L256) decides whether or not hide search */}
<div className={`${styles.wrapper} search-field-wrapper`}>
<Input <Input
type="text" type="text"
placeholder="Search dashboards by name" placeholder="Search dashboards by name"
...@@ -69,15 +71,16 @@ export const SearchField: React.FunctionComponent<SearchFieldProps> = ({ query, ...@@ -69,15 +71,16 @@ export const SearchField: React.FunctionComponent<SearchFieldProps> = ({ query,
className={styles.input} className={styles.input}
prefix={<Icon name="search" />} prefix={<Icon name="search" />}
suffix={ suffix={
<a className={styles.clearButton} onClick={() => onChange('')}> clearable && (
<span className={styles.clearButton} onClick={() => onChange('')}>
Clear Clear
</a> </span>
)
} }
{...inputProps} {...inputProps}
/> />
<div className={styles.spacer} /> <div className={styles.spacer} />
</div> </div>
</>
); );
}; };
...@@ -20,9 +20,10 @@ const data = { ...@@ -20,9 +20,10 @@ const data = {
const setup = (propOverrides?: Partial<Props>, renderMethod = shallow) => { const setup = (propOverrides?: Partial<Props>, renderMethod = shallow) => {
const props: Props = { const props: Props = {
item: data, item: data,
onToggleSelection: jest.fn(),
onTagSelected: jest.fn(), onTagSelected: jest.fn(),
editable: false, editable: false,
//@ts-ignore
onToggleAllChecked: jest.fn(),
}; };
Object.assign(props, propOverrides); Object.assign(props, propOverrides);
......
...@@ -5,19 +5,19 @@ import { e2e } from '@grafana/e2e'; ...@@ -5,19 +5,19 @@ import { e2e } from '@grafana/e2e';
import { Icon, useTheme, TagList, styleMixins, stylesFactory } from '@grafana/ui'; import { Icon, useTheme, TagList, styleMixins, stylesFactory } from '@grafana/ui';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { CoreEvents } from 'app/types'; import { CoreEvents } from 'app/types';
import { DashboardSectionItem, ItemClickWithEvent } from '../types'; import { DashboardSectionItem, OnToggleChecked } from '../types';
import { SearchCheckbox } from './SearchCheckbox'; import { SearchCheckbox } from './SearchCheckbox';
export interface Props { export interface Props {
item: DashboardSectionItem; item: DashboardSectionItem;
editable?: boolean; editable?: boolean;
onToggleSelection?: ItemClickWithEvent;
onTagSelected: (name: string) => any; onTagSelected: (name: string) => any;
onToggleChecked?: OnToggleChecked;
} }
const { selectors } = e2e.pages.Dashboards; const { selectors } = e2e.pages.Dashboards;
export const SearchItem: FC<Props> = ({ item, editable, onToggleSelection = () => {}, onTagSelected }) => { export const SearchItem: FC<Props> = ({ item, editable, onToggleChecked, onTagSelected }) => {
const theme = useTheme(); const theme = useTheme();
const styles = getResultsItemStyles(theme); const styles = getResultsItemStyles(theme);
const inputEl = useRef<HTMLInputElement>(null); const inputEl = useRef<HTMLInputElement>(null);
...@@ -47,8 +47,11 @@ export const SearchItem: FC<Props> = ({ item, editable, onToggleSelection = () = ...@@ -47,8 +47,11 @@ export const SearchItem: FC<Props> = ({ item, editable, onToggleSelection = () =
}, []); }, []);
const toggleItem = useCallback( const toggleItem = useCallback(
(event: React.MouseEvent<HTMLElement>) => { (event: React.MouseEvent) => {
onToggleSelection(item, event); event.preventDefault();
if (onToggleChecked) {
onToggleChecked(item);
}
}, },
[item] [item]
); );
......
import React, { FC, Dispatch } from 'react'; import React, { FC } from 'react';
import { css, cx } from 'emotion'; import { css, cx } from 'emotion';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
import { Icon, stylesFactory, useTheme, IconName, IconButton } from '@grafana/ui'; import { Icon, stylesFactory, useTheme, IconName, IconButton, Spinner } from '@grafana/ui';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { CoreEvents } from 'app/types'; import { CoreEvents } from 'app/types';
import { DashboardSection, ItemClickWithEvent, SearchAction } from '../types'; import { DashboardSection, OnToggleChecked } from '../types';
import { SearchItem } from './SearchItem'; import { SearchItem } from './SearchItem';
import { SearchCheckbox } from './SearchCheckbox'; import { SearchCheckbox } from './SearchCheckbox';
export interface Props { export interface Props {
dispatch?: Dispatch<SearchAction>;
editable?: boolean; editable?: boolean;
loading?: boolean; loading?: boolean;
onFolderExpanding?: () => void;
onSelectionChanged?: () => void;
onTagSelected: (name: string) => any; onTagSelected: (name: string) => any;
onToggleSection?: any; onToggleChecked?: OnToggleChecked;
onToggleSelection?: ItemClickWithEvent; onToggleSection: (section: DashboardSection) => void;
results: DashboardSection[] | undefined; results: DashboardSection[] | undefined;
} }
export const SearchResults: FC<Props> = ({ export const SearchResults: FC<Props> = ({
editable, editable,
loading, loading,
onFolderExpanding,
onSelectionChanged,
onTagSelected, onTagSelected,
onToggleChecked,
onToggleSection, onToggleSection,
onToggleSelection,
results, results,
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const styles = getSectionStyles(theme); const styles = getSectionStyles(theme);
const toggleFolderExpand = (section: DashboardSection) => {
if (onToggleSection) {
onToggleSection(section);
} else {
if (section.toggle) {
if (!section.expanded && onFolderExpanding) {
onFolderExpanding();
}
section.toggle(section).then(() => {
if (onSelectionChanged) {
onSelectionChanged();
}
});
}
}
};
if (loading) { if (loading) {
return <PageLoader />; return <Spinner className={styles.spinner} />;
} else if (!results || !results.length) { } else if (!results || !results.length) {
return <h6>No dashboards matching your query were found.</h6>; return <h6>No dashboards matching your query were found.</h6>;
} }
return ( return (
<div className="search-results-container">
<ul className={styles.wrapper}> <ul className={styles.wrapper}>
{results.map(section => ( {results.map(section => (
<li aria-label="Search section" className={styles.section} key={section.title}> <li aria-label="Search section" className={styles.section} key={section.title}>
<SectionHeader onSectionClick={toggleFolderExpand} {...{ onToggleSelection, editable, section }} /> <SectionHeader onSectionClick={onToggleSection} {...{ onToggleChecked, editable, section }} />
<ul aria-label="Search items" className={styles.wrapper}> <ul aria-label="Search items" className={styles.wrapper}>
{section.expanded && {section.expanded &&
section.items.map(item => ( section.items.map(item => (
<SearchItem key={item.id} {...{ item, editable, onToggleSelection, onTagSelected }} /> <SearchItem key={item.id} {...{ item, editable, onToggleChecked, onTagSelected }} />
))} ))}
</ul> </ul>
</li> </li>
))} ))}
</ul> </ul>
</div>
); );
}; };
...@@ -86,36 +64,41 @@ const getSectionStyles = stylesFactory((theme: GrafanaTheme) => { ...@@ -86,36 +64,41 @@ const getSectionStyles = stylesFactory((theme: GrafanaTheme) => {
padding: 0px 4px 4px 4px; padding: 0px 4px 4px 4px;
margin-bottom: 3px; margin-bottom: 3px;
`, `,
spinner: css`
display: flex;
justify-content: center;
align-items: center;
min-height: 100px;
`,
}; };
}); });
interface SectionHeaderProps { interface SectionHeaderProps {
section: DashboardSection;
onSectionClick: (section: DashboardSection) => void;
onToggleSelection?: ItemClickWithEvent;
editable?: boolean; editable?: boolean;
onSectionClick: (section: DashboardSection) => void;
onToggleChecked?: OnToggleChecked;
section: DashboardSection;
} }
const SectionHeader: FC<SectionHeaderProps> = ({ const SectionHeader: FC<SectionHeaderProps> = ({ section, onSectionClick, onToggleChecked, editable = false }) => {
section,
onSectionClick,
onToggleSelection = () => {},
editable = false,
}) => {
const theme = useTheme(); const theme = useTheme();
const styles = getSectionHeaderStyles(theme, section.selected); const styles = getSectionHeaderStyles(theme, section.selected);
const expandSection = () => { const onSectionExpand = () => {
onSectionClick(section); onSectionClick(section);
}; };
const onSectionChecked = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (onToggleChecked) {
onToggleChecked(section);
}
};
return !section.hideHeader ? ( return !section.hideHeader ? (
<div className={styles.wrapper} onClick={expandSection}> <div className={styles.wrapper} onClick={onSectionExpand}>
<SearchCheckbox <SearchCheckbox editable={editable} checked={section.checked} onClick={onSectionChecked} />
editable={editable}
checked={section.checked}
onClick={(e: MouseEvent) => onToggleSelection(section, e)}
/>
<Icon className={styles.icon} name={section.icon as IconName} /> <Icon className={styles.icon} name={section.icon as IconName} />
<span className={styles.text}>{section.title}</span> <span className={styles.text}>{section.title}</span>
......
...@@ -15,12 +15,11 @@ const setup = (propOverrides?: Partial<Props>, renderMethod = shallow) => { ...@@ -15,12 +15,11 @@ const setup = (propOverrides?: Partial<Props>, renderMethod = shallow) => {
canMove: false, canMove: false,
deleteItem: noop, deleteItem: noop,
moveTo: noop, moveTo: noop,
onSelectAllChanged: noop,
onStarredFilterChange: noop, onStarredFilterChange: noop,
onTagFilterChange: noop, onTagFilterChange: noop,
selectedStarredFilter: 'starred', onToggleAllChecked: noop,
selectedTagFilter: 'tag', selectedStarredFilter: false,
tagFilterOptions: [], selectedTagFilter: ['tag'],
}; };
Object.assign(props, propOverrides); Object.assign(props, propOverrides);
...@@ -79,7 +78,7 @@ describe('SearchResultsFilter', () => { ...@@ -79,7 +78,7 @@ describe('SearchResultsFilter', () => {
{ value: 'tag2', label: 'Tag 2' }, { value: 'tag2', label: 'Tag 2' },
]; ];
//@ts-ignore //@ts-ignore
const { wrapper } = setup({ onTagFilterChange: mockFilterByTags, tagFilterOptions: tags }, mount); const { wrapper } = setup({ onTagFilterChange: mockFilterByTags }, mount);
wrapper wrapper
.find({ placeholder: 'Filter by tag' }) .find({ placeholder: 'Filter by tag' })
.at(0) .at(0)
...@@ -87,11 +86,4 @@ describe('SearchResultsFilter', () => { ...@@ -87,11 +86,4 @@ describe('SearchResultsFilter', () => {
expect(mockFilterByTags).toHaveBeenCalledTimes(1); expect(mockFilterByTags).toHaveBeenCalledTimes(1);
expect(mockFilterByTags).toHaveBeenCalledWith(tags[0]); expect(mockFilterByTags).toHaveBeenCalledWith(tags[0]);
}); });
it('should call "onSelectAllChanged" when checkbox is changed', () => {
const mockSelectAll = jest.fn();
const { wrapper } = setup({ onSelectAllChanged: mockSelectAll });
wrapper.find('Checkbox').simulate('change');
expect(mockSelectAll).toHaveBeenCalledTimes(1);
});
}); });
...@@ -2,6 +2,8 @@ import React, { FC } from 'react'; ...@@ -2,6 +2,8 @@ import React, { FC } from 'react';
import { css } from 'emotion'; import { css } from 'emotion';
import { Button, Select, Checkbox, stylesFactory, useTheme, HorizontalGroup } from '@grafana/ui'; import { Button, Select, Checkbox, stylesFactory, useTheme, HorizontalGroup } from '@grafana/ui';
import { GrafanaTheme, SelectableValue } from '@grafana/data'; import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { TagFilter } from 'app/core/components/TagFilter/TagFilter';
import { SearchSrv } from 'app/core/services/search_srv';
type onSelectChange = (value: SelectableValue) => void; type onSelectChange = (value: SelectableValue) => void;
...@@ -11,12 +13,11 @@ export interface Props { ...@@ -11,12 +13,11 @@ export interface Props {
canMove?: boolean; canMove?: boolean;
deleteItem: () => void; deleteItem: () => void;
moveTo: () => void; moveTo: () => void;
onSelectAllChanged: any;
onStarredFilterChange: onSelectChange; onStarredFilterChange: onSelectChange;
onTagFilterChange: onSelectChange; onTagFilterChange: onSelectChange;
selectedStarredFilter: string; onToggleAllChecked: () => void;
selectedTagFilter: string; selectedStarredFilter: boolean;
tagFilterOptions: SelectableValue[]; selectedTagFilter: string[];
} }
const starredFilterOptions = [ const starredFilterOptions = [
...@@ -24,18 +25,19 @@ const starredFilterOptions = [ ...@@ -24,18 +25,19 @@ const starredFilterOptions = [
{ label: 'No', value: false }, { label: 'No', value: false },
]; ];
const searchSrv = new SearchSrv();
export const SearchResultsFilter: FC<Props> = ({ export const SearchResultsFilter: FC<Props> = ({
allChecked, allChecked,
canDelete, canDelete,
canMove, canMove,
deleteItem, deleteItem,
moveTo, moveTo,
onSelectAllChanged, onToggleAllChecked,
onStarredFilterChange, onStarredFilterChange,
onTagFilterChange, onTagFilterChange,
selectedStarredFilter, selectedStarredFilter = false,
selectedTagFilter, selectedTagFilter,
tagFilterOptions,
}) => { }) => {
const showActions = canDelete || canMove; const showActions = canDelete || canMove;
const theme = useTheme(); const theme = useTheme();
...@@ -43,7 +45,7 @@ export const SearchResultsFilter: FC<Props> = ({ ...@@ -43,7 +45,7 @@ export const SearchResultsFilter: FC<Props> = ({
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<Checkbox value={allChecked} onChange={onSelectAllChanged} /> <Checkbox value={allChecked} onChange={onToggleAllChecked} />
{showActions ? ( {showActions ? (
<HorizontalGroup spacing="md"> <HorizontalGroup spacing="md">
<Button disabled={!canMove} onClick={moveTo} icon="exchange-alt" variant="secondary"> <Button disabled={!canMove} onClick={moveTo} icon="exchange-alt" variant="secondary">
...@@ -58,17 +60,18 @@ export const SearchResultsFilter: FC<Props> = ({ ...@@ -58,17 +60,18 @@ export const SearchResultsFilter: FC<Props> = ({
<Select <Select
size="sm" size="sm"
placeholder="Filter by starred" placeholder="Filter by starred"
key={selectedStarredFilter} key={starredFilterOptions?.find(f => f.value === selectedStarredFilter)?.label}
options={starredFilterOptions} options={starredFilterOptions}
onChange={onStarredFilterChange} onChange={onStarredFilterChange}
/> />
<Select <TagFilter
size="sm" size="sm"
placeholder="Filter by tag" placeholder="Filter by tag"
key={selectedTagFilter} tags={selectedTagFilter}
options={tagFilterOptions} tagOptions={searchSrv.getDashboardTags}
onChange={onTagFilterChange} onChange={onTagFilterChange}
hideValues
/> />
</HorizontalGroup> </HorizontalGroup>
)} )}
...@@ -83,6 +86,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => { ...@@ -83,6 +86,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: ${theme.spacing.sm};
label { label {
height: 20px; height: 20px;
......
import { KeyboardEvent, useReducer } from 'react';
import { getLocationSrv } from '@grafana/runtime';
import { DashboardQuery, DashboardSearchItemType, DashboardSection } from '../types';
import { MOVE_SELECTION_DOWN, MOVE_SELECTION_UP } from '../reducers/actionTypes';
import { dashboardsSearchState, DashboardsSearchState, searchReducer } from '../reducers/dashboardSearch';
import { findSelected } from '../utils';
import { useSearch } from './useSearch';
export const useDashboardSearch = (query: DashboardQuery, onCloseSearch: () => void) => {
const reducer = useReducer(searchReducer, dashboardsSearchState);
const {
state: { results, loading },
onToggleSection,
dispatch,
} = useSearch<DashboardsSearchState>(query, reducer, { queryParsing: true });
const onKeyDown = (event: 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);
}
}
}
};
return {
results,
loading,
onToggleSection,
onKeyDown,
};
};
import { useMemo, useReducer } from 'react';
import { backendSrv } from 'app/core/services/backend_srv';
import { DashboardQuery, DashboardSection, OnDeleteItems, OnMoveItems, OnToggleChecked } from '../types';
import {
DELETE_ITEMS,
MOVE_ITEMS,
TOGGLE_ALL_CHECKED,
TOGGLE_CHECKED,
TOGGLE_CAN_SAVE,
TOGGLE_EDIT_PERMISSIONS,
} from '../reducers/actionTypes';
import { manageDashboardsReducer, manageDashboardsState, ManageDashboardsState } from '../reducers/manageDashboards';
import { useSearch } from './useSearch';
export const useManageDashboards = (
query: DashboardQuery,
state: Partial<ManageDashboardsState> = {},
folderUid?: string
) => {
const reducer = useReducer(manageDashboardsReducer, {
...manageDashboardsState,
...state,
});
const searchCallback = (folderUid: string | undefined) => {
if (folderUid) {
backendSrv.getFolderByUid(folderUid).then(folder => {
dispatch({ type: TOGGLE_CAN_SAVE, payload: folder.canSave });
if (!folder.canSave) {
dispatch({ type: TOGGLE_EDIT_PERMISSIONS, payload: false });
}
});
}
};
const {
state: { results, loading, canSave, allChecked, hasEditPermissionInFolders },
onToggleSection,
dispatch,
} = useSearch<ManageDashboardsState>(query, reducer, { folderUid, searchCallback });
const onToggleChecked: OnToggleChecked = item => {
dispatch({ type: TOGGLE_CHECKED, payload: item });
};
const onToggleAllChecked = () => {
dispatch({ type: TOGGLE_ALL_CHECKED });
};
const onDeleteItems: OnDeleteItems = (folders, dashboards) => {
dispatch({ type: DELETE_ITEMS, payload: { folders, dashboards } });
};
const onMoveItems: OnMoveItems = (selectedDashboards, folder) => {
dispatch({ type: MOVE_ITEMS, payload: { dashboards: selectedDashboards, folder } });
};
const canMove = useMemo(() => results.some((result: DashboardSection) => result.items.some(item => item.checked)), [
results,
]);
const canDelete = useMemo(() => canMove || results.some((result: DashboardSection) => result.checked), [
canMove,
results,
]);
return {
results,
loading,
canSave,
allChecked,
hasEditPermissionInFolders,
canMove,
canDelete,
onToggleSection,
onToggleChecked,
onToggleAllChecked,
onDeleteItems,
onMoveItems,
};
};
import { useDebounce } from 'react-use';
import { SearchSrv } from 'app/core/services/search_srv';
import { backendSrv } from 'app/core/services/backend_srv';
import { FETCH_RESULTS, FETCH_ITEMS, TOGGLE_SECTION } from '../reducers/actionTypes';
import { DashboardSection, UseSearch } from '../types';
import { hasId, getParsedQuery } from '../utils';
const searchSrv = new SearchSrv();
/**
* Base hook for search functionality.
* Returns state and dispatch, among others, from 'reducer' param, so it can be
* further extended.
* @param query
* @param reducer - return result of useReducer
* @param params - custom params
*/
export const useSearch: UseSearch = (query, reducer, params) => {
const { queryParsing, folderUid, searchCallback } = params;
const [state, dispatch] = reducer;
const search = () => {
const parsedQuery = getParsedQuery(query, queryParsing);
searchSrv.search(parsedQuery).then(results => {
// Remove header for folder search
if (query.folderIds.length === 1 && results.length) {
results[0].hideHeader = true;
}
dispatch({ type: FETCH_RESULTS, payload: results });
if (searchCallback) {
searchCallback(folderUid);
}
});
};
useDebounce(search, 300, [query, folderUid, queryParsing]);
// TODO as possible improvement, show spinner after expanding section while items are fetching
const onToggleSection = (section: DashboardSection) => {
if (hasId(section.title) && !section.items.length) {
backendSrv.search({ ...query, 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 });
}
};
return { state, dispatch, onToggleSection };
};
import { useReducer } from 'react';
import { SelectableValue } from '@grafana/data';
import { defaultQuery, queryReducer } from '../reducers/searchQueryReducer';
import {
ADD_TAG,
CLEAR_FILTERS,
QUERY_CHANGE,
REMOVE_STARRED,
REMOVE_TAG,
SET_TAGS,
TOGGLE_STARRED,
} from '../reducers/actionTypes';
import { DashboardQuery } from '../types';
export const useSearchQuery = (queryParams: Partial<DashboardQuery>) => {
const initialState = { ...defaultQuery, ...queryParams };
const [query, dispatch] = useReducer(queryReducer, initialState);
const onQueryChange = (query: string) => {
dispatch({ type: QUERY_CHANGE, payload: query });
};
const onRemoveStarred = () => {
dispatch({ type: REMOVE_STARRED });
};
const onTagRemove = (tag: string) => {
dispatch({ type: REMOVE_TAG, payload: tag });
};
const onTagFilterChange = (tags: string[]) => {
dispatch({ type: SET_TAGS, payload: tags });
};
const onTagAdd = (tag: string) => {
dispatch({ type: ADD_TAG, payload: tag });
};
const onClearFilters = () => {
dispatch({ type: CLEAR_FILTERS });
};
const onStarredFilterChange = (filter: SelectableValue) => {
dispatch({ type: TOGGLE_STARRED, payload: filter.value });
};
const hasFilters = query.query.length > 0 || query.tag.length > 0 || query.starred;
return {
query,
hasFilters,
onQueryChange,
onRemoveStarred,
onTagRemove,
onClearFilters,
onTagFilterChange,
onStarredFilterChange,
onTagAdd,
};
};
...@@ -4,4 +4,7 @@ export { SearchItem } from './components/SearchItem'; ...@@ -4,4 +4,7 @@ export { SearchItem } from './components/SearchItem';
export { SearchCheckbox } from './components/SearchCheckbox'; export { SearchCheckbox } from './components/SearchCheckbox';
export { SearchWrapper } from './components/SearchWrapper'; export { SearchWrapper } from './components/SearchWrapper';
export { SearchResultsFilter } from './components/SearchResultsFilter'; export { SearchResultsFilter } from './components/SearchResultsFilter';
export { ManageDashboards } from './components/ManageDashboards';
export { ConfirmDeleteModal } from './components/ConfirmDeleteModal';
export { MoveToFolderModal } from './components/MoveToFolderModal';
export * from './types'; export * from './types';
...@@ -3,3 +3,20 @@ export const TOGGLE_SECTION = 'TOGGLE_SECTION'; ...@@ -3,3 +3,20 @@ export const TOGGLE_SECTION = 'TOGGLE_SECTION';
export const FETCH_ITEMS = 'FETCH_ITEMS'; export const FETCH_ITEMS = 'FETCH_ITEMS';
export const MOVE_SELECTION_UP = 'MOVE_SELECTION_UP'; export const MOVE_SELECTION_UP = 'MOVE_SELECTION_UP';
export const MOVE_SELECTION_DOWN = 'MOVE_SELECTION_DOWN'; export const MOVE_SELECTION_DOWN = 'MOVE_SELECTION_DOWN';
// Manage dashboards
export const TOGGLE_CAN_SAVE = 'TOGGLE_CAN_SAVE';
export const TOGGLE_EDIT_PERMISSIONS = 'TOGGLE_EDIT_PERMISSIONS';
export const TOGGLE_ALL_CHECKED = 'TOGGLE_ALL_CHECKED';
export const TOGGLE_CHECKED = 'TOGGLE_SECTION_CHECKED';
export const MOVE_ITEMS = 'MOVE_ITEMS';
export const DELETE_ITEMS = 'DELETE_ITEMS';
// Search Query
export const TOGGLE_STARRED = 'TOGGLE_STARRED';
export const REMOVE_STARRED = 'REMOVE_STARRED';
export const QUERY_CHANGE = 'QUERY_CHANGE';
export const REMOVE_TAG = 'REMOVE_TAG';
export const CLEAR_FILTERS = 'CLEAR_FILTERS';
export const SET_TAGS = 'SET_TAGS';
export const ADD_TAG = 'ADD_TAG';
import { FETCH_ITEMS, FETCH_RESULTS, TOGGLE_SECTION, MOVE_SELECTION_DOWN, MOVE_SELECTION_UP } from './actionTypes'; import { FETCH_ITEMS, FETCH_RESULTS, TOGGLE_SECTION, MOVE_SELECTION_DOWN, MOVE_SELECTION_UP } from './actionTypes';
import { searchReducer as reducer, initialState } from './dashboardSearch'; import { searchReducer as reducer, dashboardsSearchState } from './dashboardSearch';
import { searchResults, sections } from '../testData'; import { searchResults, sections } from '../testData';
describe('Dashboard Search reducer', () => { describe('Dashboard Search reducer', () => {
it('should return the initial state', () => { it('should return the initial state', () => {
expect(reducer(initialState, {} as any)).toEqual(initialState); expect(reducer(dashboardsSearchState, {} as any)).toEqual(dashboardsSearchState);
}); });
it('should set the results and mark first item as selected', () => { it('should set the results and mark first item as selected', () => {
const newState = reducer(initialState, { type: FETCH_RESULTS, payload: searchResults }); const newState = reducer(dashboardsSearchState, { type: FETCH_RESULTS, payload: searchResults });
expect(newState).toEqual({ loading: false, selectedIndex: 0, results: searchResults }); expect(newState).toEqual({ loading: false, selectedIndex: 0, results: searchResults });
expect(newState.results[0].selected).toBeTruthy(); expect(newState.results[0].selected).toBeTruthy();
}); });
it('should toggle selected section', () => { it('should toggle selected section', () => {
const newState = reducer({ loading: false, results: sections }, { type: TOGGLE_SECTION, payload: sections[5] }); const newState = reducer(
{ selectedIndex: 0, loading: false, results: sections as any[] },
{ type: TOGGLE_SECTION, payload: sections[5] }
);
expect(newState.results[5].expanded).toBeFalsy(); expect(newState.results[5].expanded).toBeFalsy();
const newState2 = reducer({ loading: false, results: sections }, { type: TOGGLE_SECTION, payload: sections[1] }); const newState2 = reducer(
{ selectedIndex: 0, loading: false, results: sections as any[] },
{ type: TOGGLE_SECTION, payload: sections[1] }
);
expect(newState2.results[1].expanded).toBeTruthy(); expect(newState2.results[1].expanded).toBeTruthy();
}); });
...@@ -37,7 +44,7 @@ describe('Dashboard Search reducer', () => { ...@@ -37,7 +44,7 @@ describe('Dashboard Search reducer', () => {
}, },
]; ];
const newState = reducer( const newState = reducer(
{ loading: false, results: sections }, { selectedIndex: 0, loading: false, results: sections as any[] },
{ {
type: FETCH_ITEMS, type: FETCH_ITEMS,
payload: { payload: {
...@@ -51,7 +58,7 @@ describe('Dashboard Search reducer', () => { ...@@ -51,7 +58,7 @@ describe('Dashboard Search reducer', () => {
it('should handle MOVE_SELECTION_DOWN', () => { it('should handle MOVE_SELECTION_DOWN', () => {
const newState = reducer( const newState = reducer(
{ loading: false, selectedIndex: 0, results: sections }, { loading: false, selectedIndex: 0, results: sections as any[] },
{ {
type: MOVE_SELECTION_DOWN, type: MOVE_SELECTION_DOWN,
} }
...@@ -69,7 +76,7 @@ describe('Dashboard Search reducer', () => { ...@@ -69,7 +76,7 @@ describe('Dashboard Search reducer', () => {
// Shouldn't go over the visible results length - 1 (9) // Shouldn't go over the visible results length - 1 (9)
const newState3 = reducer( const newState3 = reducer(
{ loading: false, selectedIndex: 9, results: sections }, { loading: false, selectedIndex: 9, results: sections as any[] },
{ {
type: MOVE_SELECTION_DOWN, type: MOVE_SELECTION_DOWN,
} }
...@@ -80,7 +87,7 @@ describe('Dashboard Search reducer', () => { ...@@ -80,7 +87,7 @@ describe('Dashboard Search reducer', () => {
it('should handle MOVE_SELECTION_UP', () => { it('should handle MOVE_SELECTION_UP', () => {
// shouldn't move beyond 0 // shouldn't move beyond 0
const newState = reducer( const newState = reducer(
{ loading: false, selectedIndex: 0, results: sections }, { loading: false, selectedIndex: 0, results: sections as any[] },
{ {
type: MOVE_SELECTION_UP, type: MOVE_SELECTION_UP,
} }
...@@ -89,7 +96,7 @@ describe('Dashboard Search reducer', () => { ...@@ -89,7 +96,7 @@ describe('Dashboard Search reducer', () => {
expect(newState.selectedIndex).toEqual(0); expect(newState.selectedIndex).toEqual(0);
const newState2 = reducer( const newState2 = reducer(
{ loading: false, selectedIndex: 3, results: sections }, { loading: false, selectedIndex: 3, results: sections as any[] },
{ {
type: MOVE_SELECTION_UP, type: MOVE_SELECTION_UP,
} }
......
...@@ -2,19 +2,19 @@ import { DashboardSection, SearchAction } from '../types'; ...@@ -2,19 +2,19 @@ import { DashboardSection, SearchAction } from '../types';
import { getFlattenedSections, getLookupField, markSelected } from '../utils'; import { getFlattenedSections, getLookupField, markSelected } from '../utils';
import { FETCH_ITEMS, FETCH_RESULTS, TOGGLE_SECTION, MOVE_SELECTION_DOWN, MOVE_SELECTION_UP } from './actionTypes'; import { FETCH_ITEMS, FETCH_RESULTS, TOGGLE_SECTION, MOVE_SELECTION_DOWN, MOVE_SELECTION_UP } from './actionTypes';
interface State { export interface DashboardsSearchState {
results: DashboardSection[]; results: DashboardSection[];
loading: boolean; loading: boolean;
selectedIndex: number; selectedIndex: number;
} }
export const initialState: State = { export const dashboardsSearchState: DashboardsSearchState = {
results: [], results: [],
loading: true, loading: true,
selectedIndex: 0, selectedIndex: 0,
}; };
export const searchReducer = (state: any, action: SearchAction) => { export const searchReducer = (state: DashboardsSearchState, action: SearchAction) => {
switch (action.type) { switch (action.type) {
case FETCH_RESULTS: { case FETCH_RESULTS: {
const results = action.payload; const results = action.payload;
......
import {
TOGGLE_CAN_SAVE,
TOGGLE_EDIT_PERMISSIONS,
TOGGLE_ALL_CHECKED,
TOGGLE_CHECKED,
DELETE_ITEMS,
MOVE_ITEMS,
} from './actionTypes';
import { manageDashboardsReducer as reducer, manageDashboardsState as state } from './manageDashboards';
import { sections } from '../testData';
import { UidsToDelete } from '../types';
// Remove Recent and Starred sections as they're not used in manage dashboards
const results = sections.slice(2);
describe('Manage dashboards reducer', () => {
it('should return the initial state', () => {
expect(reducer(state, {} as any)).toEqual(state);
});
it('should handle TOGGLE_ALL_CHECKED', () => {
const newState = reducer({ ...state, results }, { type: TOGGLE_ALL_CHECKED });
expect(newState.results.every((result: any) => result.checked === true)).toBe(true);
expect(newState.results.every((result: any) => result.items.every((item: any) => item.checked === true))).toBe(
true
);
expect(newState.allChecked).toBe(true);
const newState2 = reducer({ ...newState, results }, { type: TOGGLE_ALL_CHECKED });
expect(newState2.results.every((result: any) => result.checked === false)).toBe(true);
expect(newState2.results.every((result: any) => result.items.every((item: any) => item.checked === false))).toBe(
true
);
expect(newState2.allChecked).toBe(false);
});
it('should handle TOGGLE_CAN_SAVE', () => {
const newState = reducer(state, { type: TOGGLE_CAN_SAVE, payload: true });
expect(newState.canSave).toBe(true);
});
it('should handle TOGGLE_EDIT_PERMISSIONS', () => {
const newState = reducer(state, { type: TOGGLE_EDIT_PERMISSIONS, payload: true });
expect(newState.hasEditPermissionInFolders).toBe(true);
});
it('should handle TOGGLE_CHECKED sections', () => {
const newState = reducer({ ...state, results }, { type: TOGGLE_CHECKED, payload: results[0] });
expect(newState.results[0].checked).toBe(true);
expect(newState.results[1].checked).toBeFalsy();
const newState2 = reducer(newState, { type: TOGGLE_CHECKED, payload: results[1] });
expect(newState2.results[0].checked).toBe(true);
expect(newState2.results[1].checked).toBe(true);
});
it('should handle TOGGLE_CHECKED items', () => {
const newState = reducer({ ...state, results }, { type: TOGGLE_CHECKED, payload: { id: 4069 } });
expect(newState.results[3].items[0].checked).toBe(true);
const newState2 = reducer(newState, { type: TOGGLE_CHECKED, payload: { id: 1 } });
expect(newState2.results[3].items[0].checked).toBe(true);
expect(newState2.results[3].items[1].checked).toBeFalsy();
expect(newState2.results[3].items[2].checked).toBe(true);
});
it('should handle DELETE_ITEMS', () => {
const toDelete: UidsToDelete = { dashboards: ['OzAIf_rWz', 'lBdLINUWk'], folders: ['search-test-data'] };
const newState = reducer({ ...state, results }, { type: DELETE_ITEMS, payload: toDelete });
expect(newState.results).toHaveLength(3);
expect(newState.results[1].id).toEqual(4074);
expect(newState.results[2].items).toHaveLength(1);
expect(newState.results[2].items[0].id).toEqual(4069);
});
it('should handle MOVE_ITEMS', () => {
// Move 2 dashboards to a folder with id 2
const toMove = {
dashboards: [
{
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,
},
],
folder: { id: 2 },
};
const newState = reducer({ ...state, results }, { type: MOVE_ITEMS, payload: toMove });
expect(newState.results[0].items).toHaveLength(2);
expect(newState.results[0].items[0].uid).toEqual('OzAIf_rWz');
expect(newState.results[0].items[1].uid).toEqual('lBdLINUWk');
expect(newState.results[3].items).toHaveLength(1);
expect(newState.results[3].items[0].uid).toEqual('LCFWfl9Zz');
});
});
import { DashboardSectionItem, SearchAction } from '../types';
import {
TOGGLE_CAN_SAVE,
TOGGLE_EDIT_PERMISSIONS,
TOGGLE_ALL_CHECKED,
TOGGLE_CHECKED,
MOVE_ITEMS,
DELETE_ITEMS,
} from './actionTypes';
import { dashboardsSearchState, DashboardsSearchState, searchReducer } from './dashboardSearch';
import { mergeReducers } from '../utils';
export interface ManageDashboardsState extends DashboardsSearchState {
canSave: boolean;
allChecked: boolean;
hasEditPermissionInFolders: boolean;
}
export const manageDashboardsState: ManageDashboardsState = {
...dashboardsSearchState,
canSave: false,
allChecked: false,
hasEditPermissionInFolders: false,
};
const reducer = (state: ManageDashboardsState, action: SearchAction) => {
switch (action.type) {
case TOGGLE_CAN_SAVE:
return { ...state, canSave: action.payload };
case TOGGLE_EDIT_PERMISSIONS:
return { ...state, hasEditPermissionInFolders: action.payload };
case TOGGLE_ALL_CHECKED:
const newAllChecked = !state.allChecked;
return {
...state,
results: state.results.map(result => {
return {
...result,
checked: newAllChecked,
items: result.items.map(item => ({ ...item, checked: newAllChecked })),
};
}),
allChecked: newAllChecked,
};
case TOGGLE_CHECKED:
const { id } = action.payload;
return {
...state,
results: state.results.map(result => {
if (result.id === id) {
return {
...result,
checked: !result.checked,
items: result.items.map(item => ({ ...item, checked: !result.checked })),
};
}
return {
...result,
items: result.items.map(item => (item.id === id ? { ...item, checked: !item.checked } : item)),
};
}),
};
case MOVE_ITEMS: {
const { dashboards, folder } = action.payload;
const uids = dashboards.map((d: DashboardSectionItem) => d.uid);
return {
...state,
results: state.results.map(result => {
if (folder.id === result.id) {
return { ...result, items: [...result.items, ...dashboards] };
} else {
return { ...result, items: result.items.filter(item => !uids.includes(item.uid)) };
}
}),
};
}
case DELETE_ITEMS: {
const { folders, dashboards } = action.payload;
if (!folders.length && !dashboards.length) {
return state;
}
return {
...state,
results: state.results.reduce((filtered, result) => {
if (!folders.includes(result.uid)) {
return [...filtered, { ...result, items: result.items.filter(item => !dashboards.includes(item.uid)) }];
}
return filtered;
}, []),
};
}
default:
return state;
}
};
export const manageDashboardsReducer = mergeReducers([searchReducer, reducer]);
import { SearchAction, DashboardQuery } from '../types';
import {
ADD_TAG,
CLEAR_FILTERS,
QUERY_CHANGE,
REMOVE_STARRED,
REMOVE_TAG,
SET_TAGS,
TOGGLE_STARRED,
} from './actionTypes';
export const defaultQuery: DashboardQuery = {
query: '',
tag: [],
starred: false,
skipRecent: false,
skipStarred: false,
folderIds: [],
};
export const queryReducer = (state: DashboardQuery, action: SearchAction) => {
switch (action.type) {
case QUERY_CHANGE:
return { ...state, query: action.payload };
case REMOVE_TAG:
return { ...state, tag: state.tag.filter(t => t !== action.payload) };
case SET_TAGS:
return { ...state, tag: action.payload };
case ADD_TAG: {
const tag = action.payload;
return tag && !state.tag.includes(tag) ? { ...state, tag: [...state.tag, tag] } : state;
}
case TOGGLE_STARRED:
return { ...state, starred: action.payload };
case REMOVE_STARRED:
return { ...state, starred: false };
case CLEAR_FILTERS:
return { ...state, query: '', tag: [], starred: false };
default:
return state;
}
};
...@@ -9,7 +9,7 @@ export const searchResults = [ ...@@ -9,7 +9,7 @@ export const searchResults = [
url: '/dashboards/f/JB_zdOUWk/gdev-dashboards', url: '/dashboards/f/JB_zdOUWk/gdev-dashboards',
icon: 'folder', icon: 'folder',
score: 0, score: 0,
checked: false, checked: true,
}, },
{ {
id: 0, id: 0,
...@@ -26,7 +26,7 @@ export const searchResults = [ ...@@ -26,7 +26,7 @@ export const searchResults = [
//@ts-ignore //@ts-ignore
tags: [], tags: [],
isStarred: false, isStarred: false,
checked: false, checked: true,
}, },
{ {
id: 46, id: 46,
...@@ -38,7 +38,7 @@ export const searchResults = [ ...@@ -38,7 +38,7 @@ export const searchResults = [
type: 'dash-db', type: 'dash-db',
tags: [], tags: [],
isStarred: false, isStarred: false,
checked: false, checked: true,
}, },
], ],
icon: 'folder-open', icon: 'folder-open',
......
import { Dispatch } from 'react';
import { Action } from 'redux';
import { FolderInfo } from '../../types';
export enum DashboardSearchItemType { export enum DashboardSearchItemType {
DashDB = 'dash-db', DashDB = 'dash-db',
DashHome = 'dash-home', DashHome = 'dash-home',
...@@ -42,9 +46,21 @@ export interface DashboardTag { ...@@ -42,9 +46,21 @@ export interface DashboardTag {
count: number; count: number;
} }
export interface SearchAction extends Action {
payload?: any;
}
export interface OpenSearchParams {
query?: string;
}
export interface UidsToDelete {
folders: string[];
dashboards: string[];
}
export interface DashboardQuery { export interface DashboardQuery {
query: string; query: string;
mode: string;
tag: string[]; tag: string[];
starred: boolean; starred: boolean;
skipRecent: boolean; skipRecent: boolean;
...@@ -52,19 +68,19 @@ export interface DashboardQuery { ...@@ -52,19 +68,19 @@ export interface DashboardQuery {
folderIds: number[]; folderIds: number[];
} }
export interface SectionsState { export type SearchReducer<S> = [S, Dispatch<SearchAction>];
sections: DashboardSection[]; interface UseSearchParams {
allChecked: boolean; queryParsing?: boolean;
dashboardTags: DashboardTag[]; searchCallback?: (folderUid: string | undefined) => any;
folderUid?: string;
} }
export type ItemClickWithEvent = (item: DashboardSectionItem | DashboardSection, event: any) => void; export type UseSearch = <S>(
query: DashboardQuery,
export type SearchAction = { reducer: SearchReducer<S>,
type: string; params: UseSearchParams
payload?: any; ) => { state: S; dispatch: Dispatch<SearchAction>; onToggleSection: (section: DashboardSection) => void };
};
export interface OpenSearchParams { export type OnToggleChecked = (item: DashboardSectionItem | DashboardSection) => void;
query?: string; export type OnDeleteItems = (folders: string[], dashboards: string[]) => void;
} export type OnMoveItems = (selectedDashboards: DashboardSectionItem[], folder: FolderInfo | null) => void;
import { findSelected, getFlattenedSections, markSelected } from './utils'; import {
import { DashboardSection } from './types'; findSelected,
import { sections } from './testData'; getCheckedDashboardsUids,
getCheckedUids,
getFlattenedSections,
markSelected,
mergeReducers,
} from './utils';
import { sections, searchResults } from './testData';
describe('Search utils', () => { describe('Search utils', () => {
describe('getFlattenedSections', () => { describe('getFlattenedSections', () => {
it('should return an array of items plus children for expanded items', () => { it('should return an array of items plus children for expanded items', () => {
const flatSections = getFlattenedSections(sections as DashboardSection[]); const flatSections = getFlattenedSections(sections as any[]);
expect(flatSections).toHaveLength(10); expect(flatSections).toHaveLength(10);
expect(flatSections).toEqual([ expect(flatSections).toEqual([
'Starred', 'Starred',
...@@ -80,15 +86,64 @@ describe('Search utils', () => { ...@@ -80,15 +86,64 @@ describe('Search utils', () => {
const results = [...sections, { id: 'Test', selected: true }]; const results = [...sections, { id: 'Test', selected: true }];
const found = findSelected(results); const found = findSelected(results);
expect(found.id).toEqual('Test'); expect(found?.id).toEqual('Test');
}); });
it('should find selected item', () => { it('should find selected item', () => {
const results = [{ expanded: true, id: 'Test', items: [{ id: 1 }, { id: 2, selected: true }, { id: 3 }] }]; const results = [{ expanded: true, id: 'Test', items: [{ id: 1 }, { id: 2, selected: true }, { id: 3 }] }];
const found = findSelected(results); const found = findSelected(results);
expect(found.id).toEqual(2); expect(found?.id).toEqual(2);
}); });
}); });
}); });
describe('mergeReducers', () => {
const reducer1 = (state: any = { reducer1: false }, action: any) => {
if (action.type === 'reducer1') {
return { ...state, reducer1: !state.reducer1 };
}
return state;
};
const reducer2 = (state: any = { reducer2: false }, action: any) => {
if (action.type === 'reducer2') {
return { ...state, reducer2: !state.reducer2 };
}
return state;
};
const mergedReducers = mergeReducers([reducer1, reducer2]);
it('should merge state from all reducers into one without nesting', () => {
expect(mergedReducers({ reducer1: false }, { type: '' })).toEqual({ reducer1: false });
});
it('should correctly set state from multiple reducers', () => {
const state = { reducer1: false, reducer2: true };
const newState = mergedReducers(state, { type: 'reducer2' });
expect(newState).toEqual({ reducer1: false, reducer2: false });
const newState2 = mergedReducers(newState, { type: 'reducer1' });
expect(newState2).toEqual({ reducer1: true, reducer2: false });
});
});
describe('getCheckedUids', () => {
it('should return object with empty arrays if no checked items are available', () => {
expect(getCheckedUids(sections as any[])).toEqual({ folders: [], dashboards: [] });
});
it('should return uids for all checked items', () => {
expect(getCheckedUids(searchResults as any[])).toEqual({
folders: ['JB_zdOUWk'],
dashboards: ['lBdLINUWk', '8DY63kQZk'],
});
});
});
describe('getCheckedDashboardsUids', () => {
it('should get uids of all checked dashboards', () => {
expect(getCheckedDashboardsUids(searchResults as any[])).toEqual(['lBdLINUWk', '8DY63kQZk']);
});
});
}); });
import { DashboardSection, DashboardSectionItem } from './types'; import { DashboardQuery, DashboardSection, DashboardSectionItem, SearchAction, UidsToDelete } from './types';
import { NO_ID_SECTIONS } from './constants'; import { NO_ID_SECTIONS } from './constants';
import { parse, SearchParserResult } from 'search-query-parser'; import { parse, SearchParserResult } from 'search-query-parser';
import { getDashboardSrv } from '../dashboard/services/DashboardSrv';
/** /**
* Check if folder has id. Only Recent and Starred folders are the ones without * Check if folder has id. Only Recent and Starred folders are the ones without
...@@ -83,8 +84,7 @@ export const findSelected = (sections: any): DashboardSection | DashboardSection ...@@ -83,8 +84,7 @@ export const findSelected = (sections: any): DashboardSection | DashboardSection
return null; return null;
}; };
// TODO check if there are any use cases where query isn't a string export const parseQuery = (query: string) => {
export const parseQuery = (query: any) => {
const parsedQuery = parse(query, { const parsedQuery = parse(query, {
keywords: ['folder'], keywords: ['folder'],
}); });
...@@ -97,3 +97,86 @@ export const parseQuery = (query: any) => { ...@@ -97,3 +97,86 @@ export const parseQuery = (query: any) => {
return parsedQuery; return parsedQuery;
}; };
/**
* Merge multiple reducers into one, keeping the state structure flat (no nested
* separate state for each reducer). If there are multiple state slices with the same
* key, the latest reducer's state is applied.
* Compared to Redux's combineReducers this allows multiple reducers to operate
* on the same state or different slices of the same state. Useful when multiple
* components have the same structure but different or extra logic when modifying it.
* If reducers have the same action types, the action types from the rightmost reducer
* take precedence
* @param reducers
*/
export const mergeReducers = (reducers: any[]) => (prevState: any, action: SearchAction) => {
return reducers.reduce((nextState, reducer) => ({ ...nextState, ...reducer(nextState, action) }), prevState);
};
/**
* Collect all the checked dashboards
* @param sections
*/
export const getCheckedDashboards = (sections: DashboardSection[]): DashboardSectionItem[] => {
if (!sections.length) {
return [];
}
return sections.reduce((uids, section) => {
return [...uids, ...section.items.filter(item => item.checked)];
}, []);
};
/**
* Collect uids of all the checked dashboards
* @param sections
*/
export const getCheckedDashboardsUids = (sections: DashboardSection[]) => {
if (!sections.length) {
return [];
}
return getCheckedDashboards(sections).map(item => item.uid);
};
/**
* Collect uids of all checked folders and dashboards. Used for delete operation, among others
* @param sections
*/
export const getCheckedUids = (sections: DashboardSection[]): UidsToDelete => {
const emptyResults: UidsToDelete = { folders: [], dashboards: [] };
if (!sections.length) {
return emptyResults;
}
return sections.reduce((result, section) => {
if (section?.id !== 0 && section.checked) {
return { ...result, folders: [...result.folders, section.uid] };
} else {
return { ...result, dashboards: getCheckedDashboardsUids(sections) };
}
}, emptyResults) as UidsToDelete;
};
/**
* When search is done within a dashboard folder, add folder id to the search query
* to narrow down the results to the folder
* @param query
* @param queryParsing
*/
export const getParsedQuery = (query: DashboardQuery, queryParsing = false) => {
if (!queryParsing) {
return query;
}
let folderIds: number[] = [];
if (parseQuery(query.query).folder === 'current') {
const { folderId } = getDashboardSrv().getCurrent().meta;
if (folderId) {
folderIds = [folderId];
}
}
return { ...query, query: parseQuery(query.query).text as string, folderIds };
};
...@@ -26,7 +26,7 @@ ...@@ -26,7 +26,7 @@
<p class="current-text">current</p> <p class="current-text">current</p>
</div> </div>
<div class="error-row" style="flex: 1"> <div class="error-row" style="flex: 1">
<icon name="minus-circle'" className="error-minus"></icon> <icon name="'minus-circle'" className="error-minus"></icon>
<div class="error-column error-space-between error-full-width"> <div class="error-column error-space-between error-full-width">
<div class="error-row error-space-between"> <div class="error-row error-space-between">
<p>Chances you are on the page you are looking for.</p> <p>Chances you are on the page you are looking for.</p>
......
...@@ -290,15 +290,6 @@ export function grafanaAppDirective( ...@@ -290,15 +290,6 @@ export function grafanaAppDirective(
}, 100); }, 100);
} }
// hide search
if (body.find('.search-container').length > 0) {
if (target.parents('.search-results-container, .search-field-wrapper').length === 0) {
scope.$apply(() => {
scope.appEvent(CoreEvents.hideDashSearch);
});
}
}
// hide popovers // hide popovers
const popover = elem.find('.popover'); const popover = elem.find('.popover');
if (popover.length > 0 && target.parents('.graph-legend').length === 0) { if (popover.length > 0 && target.parents('.graph-legend').length === 0) {
......
...@@ -2,7 +2,6 @@ import './dashboard_loaders'; ...@@ -2,7 +2,6 @@ import './dashboard_loaders';
import './ReactContainer'; import './ReactContainer';
import { applyRouteRegistrationHandlers } from './registry'; import { applyRouteRegistrationHandlers } from './registry';
// Pages // Pages
import FolderDashboardsCtrl from 'app/features/folders/FolderDashboardsCtrl';
import LdapPage from 'app/features/admin/ldap/LdapPage'; import LdapPage from 'app/features/admin/ldap/LdapPage';
import UserAdminPage from 'app/features/admin/UserAdminPage'; import UserAdminPage from 'app/features/admin/UserAdminPage';
import SignupPage from 'app/features/profile/SignupPage'; import SignupPage from 'app/features/profile/SignupPage';
...@@ -156,9 +155,13 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati ...@@ -156,9 +155,13 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
}, },
}) })
.when('/dashboards', { .when('/dashboards', {
templateUrl: 'public/app/features/manage-dashboards/partials/dashboard_list.html', template: '<react-container />',
controller: 'DashboardListCtrl', resolve: {
controllerAs: 'ctrl', component: () =>
SafeDynamicImport(
import(/* webpackChunkName: "DashboardListPage"*/ 'app/features/search/components/DashboardListPage')
),
},
}) })
.when('/dashboards/folder/new', { .when('/dashboards/folder/new', {
template: '<react-container />', template: '<react-container />',
...@@ -188,14 +191,22 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati ...@@ -188,14 +191,22 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
}, },
}) })
.when('/dashboards/f/:uid/:slug', { .when('/dashboards/f/:uid/:slug', {
templateUrl: 'public/app/features/folders/partials/folder_dashboards.html', template: '<react-container />',
controller: FolderDashboardsCtrl, resolve: {
controllerAs: 'ctrl', component: () =>
SafeDynamicImport(
import(/* webpackChunkName: "DashboardListPage"*/ 'app/features/search/components/DashboardListPage')
),
},
}) })
.when('/dashboards/f/:uid', { .when('/dashboards/f/:uid', {
templateUrl: 'public/app/features/folders/partials/folder_dashboards.html', template: '<react-container />',
controller: FolderDashboardsCtrl, resolve: {
controllerAs: 'ctrl', component: () =>
SafeDynamicImport(
import(/* webpackChunkName: "DashboardListPage"*/ 'app/features/search/components/DashboardListPage')
),
},
}) })
.when('/explore', { .when('/explore', {
template: '<react-container />', template: '<react-container />',
......
...@@ -21,7 +21,7 @@ ...@@ -21,7 +21,7 @@
border-radius: 3px; border-radius: 3px;
text-shadow: none; text-shadow: none;
font-size: 13px; font-size: 13px;
padding: 2px 6px 2px 6px; padding: 0px 6px;
border: 1px solid lighten($purple, 10%); border: 1px solid lighten($purple, 10%);
.icon-tag { .icon-tag {
......
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