mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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>
This commit is contained in:
parent
1816ab8013
commit
89c8855f9d
@ -1,7 +1,6 @@
|
||||
export const componentTpl = `
|
||||
import React, { FC } from 'react';
|
||||
export const componentTpl = `import React, { FC } from 'react';
|
||||
|
||||
interface Props = {};
|
||||
interface Props {};
|
||||
|
||||
export const <%= name %>: FC<Props> = (props) => {
|
||||
return (
|
||||
|
@ -43,7 +43,7 @@ export class FolderPicker extends PureComponent<Props, State> {
|
||||
enableReset: false,
|
||||
initialTitle: '',
|
||||
enableCreateNew: false,
|
||||
useInNextGenForms: false,
|
||||
useNewForms: false,
|
||||
};
|
||||
|
||||
componentDidMount = async () => {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { IScope } from 'angular';
|
||||
import _ from 'lodash';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
//@ts-ignore
|
||||
import coreModule from 'app/core/core_module';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { SearchSrv } from 'app/core/services/search_srv';
|
||||
@ -337,26 +338,6 @@ export class ManageDashboardsCtrl {
|
||||
|
||||
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() {
|
||||
@ -373,4 +354,4 @@ export function manageDashboardsDirective() {
|
||||
};
|
||||
}
|
||||
|
||||
coreModule.directive('manageDashboards', manageDashboardsDirective);
|
||||
//coreModule.directive('manageDashboards', manageDashboardsDirective);
|
||||
|
@ -2,3 +2,4 @@ import { LocationState } from 'app/types';
|
||||
|
||||
export const getRouteParamsId = (state: LocationState) => state.routeParams.id;
|
||||
export const getRouteParamsPage = (state: LocationState) => state.routeParams.page;
|
||||
export const getRouteParams = (state: LocationState) => state.routeParams;
|
||||
|
@ -1,36 +0,0 @@
|
||||
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();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
<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 />
|
@ -1,7 +0,0 @@
|
||||
<page-header model="ctrl.navModel"></page-header>
|
||||
|
||||
<div class="page-container page-body">
|
||||
<manage-dashboards />
|
||||
</div>
|
||||
|
||||
<footer />
|
72
public/app/features/search/components/ConfirmDeleteModal.tsx
Normal file
72
public/app/features/search/components/ConfirmDeleteModal.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
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};
|
||||
`,
|
||||
};
|
||||
});
|
28
public/app/features/search/components/DashboardActions.tsx
Normal file
28
public/app/features/search/components/DashboardActions.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
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>
|
||||
);
|
||||
};
|
50
public/app/features/search/components/DashboardListPage.tsx
Normal file
50
public/app/features/search/components/DashboardListPage.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
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(() => {
|
||||
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
|
||||
* calls inside useDebounce hook
|
||||
*/
|
||||
describe('DashboardSearch', () => {
|
||||
it('should call search api with default query when initialised', async () => {
|
||||
await act(() => {
|
||||
mount(<DashboardSearch onCloseSearch={() => {}} />);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
await setup();
|
||||
|
||||
expect(mockSearch).toHaveBeenCalledTimes(1);
|
||||
expect(mockSearch).toHaveBeenCalledWith({
|
||||
query: '',
|
||||
parsedQuery: { text: '' },
|
||||
tags: [],
|
||||
tag: [],
|
||||
skipRecent: false,
|
||||
skipStarred: false,
|
||||
starred: false,
|
||||
folderIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should call api with updated query on query change', async () => {
|
||||
let wrapper: any;
|
||||
await act(() => {
|
||||
wrapper = mount(<DashboardSearch onCloseSearch={() => {}} />);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
let wrapper = await setup();
|
||||
|
||||
await act(() => {
|
||||
wrapper
|
||||
//@ts-ignore
|
||||
await act(async () => {
|
||||
// @ts-ignore
|
||||
await wrapper
|
||||
.find({ placeholder: 'Search dashboards by name' })
|
||||
.hostNodes()
|
||||
//@ts-ignore
|
||||
.prop('onChange')({ currentTarget: { value: 'Test' } });
|
||||
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(mockSearch).toHaveBeenCalledWith({
|
||||
query: 'Test',
|
||||
parsedQuery: { text: 'Test' },
|
||||
tags: [],
|
||||
skipRecent: false,
|
||||
skipStarred: false,
|
||||
tag: [],
|
||||
starred: false,
|
||||
folderIds: [],
|
||||
@ -62,11 +72,8 @@ describe('DashboardSearch', () => {
|
||||
});
|
||||
|
||||
it("should render 'No results' message when there are no dashboards", async () => {
|
||||
let wrapper: any;
|
||||
await act(() => {
|
||||
wrapper = mount(<DashboardSearch onCloseSearch={() => {}} />);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
let wrapper = await setup();
|
||||
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.findWhere((c: any) => c.type() === 'h6' && c.text() === 'No dashboards matching your query were found.')
|
||||
@ -76,31 +83,26 @@ describe('DashboardSearch', () => {
|
||||
it('should render search results', async () => {
|
||||
//@ts-ignore
|
||||
mockSearch.mockImplementation(() => Promise.resolve(searchResults));
|
||||
let wrapper: any;
|
||||
await act(() => {
|
||||
wrapper = mount(<DashboardSearch onCloseSearch={() => {}} />);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
let wrapper = await setup();
|
||||
wrapper.update();
|
||||
expect(wrapper.find({ 'aria-label': 'Search section' })).toHaveLength(2);
|
||||
expect(wrapper.find({ 'aria-label': 'Search items' }).children()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should call search with selected tags', async () => {
|
||||
let wrapper: any;
|
||||
await act(() => {
|
||||
wrapper = mount(<DashboardSearch onCloseSearch={() => {}} />);
|
||||
let wrapper = await setup();
|
||||
|
||||
//@ts-ignore
|
||||
await act(async () => {
|
||||
//@ts-ignore
|
||||
await wrapper.find('TagFilter').prop('onChange')(['TestTag']);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
await act(() => {
|
||||
wrapper.find('TagFilter').prop('onChange')(['TestTag']);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(mockSearch).toHaveBeenCalledWith({
|
||||
query: '',
|
||||
parsedQuery: { text: '' },
|
||||
tags: ['TestTag'],
|
||||
skipRecent: false,
|
||||
skipStarred: false,
|
||||
tag: ['TestTag'],
|
||||
starred: false,
|
||||
folderIds: [],
|
||||
|
@ -1,31 +1,18 @@
|
||||
import React, { FC, useReducer, useState } from 'react';
|
||||
import { useDebounce } from 'react-use';
|
||||
import React, { FC } from 'react';
|
||||
import { css } from 'emotion';
|
||||
import { Icon, useTheme, CustomScrollbar, stylesFactory } from '@grafana/ui';
|
||||
import { getLocationSrv } from '@grafana/runtime';
|
||||
import { Icon, useTheme, CustomScrollbar, stylesFactory, Button } from '@grafana/ui';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { SearchSrv } from 'app/core/services/search_srv';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { SearchQuery } from 'app/core/components/search/search';
|
||||
import { TagFilter } from 'app/core/components/TagFilter/TagFilter';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { DashboardSearchItemType, DashboardSection, OpenSearchParams } from '../types';
|
||||
import { findSelected, hasId, parseQuery } from '../utils';
|
||||
import { searchReducer, initialState } from '../reducers/dashboardSearch';
|
||||
import { getDashboardSrv } from '../../dashboard/services/DashboardSrv';
|
||||
import {
|
||||
FETCH_ITEMS,
|
||||
FETCH_RESULTS,
|
||||
TOGGLE_SECTION,
|
||||
MOVE_SELECTION_DOWN,
|
||||
MOVE_SELECTION_UP,
|
||||
} from '../reducers/actionTypes';
|
||||
import { OpenSearchParams } from '../types';
|
||||
import { useSearchQuery } from '../hooks/useSearchQuery';
|
||||
import { useDashboardSearch } from '../hooks/useDashboardSearch';
|
||||
import { SearchField } from './SearchField';
|
||||
import { SearchResults } from './SearchResults';
|
||||
|
||||
const searchSrv = new SearchSrv();
|
||||
|
||||
const defaultQuery: SearchQuery = { query: '', parsedQuery: { text: '' }, tags: [], starred: false };
|
||||
const { isEditor, hasEditPermissionInFolders } = contextSrv;
|
||||
const canEdit = isEditor || hasEditPermissionInFolders;
|
||||
|
||||
@ -35,70 +22,11 @@ export interface Props {
|
||||
}
|
||||
|
||||
export const DashboardSearch: FC<Props> = ({ onCloseSearch, payload = {} }) => {
|
||||
const [query, setQuery] = useState({ ...defaultQuery, ...payload, parsedQuery: parseQuery(payload.query) });
|
||||
const [{ results, loading }, dispatch] = useReducer(searchReducer, initialState);
|
||||
const { query, onQueryChange, onClearFilters, onTagFilterChange, onTagAdd } = useSearchQuery(payload);
|
||||
const { results, loading, onToggleSection, onKeyDown } = useDashboardSearch(query, onCloseSearch);
|
||||
const theme = useTheme();
|
||||
const styles = getStyles(theme);
|
||||
|
||||
const search = () => {
|
||||
let folderIds: number[] = [];
|
||||
if (query.parsedQuery.folder === 'current') {
|
||||
const { folderId } = getDashboardSrv().getCurrent().meta;
|
||||
if (folderId) {
|
||||
folderIds.push(folderId);
|
||||
}
|
||||
}
|
||||
searchSrv.search({ ...query, tag: query.tags, query: query.parsedQuery.text, folderIds }).then(results => {
|
||||
dispatch({ type: FETCH_RESULTS, payload: results });
|
||||
});
|
||||
};
|
||||
|
||||
useDebounce(search, 300, [query]);
|
||||
|
||||
const onToggleSection = (section: DashboardSection) => {
|
||||
if (hasId(section.title) && !section.items.length) {
|
||||
backendSrv.search({ ...defaultQuery, folderIds: [section.id] }).then(items => {
|
||||
dispatch({ type: FETCH_ITEMS, payload: { section, items } });
|
||||
dispatch({ type: TOGGLE_SECTION, payload: section });
|
||||
});
|
||||
} else {
|
||||
dispatch({ type: TOGGLE_SECTION, payload: section });
|
||||
}
|
||||
};
|
||||
|
||||
const onQueryChange = (searchQuery: string) => {
|
||||
setQuery(q => ({
|
||||
...q,
|
||||
parsedQuery: parseQuery(searchQuery),
|
||||
query: searchQuery,
|
||||
}));
|
||||
};
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
onCloseSearch();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
dispatch({ type: MOVE_SELECTION_UP });
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
dispatch({ type: MOVE_SELECTION_DOWN });
|
||||
break;
|
||||
case 'Enter':
|
||||
const selectedItem = findSelected(results);
|
||||
if (selectedItem) {
|
||||
if (selectedItem.type === DashboardSearchItemType.DashFolder) {
|
||||
onToggleSection(selectedItem as DashboardSection);
|
||||
} else {
|
||||
getLocationSrv().update({ path: selectedItem.url });
|
||||
// Delay closing to prevent current page flicker
|
||||
setTimeout(onCloseSearch, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The main search input has own keydown handler, also TagFilter uses input, so
|
||||
// clicking Esc when tagFilter is active shouldn't close the whole search overlay
|
||||
const onClose = (e: React.KeyboardEvent<HTMLElement>) => {
|
||||
@ -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 (
|
||||
<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__col_1">
|
||||
<CustomScrollbar>
|
||||
<div className="search-results-container">
|
||||
<SearchResults
|
||||
results={results}
|
||||
loading={loading}
|
||||
onTagSelected={onTagSelected}
|
||||
dispatch={dispatch}
|
||||
editable={false}
|
||||
onToggleSection={onToggleSection}
|
||||
/>
|
||||
</div>
|
||||
<SearchResults
|
||||
results={results}
|
||||
loading={loading}
|
||||
onTagSelected={onTagAdd}
|
||||
editable={false}
|
||||
onToggleSection={onToggleSection}
|
||||
/>
|
||||
</CustomScrollbar>
|
||||
</div>
|
||||
<div className="search-dropdown__col_2">
|
||||
@ -145,14 +63,14 @@ export const DashboardSearch: FC<Props> = ({ onCloseSearch, payload = {} }) => {
|
||||
<div className="search-filter-box__header">
|
||||
<Icon name="filter" className={styles.filter} size="sm" />
|
||||
Filter by:
|
||||
{query.tags.length > 0 && (
|
||||
<a className="pointer pull-right small" onClick={onClearSearchFilters}>
|
||||
{query.tag.length > 0 && (
|
||||
<a className="pointer pull-right small" onClick={onClearFilters}>
|
||||
<Icon name="times" size="sm" /> Clear
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TagFilter tags={query.tags} tagOptions={searchSrv.getDashboardTags} onChange={onTagFiltersChanged} />
|
||||
<TagFilter tags={query.tag} tagOptions={searchSrv.getDashboardTags} onChange={onTagFilterChange} />
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
@ -178,9 +96,9 @@ export const DashboardSearch: FC<Props> = ({ onCloseSearch, payload = {} }) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.closeBtn} onClick={onCloseSearch}>
|
||||
Close search <Icon name="times" className={styles.close} />
|
||||
</div>
|
||||
<Button icon="times" className={styles.closeBtn} onClick={onCloseSearch} variant="secondary">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -189,17 +107,9 @@ export const DashboardSearch: FC<Props> = ({ onCloseSearch, payload = {} }) => {
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
return {
|
||||
closeBtn: css`
|
||||
top: 20px;
|
||||
top: 10px;
|
||||
right: 8px;
|
||||
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`
|
||||
margin-right: ${theme.spacing.sm};
|
||||
@ -212,5 +122,8 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
margin-left: ${theme.spacing.xs};
|
||||
margin-bottom: 1px;
|
||||
`,
|
||||
searchField: css`
|
||||
padding-left: ${theme.spacing.md};
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
169
public/app/features/search/components/ManageDashboards.tsx
Normal file
169
public/app/features/search/components/ManageDashboards.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
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" />
|
||||
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;
|
||||
}
|
||||
`,
|
||||
};
|
||||
});
|
91
public/app/features/search/components/MoveToFolderModal.tsx
Normal file
91
public/app/features/search/components/MoveToFolderModal.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
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};
|
||||
`,
|
||||
};
|
||||
});
|
@ -1,15 +1,16 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React, { FC, useContext } from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
import { ThemeContext, Icon, Input } from '@grafana/ui';
|
||||
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>>;
|
||||
|
||||
interface SearchFieldProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
|
||||
query: SearchQuery;
|
||||
query: DashboardQuery;
|
||||
onChange: (query: string) => void;
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
clearable?: boolean;
|
||||
}
|
||||
|
||||
const getSearchFieldStyles = (theme: GrafanaTheme) => ({
|
||||
@ -24,7 +25,6 @@ const getSearchFieldStyles = (theme: GrafanaTheme) => ({
|
||||
`,
|
||||
input: css`
|
||||
max-width: 683px;
|
||||
padding-left: ${theme.spacing.md};
|
||||
margin-right: 90px;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
@ -45,39 +45,42 @@ const getSearchFieldStyles = (theme: GrafanaTheme) => ({
|
||||
font-size: ${theme.typography.size.sm};
|
||||
color: ${theme.colors.textWeak};
|
||||
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 styles = getSearchFieldStyles(theme);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 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
|
||||
type="text"
|
||||
placeholder="Search dashboards by name"
|
||||
value={query.query}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(event.currentTarget.value);
|
||||
}}
|
||||
tabIndex={1}
|
||||
spellCheck={false}
|
||||
className={styles.input}
|
||||
prefix={<Icon name="search" />}
|
||||
suffix={
|
||||
<a className={styles.clearButton} onClick={() => onChange('')}>
|
||||
<div className={cx(styles.wrapper, className)}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search dashboards by name"
|
||||
value={query.query}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(event.currentTarget.value);
|
||||
}}
|
||||
tabIndex={1}
|
||||
spellCheck={false}
|
||||
className={styles.input}
|
||||
prefix={<Icon name="search" />}
|
||||
suffix={
|
||||
clearable && (
|
||||
<span className={styles.clearButton} onClick={() => onChange('')}>
|
||||
Clear
|
||||
</a>
|
||||
}
|
||||
{...inputProps}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{...inputProps}
|
||||
/>
|
||||
|
||||
<div className={styles.spacer} />
|
||||
</div>
|
||||
</>
|
||||
<div className={styles.spacer} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -20,9 +20,10 @@ const data = {
|
||||
const setup = (propOverrides?: Partial<Props>, renderMethod = shallow) => {
|
||||
const props: Props = {
|
||||
item: data,
|
||||
onToggleSelection: jest.fn(),
|
||||
onTagSelected: jest.fn(),
|
||||
editable: false,
|
||||
//@ts-ignore
|
||||
onToggleAllChecked: jest.fn(),
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
@ -5,19 +5,19 @@ import { e2e } from '@grafana/e2e';
|
||||
import { Icon, useTheme, TagList, styleMixins, stylesFactory } from '@grafana/ui';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { CoreEvents } from 'app/types';
|
||||
import { DashboardSectionItem, ItemClickWithEvent } from '../types';
|
||||
import { DashboardSectionItem, OnToggleChecked } from '../types';
|
||||
import { SearchCheckbox } from './SearchCheckbox';
|
||||
|
||||
export interface Props {
|
||||
item: DashboardSectionItem;
|
||||
editable?: boolean;
|
||||
onToggleSelection?: ItemClickWithEvent;
|
||||
onTagSelected: (name: string) => any;
|
||||
onToggleChecked?: OnToggleChecked;
|
||||
}
|
||||
|
||||
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 styles = getResultsItemStyles(theme);
|
||||
const inputEl = useRef<HTMLInputElement>(null);
|
||||
@ -47,8 +47,11 @@ export const SearchItem: FC<Props> = ({ item, editable, onToggleSelection = () =
|
||||
}, []);
|
||||
|
||||
const toggleItem = useCallback(
|
||||
(event: React.MouseEvent<HTMLElement>) => {
|
||||
onToggleSelection(item, event);
|
||||
(event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
if (onToggleChecked) {
|
||||
onToggleChecked(item);
|
||||
}
|
||||
},
|
||||
[item]
|
||||
);
|
||||
|
@ -1,77 +1,55 @@
|
||||
import React, { FC, Dispatch } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { Icon, stylesFactory, useTheme, IconName, IconButton } from '@grafana/ui';
|
||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||
import { Icon, stylesFactory, useTheme, IconName, IconButton, Spinner } from '@grafana/ui';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { CoreEvents } from 'app/types';
|
||||
import { DashboardSection, ItemClickWithEvent, SearchAction } from '../types';
|
||||
import { DashboardSection, OnToggleChecked } from '../types';
|
||||
import { SearchItem } from './SearchItem';
|
||||
import { SearchCheckbox } from './SearchCheckbox';
|
||||
|
||||
export interface Props {
|
||||
dispatch?: Dispatch<SearchAction>;
|
||||
editable?: boolean;
|
||||
loading?: boolean;
|
||||
onFolderExpanding?: () => void;
|
||||
onSelectionChanged?: () => void;
|
||||
onTagSelected: (name: string) => any;
|
||||
onToggleSection?: any;
|
||||
onToggleSelection?: ItemClickWithEvent;
|
||||
onToggleChecked?: OnToggleChecked;
|
||||
onToggleSection: (section: DashboardSection) => void;
|
||||
results: DashboardSection[] | undefined;
|
||||
}
|
||||
|
||||
export const SearchResults: FC<Props> = ({
|
||||
editable,
|
||||
loading,
|
||||
onFolderExpanding,
|
||||
onSelectionChanged,
|
||||
onTagSelected,
|
||||
onToggleChecked,
|
||||
onToggleSection,
|
||||
onToggleSelection,
|
||||
results,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const styles = getSectionStyles(theme);
|
||||
|
||||
const toggleFolderExpand = (section: DashboardSection) => {
|
||||
if (onToggleSection) {
|
||||
onToggleSection(section);
|
||||
} else {
|
||||
if (section.toggle) {
|
||||
if (!section.expanded && onFolderExpanding) {
|
||||
onFolderExpanding();
|
||||
}
|
||||
|
||||
section.toggle(section).then(() => {
|
||||
if (onSelectionChanged) {
|
||||
onSelectionChanged();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <PageLoader />;
|
||||
return <Spinner className={styles.spinner} />;
|
||||
} else if (!results || !results.length) {
|
||||
return <h6>No dashboards matching your query were found.</h6>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={styles.wrapper}>
|
||||
{results.map(section => (
|
||||
<li aria-label="Search section" className={styles.section} key={section.title}>
|
||||
<SectionHeader onSectionClick={toggleFolderExpand} {...{ onToggleSelection, editable, section }} />
|
||||
<ul aria-label="Search items" className={styles.wrapper}>
|
||||
{section.expanded &&
|
||||
section.items.map(item => (
|
||||
<SearchItem key={item.id} {...{ item, editable, onToggleSelection, onTagSelected }} />
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="search-results-container">
|
||||
<ul className={styles.wrapper}>
|
||||
{results.map(section => (
|
||||
<li aria-label="Search section" className={styles.section} key={section.title}>
|
||||
<SectionHeader onSectionClick={onToggleSection} {...{ onToggleChecked, editable, section }} />
|
||||
<ul aria-label="Search items" className={styles.wrapper}>
|
||||
{section.expanded &&
|
||||
section.items.map(item => (
|
||||
<SearchItem key={item.id} {...{ item, editable, onToggleChecked, onTagSelected }} />
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -86,36 +64,41 @@ const getSectionStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
padding: 0px 4px 4px 4px;
|
||||
margin-bottom: 3px;
|
||||
`,
|
||||
spinner: css`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100px;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
interface SectionHeaderProps {
|
||||
section: DashboardSection;
|
||||
onSectionClick: (section: DashboardSection) => void;
|
||||
onToggleSelection?: ItemClickWithEvent;
|
||||
editable?: boolean;
|
||||
onSectionClick: (section: DashboardSection) => void;
|
||||
onToggleChecked?: OnToggleChecked;
|
||||
section: DashboardSection;
|
||||
}
|
||||
|
||||
const SectionHeader: FC<SectionHeaderProps> = ({
|
||||
section,
|
||||
onSectionClick,
|
||||
onToggleSelection = () => {},
|
||||
editable = false,
|
||||
}) => {
|
||||
const SectionHeader: FC<SectionHeaderProps> = ({ section, onSectionClick, onToggleChecked, editable = false }) => {
|
||||
const theme = useTheme();
|
||||
const styles = getSectionHeaderStyles(theme, section.selected);
|
||||
|
||||
const expandSection = () => {
|
||||
const onSectionExpand = () => {
|
||||
onSectionClick(section);
|
||||
};
|
||||
|
||||
const onSectionChecked = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onToggleChecked) {
|
||||
onToggleChecked(section);
|
||||
}
|
||||
};
|
||||
|
||||
return !section.hideHeader ? (
|
||||
<div className={styles.wrapper} onClick={expandSection}>
|
||||
<SearchCheckbox
|
||||
editable={editable}
|
||||
checked={section.checked}
|
||||
onClick={(e: MouseEvent) => onToggleSelection(section, e)}
|
||||
/>
|
||||
<div className={styles.wrapper} onClick={onSectionExpand}>
|
||||
<SearchCheckbox editable={editable} checked={section.checked} onClick={onSectionChecked} />
|
||||
<Icon className={styles.icon} name={section.icon as IconName} />
|
||||
|
||||
<span className={styles.text}>{section.title}</span>
|
||||
|
@ -15,12 +15,11 @@ const setup = (propOverrides?: Partial<Props>, renderMethod = shallow) => {
|
||||
canMove: false,
|
||||
deleteItem: noop,
|
||||
moveTo: noop,
|
||||
onSelectAllChanged: noop,
|
||||
onStarredFilterChange: noop,
|
||||
onTagFilterChange: noop,
|
||||
selectedStarredFilter: 'starred',
|
||||
selectedTagFilter: 'tag',
|
||||
tagFilterOptions: [],
|
||||
onToggleAllChecked: noop,
|
||||
selectedStarredFilter: false,
|
||||
selectedTagFilter: ['tag'],
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
@ -79,7 +78,7 @@ describe('SearchResultsFilter', () => {
|
||||
{ value: 'tag2', label: 'Tag 2' },
|
||||
];
|
||||
//@ts-ignore
|
||||
const { wrapper } = setup({ onTagFilterChange: mockFilterByTags, tagFilterOptions: tags }, mount);
|
||||
const { wrapper } = setup({ onTagFilterChange: mockFilterByTags }, mount);
|
||||
wrapper
|
||||
.find({ placeholder: 'Filter by tag' })
|
||||
.at(0)
|
||||
@ -87,11 +86,4 @@ describe('SearchResultsFilter', () => {
|
||||
expect(mockFilterByTags).toHaveBeenCalledTimes(1);
|
||||
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';
|
||||
import { css } from 'emotion';
|
||||
import { Button, Select, Checkbox, stylesFactory, useTheme, HorizontalGroup } from '@grafana/ui';
|
||||
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;
|
||||
|
||||
@ -11,12 +13,11 @@ export interface Props {
|
||||
canMove?: boolean;
|
||||
deleteItem: () => void;
|
||||
moveTo: () => void;
|
||||
onSelectAllChanged: any;
|
||||
onStarredFilterChange: onSelectChange;
|
||||
onTagFilterChange: onSelectChange;
|
||||
selectedStarredFilter: string;
|
||||
selectedTagFilter: string;
|
||||
tagFilterOptions: SelectableValue[];
|
||||
onToggleAllChecked: () => void;
|
||||
selectedStarredFilter: boolean;
|
||||
selectedTagFilter: string[];
|
||||
}
|
||||
|
||||
const starredFilterOptions = [
|
||||
@ -24,18 +25,19 @@ const starredFilterOptions = [
|
||||
{ label: 'No', value: false },
|
||||
];
|
||||
|
||||
const searchSrv = new SearchSrv();
|
||||
|
||||
export const SearchResultsFilter: FC<Props> = ({
|
||||
allChecked,
|
||||
canDelete,
|
||||
canMove,
|
||||
deleteItem,
|
||||
moveTo,
|
||||
onSelectAllChanged,
|
||||
onToggleAllChecked,
|
||||
onStarredFilterChange,
|
||||
onTagFilterChange,
|
||||
selectedStarredFilter,
|
||||
selectedStarredFilter = false,
|
||||
selectedTagFilter,
|
||||
tagFilterOptions,
|
||||
}) => {
|
||||
const showActions = canDelete || canMove;
|
||||
const theme = useTheme();
|
||||
@ -43,7 +45,7 @@ export const SearchResultsFilter: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Checkbox value={allChecked} onChange={onSelectAllChanged} />
|
||||
<Checkbox value={allChecked} onChange={onToggleAllChecked} />
|
||||
{showActions ? (
|
||||
<HorizontalGroup spacing="md">
|
||||
<Button disabled={!canMove} onClick={moveTo} icon="exchange-alt" variant="secondary">
|
||||
@ -58,17 +60,18 @@ export const SearchResultsFilter: FC<Props> = ({
|
||||
<Select
|
||||
size="sm"
|
||||
placeholder="Filter by starred"
|
||||
key={selectedStarredFilter}
|
||||
key={starredFilterOptions?.find(f => f.value === selectedStarredFilter)?.label}
|
||||
options={starredFilterOptions}
|
||||
onChange={onStarredFilterChange}
|
||||
/>
|
||||
|
||||
<Select
|
||||
<TagFilter
|
||||
size="sm"
|
||||
placeholder="Filter by tag"
|
||||
key={selectedTagFilter}
|
||||
options={tagFilterOptions}
|
||||
tags={selectedTagFilter}
|
||||
tagOptions={searchSrv.getDashboardTags}
|
||||
onChange={onTagFilterChange}
|
||||
hideValues
|
||||
/>
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
@ -83,6 +86,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: ${theme.spacing.sm};
|
||||
|
||||
label {
|
||||
height: 20px;
|
||||
|
48
public/app/features/search/hooks/useDashboardSearch.ts
Normal file
48
public/app/features/search/hooks/useDashboardSearch.ts
Normal file
@ -0,0 +1,48 @@
|
||||
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,
|
||||
};
|
||||
};
|
80
public/app/features/search/hooks/useManageDashboards.ts
Normal file
80
public/app/features/search/hooks/useManageDashboards.ts
Normal file
@ -0,0 +1,80 @@
|
||||
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,
|
||||
};
|
||||
};
|
53
public/app/features/search/hooks/useSearch.ts
Normal file
53
public/app/features/search/hooks/useSearch.ts
Normal file
@ -0,0 +1,53 @@
|
||||
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 };
|
||||
};
|
60
public/app/features/search/hooks/useSearchQuery.ts
Normal file
60
public/app/features/search/hooks/useSearchQuery.ts
Normal file
@ -0,0 +1,60 @@
|
||||
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';
|
||||
export { SearchCheckbox } from './components/SearchCheckbox';
|
||||
export { SearchWrapper } from './components/SearchWrapper';
|
||||
export { SearchResultsFilter } from './components/SearchResultsFilter';
|
||||
export { ManageDashboards } from './components/ManageDashboards';
|
||||
export { ConfirmDeleteModal } from './components/ConfirmDeleteModal';
|
||||
export { MoveToFolderModal } from './components/MoveToFolderModal';
|
||||
export * from './types';
|
||||
|
@ -3,3 +3,20 @@ export const TOGGLE_SECTION = 'TOGGLE_SECTION';
|
||||
export const FETCH_ITEMS = 'FETCH_ITEMS';
|
||||
export const MOVE_SELECTION_UP = 'MOVE_SELECTION_UP';
|
||||
export const MOVE_SELECTION_DOWN = 'MOVE_SELECTION_DOWN';
|
||||
|
||||
// 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';
|
||||
|
@ -1,21 +1,28 @@
|
||||
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';
|
||||
|
||||
describe('Dashboard Search reducer', () => {
|
||||
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', () => {
|
||||
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.results[0].selected).toBeTruthy();
|
||||
});
|
||||
|
||||
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();
|
||||
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();
|
||||
});
|
||||
|
||||
@ -37,7 +44,7 @@ describe('Dashboard Search reducer', () => {
|
||||
},
|
||||
];
|
||||
const newState = reducer(
|
||||
{ loading: false, results: sections },
|
||||
{ selectedIndex: 0, loading: false, results: sections as any[] },
|
||||
{
|
||||
type: FETCH_ITEMS,
|
||||
payload: {
|
||||
@ -51,7 +58,7 @@ describe('Dashboard Search reducer', () => {
|
||||
|
||||
it('should handle MOVE_SELECTION_DOWN', () => {
|
||||
const newState = reducer(
|
||||
{ loading: false, selectedIndex: 0, results: sections },
|
||||
{ loading: false, selectedIndex: 0, results: sections as any[] },
|
||||
{
|
||||
type: MOVE_SELECTION_DOWN,
|
||||
}
|
||||
@ -69,7 +76,7 @@ describe('Dashboard Search reducer', () => {
|
||||
|
||||
// Shouldn't go over the visible results length - 1 (9)
|
||||
const newState3 = reducer(
|
||||
{ loading: false, selectedIndex: 9, results: sections },
|
||||
{ loading: false, selectedIndex: 9, results: sections as any[] },
|
||||
{
|
||||
type: MOVE_SELECTION_DOWN,
|
||||
}
|
||||
@ -80,7 +87,7 @@ describe('Dashboard Search reducer', () => {
|
||||
it('should handle MOVE_SELECTION_UP', () => {
|
||||
// shouldn't move beyond 0
|
||||
const newState = reducer(
|
||||
{ loading: false, selectedIndex: 0, results: sections },
|
||||
{ loading: false, selectedIndex: 0, results: sections as any[] },
|
||||
{
|
||||
type: MOVE_SELECTION_UP,
|
||||
}
|
||||
@ -89,7 +96,7 @@ describe('Dashboard Search reducer', () => {
|
||||
expect(newState.selectedIndex).toEqual(0);
|
||||
|
||||
const newState2 = reducer(
|
||||
{ loading: false, selectedIndex: 3, results: sections },
|
||||
{ loading: false, selectedIndex: 3, results: sections as any[] },
|
||||
{
|
||||
type: MOVE_SELECTION_UP,
|
||||
}
|
||||
|
@ -2,19 +2,19 @@ import { DashboardSection, SearchAction } from '../types';
|
||||
import { getFlattenedSections, getLookupField, markSelected } from '../utils';
|
||||
import { FETCH_ITEMS, FETCH_RESULTS, TOGGLE_SECTION, MOVE_SELECTION_DOWN, MOVE_SELECTION_UP } from './actionTypes';
|
||||
|
||||
interface State {
|
||||
export interface DashboardsSearchState {
|
||||
results: DashboardSection[];
|
||||
loading: boolean;
|
||||
selectedIndex: number;
|
||||
}
|
||||
|
||||
export const initialState: State = {
|
||||
export const dashboardsSearchState: DashboardsSearchState = {
|
||||
results: [],
|
||||
loading: true,
|
||||
selectedIndex: 0,
|
||||
};
|
||||
|
||||
export const searchReducer = (state: any, action: SearchAction) => {
|
||||
export const searchReducer = (state: DashboardsSearchState, action: SearchAction) => {
|
||||
switch (action.type) {
|
||||
case FETCH_RESULTS: {
|
||||
const results = action.payload;
|
||||
|
104
public/app/features/search/reducers/manageDashboards.test.ts
Normal file
104
public/app/features/search/reducers/manageDashboards.test.ts
Normal file
@ -0,0 +1,104 @@
|
||||
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');
|
||||
});
|
||||
});
|
97
public/app/features/search/reducers/manageDashboards.ts
Normal file
97
public/app/features/search/reducers/manageDashboards.ts
Normal file
@ -0,0 +1,97 @@
|
||||
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]);
|
42
public/app/features/search/reducers/searchQueryReducer.ts
Normal file
42
public/app/features/search/reducers/searchQueryReducer.ts
Normal file
@ -0,0 +1,42 @@
|
||||
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 = [
|
||||
url: '/dashboards/f/JB_zdOUWk/gdev-dashboards',
|
||||
icon: 'folder',
|
||||
score: 0,
|
||||
checked: false,
|
||||
checked: true,
|
||||
},
|
||||
{
|
||||
id: 0,
|
||||
@ -26,7 +26,7 @@ export const searchResults = [
|
||||
//@ts-ignore
|
||||
tags: [],
|
||||
isStarred: false,
|
||||
checked: false,
|
||||
checked: true,
|
||||
},
|
||||
{
|
||||
id: 46,
|
||||
@ -38,7 +38,7 @@ export const searchResults = [
|
||||
type: 'dash-db',
|
||||
tags: [],
|
||||
isStarred: false,
|
||||
checked: false,
|
||||
checked: true,
|
||||
},
|
||||
],
|
||||
icon: 'folder-open',
|
||||
|
@ -1,3 +1,7 @@
|
||||
import { Dispatch } from 'react';
|
||||
import { Action } from 'redux';
|
||||
import { FolderInfo } from '../../types';
|
||||
|
||||
export enum DashboardSearchItemType {
|
||||
DashDB = 'dash-db',
|
||||
DashHome = 'dash-home',
|
||||
@ -42,9 +46,21 @@ export interface DashboardTag {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SearchAction extends Action {
|
||||
payload?: any;
|
||||
}
|
||||
|
||||
export interface OpenSearchParams {
|
||||
query?: string;
|
||||
}
|
||||
|
||||
export interface UidsToDelete {
|
||||
folders: string[];
|
||||
dashboards: string[];
|
||||
}
|
||||
|
||||
export interface DashboardQuery {
|
||||
query: string;
|
||||
mode: string;
|
||||
tag: string[];
|
||||
starred: boolean;
|
||||
skipRecent: boolean;
|
||||
@ -52,19 +68,19 @@ export interface DashboardQuery {
|
||||
folderIds: number[];
|
||||
}
|
||||
|
||||
export interface SectionsState {
|
||||
sections: DashboardSection[];
|
||||
allChecked: boolean;
|
||||
dashboardTags: DashboardTag[];
|
||||
export type SearchReducer<S> = [S, Dispatch<SearchAction>];
|
||||
interface UseSearchParams {
|
||||
queryParsing?: boolean;
|
||||
searchCallback?: (folderUid: string | undefined) => any;
|
||||
folderUid?: string;
|
||||
}
|
||||
|
||||
export type ItemClickWithEvent = (item: DashboardSectionItem | DashboardSection, event: any) => void;
|
||||
export type UseSearch = <S>(
|
||||
query: DashboardQuery,
|
||||
reducer: SearchReducer<S>,
|
||||
params: UseSearchParams
|
||||
) => { state: S; dispatch: Dispatch<SearchAction>; onToggleSection: (section: DashboardSection) => void };
|
||||
|
||||
export type SearchAction = {
|
||||
type: string;
|
||||
payload?: any;
|
||||
};
|
||||
|
||||
export interface OpenSearchParams {
|
||||
query?: string;
|
||||
}
|
||||
export type OnToggleChecked = (item: DashboardSectionItem | DashboardSection) => void;
|
||||
export type OnDeleteItems = (folders: string[], dashboards: string[]) => void;
|
||||
export type OnMoveItems = (selectedDashboards: DashboardSectionItem[], folder: FolderInfo | null) => void;
|
||||
|
@ -1,11 +1,17 @@
|
||||
import { findSelected, getFlattenedSections, markSelected } from './utils';
|
||||
import { DashboardSection } from './types';
|
||||
import { sections } from './testData';
|
||||
import {
|
||||
findSelected,
|
||||
getCheckedDashboardsUids,
|
||||
getCheckedUids,
|
||||
getFlattenedSections,
|
||||
markSelected,
|
||||
mergeReducers,
|
||||
} from './utils';
|
||||
import { sections, searchResults } from './testData';
|
||||
|
||||
describe('Search utils', () => {
|
||||
describe('getFlattenedSections', () => {
|
||||
it('should return an array of items plus children for expanded items', () => {
|
||||
const flatSections = getFlattenedSections(sections as DashboardSection[]);
|
||||
const flatSections = getFlattenedSections(sections as any[]);
|
||||
expect(flatSections).toHaveLength(10);
|
||||
expect(flatSections).toEqual([
|
||||
'Starred',
|
||||
@ -80,15 +86,64 @@ describe('Search utils', () => {
|
||||
const results = [...sections, { id: 'Test', selected: true }];
|
||||
|
||||
const found = findSelected(results);
|
||||
expect(found.id).toEqual('Test');
|
||||
expect(found?.id).toEqual('Test');
|
||||
});
|
||||
|
||||
it('should find selected item', () => {
|
||||
const results = [{ expanded: true, id: 'Test', items: [{ id: 1 }, { id: 2, selected: true }, { id: 3 }] }];
|
||||
|
||||
const found = findSelected(results);
|
||||
expect(found.id).toEqual(2);
|
||||
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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { DashboardSection, DashboardSectionItem } from './types';
|
||||
import { DashboardQuery, DashboardSection, DashboardSectionItem, SearchAction, UidsToDelete } from './types';
|
||||
import { NO_ID_SECTIONS } from './constants';
|
||||
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
|
||||
@ -83,8 +84,7 @@ export const findSelected = (sections: any): DashboardSection | DashboardSection
|
||||
return null;
|
||||
};
|
||||
|
||||
// TODO check if there are any use cases where query isn't a string
|
||||
export const parseQuery = (query: any) => {
|
||||
export const parseQuery = (query: string) => {
|
||||
const parsedQuery = parse(query, {
|
||||
keywords: ['folder'],
|
||||
});
|
||||
@ -97,3 +97,86 @@ export const parseQuery = (query: any) => {
|
||||
|
||||
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 @@
|
||||
<p class="current-text">current</p>
|
||||
</div>
|
||||
<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-row error-space-between">
|
||||
<p>Chances you are on the page you are looking for.</p>
|
||||
|
@ -290,15 +290,6 @@ export function grafanaAppDirective(
|
||||
}, 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
|
||||
const popover = elem.find('.popover');
|
||||
if (popover.length > 0 && target.parents('.graph-legend').length === 0) {
|
||||
|
@ -2,7 +2,6 @@ import './dashboard_loaders';
|
||||
import './ReactContainer';
|
||||
import { applyRouteRegistrationHandlers } from './registry';
|
||||
// Pages
|
||||
import FolderDashboardsCtrl from 'app/features/folders/FolderDashboardsCtrl';
|
||||
import LdapPage from 'app/features/admin/ldap/LdapPage';
|
||||
import UserAdminPage from 'app/features/admin/UserAdminPage';
|
||||
import SignupPage from 'app/features/profile/SignupPage';
|
||||
@ -156,9 +155,13 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
|
||||
},
|
||||
})
|
||||
.when('/dashboards', {
|
||||
templateUrl: 'public/app/features/manage-dashboards/partials/dashboard_list.html',
|
||||
controller: 'DashboardListCtrl',
|
||||
controllerAs: 'ctrl',
|
||||
template: '<react-container />',
|
||||
resolve: {
|
||||
component: () =>
|
||||
SafeDynamicImport(
|
||||
import(/* webpackChunkName: "DashboardListPage"*/ 'app/features/search/components/DashboardListPage')
|
||||
),
|
||||
},
|
||||
})
|
||||
.when('/dashboards/folder/new', {
|
||||
template: '<react-container />',
|
||||
@ -188,14 +191,22 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
|
||||
},
|
||||
})
|
||||
.when('/dashboards/f/:uid/:slug', {
|
||||
templateUrl: 'public/app/features/folders/partials/folder_dashboards.html',
|
||||
controller: FolderDashboardsCtrl,
|
||||
controllerAs: 'ctrl',
|
||||
template: '<react-container />',
|
||||
resolve: {
|
||||
component: () =>
|
||||
SafeDynamicImport(
|
||||
import(/* webpackChunkName: "DashboardListPage"*/ 'app/features/search/components/DashboardListPage')
|
||||
),
|
||||
},
|
||||
})
|
||||
.when('/dashboards/f/:uid', {
|
||||
templateUrl: 'public/app/features/folders/partials/folder_dashboards.html',
|
||||
controller: FolderDashboardsCtrl,
|
||||
controllerAs: 'ctrl',
|
||||
template: '<react-container />',
|
||||
resolve: {
|
||||
component: () =>
|
||||
SafeDynamicImport(
|
||||
import(/* webpackChunkName: "DashboardListPage"*/ 'app/features/search/components/DashboardListPage')
|
||||
),
|
||||
},
|
||||
})
|
||||
.when('/explore', {
|
||||
template: '<react-container />',
|
||||
|
@ -21,7 +21,7 @@
|
||||
border-radius: 3px;
|
||||
text-shadow: none;
|
||||
font-size: 13px;
|
||||
padding: 2px 6px 2px 6px;
|
||||
padding: 0px 6px;
|
||||
border: 1px solid lighten($purple, 10%);
|
||||
|
||||
.icon-tag {
|
||||
|
Loading…
Reference in New Issue
Block a user