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 = `
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 () => {
......
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;
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(() => {
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={() => {}} />);
jest.runAllTimers();
});
let wrapper = await setup();
await act(() => {
wrapper.find('TagFilter').prop('onChange')(['TestTag']);
//@ts-ignore
await act(async () => {
//@ts-ignore
await 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: [],
......
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}
onTagSelected={onTagAdd}
editable={false}
onToggleSection={onToggleSection}
/>
</div>
</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};
`,
};
});
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 { 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,18 +45,20 @@ 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`}>
<div className={cx(styles.wrapper, className)}>
<Input
type="text"
placeholder="Search dashboards by name"
......@@ -69,15 +71,16 @@ export const SearchField: React.FunctionComponent<SearchFieldProps> = ({ query,
className={styles.input}
prefix={<Icon name="search" />}
suffix={
<a className={styles.clearButton} onClick={() => onChange('')}>
clearable && (
<span className={styles.clearButton} onClick={() => onChange('')}>
Clear
</a>
</span>
)
}
{...inputProps}
/>
<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]
);
......
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 (
<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={toggleFolderExpand} {...{ onToggleSelection, editable, section }} />
<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, onToggleSelection, onTagSelected }} />
<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;
......
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';
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';
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;
......
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 = [
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',
......
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 SearchAction = {
type: string;
payload?: any;
};
export type UseSearch = <S>(
query: DashboardQuery,
reducer: SearchReducer<S>,
params: UseSearchParams
) => { state: S; dispatch: Dispatch<SearchAction>; onToggleSection: (section: DashboardSection) => void };
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;
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']);
});
});
});
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 {
......
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