diff --git a/pkg/api/index.go b/pkg/api/index.go
index 0ca6801ac83..01e85f92654 100644
--- a/pkg/api/index.go
+++ b/pkg/api/index.go
@@ -217,6 +217,12 @@ func setIndexViewData(c *middleware.Context) (*dtos.IndexViewData, error) {
{Text: "New", Url: setting.AppSubUrl + "/datasources", Icon: "fa fa-fw fa-plus"},
},
},
+ {
+ Text: "Dashboard List",
+ Description: "Manage Dashboards And Folders",
+ Id: "dashboards",
+ Url: setting.AppSubUrl + "/dashboards",
+ },
{
Text: "Preferences",
Id: "org",
diff --git a/public/app/core/routes/routes.ts b/public/app/core/routes/routes.ts
index c33d368248f..e6861db4252 100644
--- a/public/app/core/routes/routes.ts
+++ b/public/app/core/routes/routes.ts
@@ -48,10 +48,6 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
reloadOnSearch: false,
pageClass: 'page-dashboard',
})
- .when('/dashboards/list', {
- templateUrl: 'public/app/features/dashboard/partials/dash_list.html',
- controller : 'DashListCtrl',
- })
.when('/configuration', {
templateUrl: 'public/app/features/admin/partials/configuration_home.html',
controller : 'ConfigurationHomeCtrl',
@@ -73,6 +69,11 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
controller : 'DataSourceEditCtrl',
controllerAs: 'ctrl',
})
+ .when('/dashboards', {
+ templateUrl: 'public/app/features/dashboard/partials/dashboardList.html',
+ controller : 'DashboardListCtrl',
+ controllerAs: 'ctrl',
+ })
.when('/org', {
templateUrl: 'public/app/features/org/partials/orgDetails.html',
controller : 'OrgDetailsCtrl',
diff --git a/public/app/features/admin/admin_list_users_ctrl.ts b/public/app/features/admin/admin_list_users_ctrl.ts
index ce0a33e634a..b2fcf403702 100644
--- a/public/app/features/admin/admin_list_users_ctrl.ts
+++ b/public/app/features/admin/admin_list_users_ctrl.ts
@@ -1,5 +1,3 @@
-///
-
export default class AdminListUsersCtrl {
users: any;
pages = [];
diff --git a/public/app/features/dashboard/all.js b/public/app/features/dashboard/all.js
deleted file mode 100644
index 620a66e09e8..00000000000
--- a/public/app/features/dashboard/all.js
+++ /dev/null
@@ -1,32 +0,0 @@
-define([
- './dashboard_ctrl',
- './alerting_srv',
- './history/history',
- './dashboardLoaderSrv',
- './dashnav/dashnav',
- './submenu/submenu',
- './save_as_modal',
- './save_modal',
- './shareModalCtrl',
- './shareSnapshotCtrl',
- './dashboard_srv',
- './viewStateSrv',
- './time_srv',
- './unsavedChangesSrv',
- './unsaved_changes_modal',
- './timepicker/timepicker',
- './impression_store',
- './upload',
- './import/dash_import',
- './export/export_modal',
- './export_data/export_data_modal',
- './ad_hoc_filters',
- './repeat_option/repeat_option',
- './dashgrid/DashboardGrid',
- './dashgrid/PanelLoader',
- './dashgrid/RowOptions',
- './acl/acl',
- './acl/acl',
- './folder_picker/picker',
- './folder_modal/folder'
-], function () {});
diff --git a/public/app/features/dashboard/all.ts b/public/app/features/dashboard/all.ts
new file mode 100644
index 00000000000..220f2b29081
--- /dev/null
+++ b/public/app/features/dashboard/all.ts
@@ -0,0 +1,36 @@
+
+import './dashboard_ctrl';
+import './alerting_srv';
+import './history/history';
+import './dashboardLoaderSrv';
+import './dashnav/dashnav';
+import './submenu/submenu';
+import './save_as_modal';
+import './save_modal';
+import './shareModalCtrl';
+import './shareSnapshotCtrl';
+import './dashboard_srv';
+import './viewStateSrv';
+import './time_srv';
+import './unsavedChangesSrv';
+import './unsaved_changes_modal';
+import './timepicker/timepicker';
+import './impression_store';
+import './upload';
+import './import/dash_import';
+import './export/export_modal';
+import './export_data/export_data_modal';
+import './ad_hoc_filters';
+import './repeat_option/repeat_option';
+import './dashgrid/DashboardGrid';
+import './dashgrid/PanelLoader';
+import './dashgrid/RowOptions';
+import './acl/acl';
+import './folder_picker/picker';
+import './folder_modal/folder';
+import './move_to_folder_modal/move_to_folder';
+import coreModule from 'app/core/core_module';
+
+import {DashboardListCtrl} from './dashboard_list_ctrl';
+
+coreModule.controller('DashboardListCtrl', DashboardListCtrl);
diff --git a/public/app/features/dashboard/dashboard_list_ctrl.ts b/public/app/features/dashboard/dashboard_list_ctrl.ts
new file mode 100644
index 00000000000..232d8d7bb22
--- /dev/null
+++ b/public/app/features/dashboard/dashboard_list_ctrl.ts
@@ -0,0 +1,117 @@
+import _ from 'lodash';
+import appEvents from 'app/core/app_events';
+
+export class DashboardListCtrl {
+ public dashboards: any [];
+ query: any;
+ navModel: any;
+ canDelete = false;
+ canMove = false;
+
+ /** @ngInject */
+ constructor(private backendSrv, navModelSrv, private $q) {
+ this.navModel = navModelSrv.getNav('cfg', 'dashboards');
+ this.query = '';
+ this.getDashboards();
+ }
+
+ getDashboards() {
+ return this.backendSrv.get(`/api/search?query=${this.query}&mode=tree`).then((result) => {
+
+ this.dashboards = this.groupDashboardsInFolders(result);
+
+ for (let dash of this.dashboards) {
+ dash.checked = false;
+ }
+ });
+ }
+
+ groupDashboardsInFolders(results) {
+ let byId = _.groupBy(results, 'id');
+ let byFolderId = _.groupBy(results, 'folderId');
+ let finalList = [];
+
+ // add missing parent folders
+ _.each(results, (hit, index) => {
+ if (hit.folderId && !byId[hit.folderId]) {
+ const folder = {
+ id: hit.folderId,
+ uri: `db/${hit.folderSlug}`,
+ title: hit.folderTitle,
+ type: 'dash-folder'
+ };
+ byId[hit.folderId] = folder;
+ results.splice(index, 0, folder);
+ }
+ });
+
+ // group by folder
+ for (let hit of results) {
+ if (hit.folderId) {
+ hit.type = "dash-child";
+ } else {
+ finalList.push(hit);
+ }
+
+ hit.url = 'dashboard/' + hit.uri;
+
+ if (hit.type === 'dash-folder') {
+ if (!byFolderId[hit.id]) {
+ continue;
+ }
+
+ for (let child of byFolderId[hit.id]) {
+ finalList.push(child);
+ }
+ }
+ }
+
+ return finalList;
+ }
+
+ selectionChanged() {
+ const selected = _.filter(this.dashboards, {checked: true}).length;
+ this.canDelete = selected > 0;
+
+ const selectedDashboards = _.filter(this.dashboards, (o) => {
+ return o.checked && (o.type === 'dash-db' || o.type === 'dash-child');
+ }).length;
+
+ const selectedFolders = _.filter(this.dashboards, {checked: true, type: 'dash-folder'}).length;
+ this.canMove = selectedDashboards > 0 && selectedFolders === 0;
+ }
+
+ delete() {
+ const selectedDashboards = _.filter(this.dashboards, {checked: true});
+
+ appEvents.emit('confirm-modal', {
+ title: 'Delete',
+ text: `Do you want to delete the ${selectedDashboards.length} selected dashboards?`,
+ icon: 'fa-trash',
+ yesText: 'Delete',
+ onConfirm: () => {
+ const promises = [];
+ for (let dash of selectedDashboards) {
+ promises.push(this.backendSrv.delete(`/api/dashboards/${dash.uri}`));
+ }
+
+ this.$q.all(promises).then(() => {
+ this.getDashboards();
+ });
+ }
+ });
+ }
+
+ moveTo() {
+ const selectedDashboards = _.filter(this.dashboards, {checked: true});
+
+ const template = '' +
+ '`';
+ appEvents.emit('show-modal', {
+ templateHtml: template,
+ modalClass: 'modal--narrow',
+ model: {dashboards: selectedDashboards, afterSave: this.getDashboards.bind(this)}
+ });
+ }
+}
diff --git a/public/app/features/dashboard/move_to_folder_modal/move_to_folder.html b/public/app/features/dashboard/move_to_folder_modal/move_to_folder.html
new file mode 100644
index 00000000000..429d6439e57
--- /dev/null
+++ b/public/app/features/dashboard/move_to_folder_modal/move_to_folder.html
@@ -0,0 +1,29 @@
+
diff --git a/public/app/features/dashboard/move_to_folder_modal/move_to_folder.ts b/public/app/features/dashboard/move_to_folder_modal/move_to_folder.ts
new file mode 100644
index 00000000000..df7b49c2f00
--- /dev/null
+++ b/public/app/features/dashboard/move_to_folder_modal/move_to_folder.ts
@@ -0,0 +1,61 @@
+import coreModule from 'app/core/core_module';
+import appEvents from 'app/core/app_events';
+import {DashboardModel} from '../dashboard_model';
+
+export class MoveToFolderCtrl {
+ dashboards: any;
+ folder: any;
+ dismiss: any;
+ afterSave: any;
+
+ /** @ngInject */
+ constructor(private backendSrv, private $q) {}
+
+ onFolderChange(folder) {
+ this.folder = folder;
+ }
+
+ save() {
+ const promises = [];
+ for (let dash of this.dashboards) {
+ const promise = this.backendSrv.get('/api/dashboards/' + dash.uri).then(fullDash => {
+ const model = new DashboardModel(fullDash.dashboard, fullDash.meta);
+ model.folderId = this.folder.id;
+ model.meta.folderId = this.folder.id;
+ model.meta.folderTitle = this.folder.title;
+ const clone = model.getSaveModelClone();
+ return this.backendSrv.saveDashboard(clone);
+ });
+
+ promises.push(promise);
+ }
+
+ return this.$q.all(promises).then(() => {
+ appEvents.emit('alert-success', ['Dashboards Moved', 'OK']);
+ this.dismiss();
+
+ return this.afterSave();
+ }).then(() => {
+ console.log('afterSave');
+ }).catch(err => {
+ appEvents.emit('alert-error', [err.message]);
+ });
+ }
+}
+
+export function moveToFolderModal() {
+ return {
+ restrict: 'E',
+ templateUrl: 'public/app/features/dashboard/move_to_folder_modal/move_to_folder.html',
+ controller: MoveToFolderCtrl,
+ bindToController: true,
+ controllerAs: 'ctrl',
+ scope: {
+ dismiss: "&",
+ dashboards: "=",
+ afterSave: "&"
+ }
+ };
+}
+
+coreModule.directive('moveToFolderModal', moveToFolderModal);
diff --git a/public/app/features/dashboard/partials/dashboardList.html b/public/app/features/dashboard/partials/dashboardList.html
new file mode 100644
index 00000000000..aca798ebf32
--- /dev/null
+++ b/public/app/features/dashboard/partials/dashboardList.html
@@ -0,0 +1,88 @@
+
+
diff --git a/public/app/features/dashboard/specs/dashboard_list_ctrl.jest.ts b/public/app/features/dashboard/specs/dashboard_list_ctrl.jest.ts
new file mode 100644
index 00000000000..ad85c8d319c
--- /dev/null
+++ b/public/app/features/dashboard/specs/dashboard_list_ctrl.jest.ts
@@ -0,0 +1,174 @@
+import {DashboardListCtrl} from '../dashboard_list_ctrl';
+import q from 'q';
+
+describe('DashboardListCtrl', () => {
+ describe('when fetching dashboards', () => {
+ let ctrl;
+
+ describe('and dashboard has parent that is not in search result', () => {
+ beforeEach(() => {
+ const response = [
+ {
+ id: 399,
+ title: "Dashboard Test",
+ uri: "db/dashboard-test",
+ type: "dash-db",
+ tags: [],
+ isStarred: false,
+ folderId: 410,
+ folderTitle: "afolder",
+ folderSlug: "afolder"
+ }
+ ];
+
+ ctrl = new DashboardListCtrl({get: () => q.resolve(response)}, {getNav: () => {}}, q);
+ return ctrl.getDashboards();
+ });
+
+ it('should add the missing parent folder to the result', () => {
+ expect(ctrl.dashboards.length).toEqual(2);
+ expect(ctrl.dashboards[0].id).toEqual(410);
+ expect(ctrl.dashboards[1].id).toEqual(399);
+ });
+ });
+
+ beforeEach(() => {
+ const response = [
+ {
+ id: 410,
+ title: "afolder",
+ uri: "db/afolder",
+ type: "dash-folder",
+ tags: [],
+ isStarred: false
+ },
+ {
+ id: 3,
+ title: "something else",
+ uri: "db/something-else",
+ type: "dash-db",
+ tags: [],
+ isStarred: false,
+ },
+ {
+ id: 399,
+ title: "Dashboard Test",
+ uri: "db/dashboard-test",
+ type: "dash-db",
+ tags: [],
+ isStarred: false,
+ folderId: 410,
+ folderTitle: "afolder",
+ folderSlug: "afolder"
+ }
+ ];
+ ctrl = new DashboardListCtrl({get: () => q.resolve(response)}, {getNav: () => {}}, null);
+ return ctrl.getDashboards();
+ });
+
+ it('should group them in folders', () => {
+ expect(ctrl.dashboards.length).toEqual(3);
+ expect(ctrl.dashboards[0].id).toEqual(410);
+ expect(ctrl.dashboards[1].id).toEqual(399);
+ expect(ctrl.dashboards[2].id).toEqual(3);
+ });
+ });
+
+ describe('when selecting dashboards', () => {
+ let ctrl;
+
+ beforeEach(() => {
+ ctrl = new DashboardListCtrl({get: () => q.resolve([])}, {getNav: () => {}}, null);
+ });
+
+ describe('and no dashboards are selected', () => {
+ beforeEach(() => {
+ ctrl.dashboards = [
+ {id: 1, type: 'dash-folder'},
+ {id: 2, type: 'dash-db'}
+ ];
+ ctrl.selectionChanged();
+ });
+
+ it('should disable Move To button', () => {
+ expect(ctrl.canMove).toBeFalsy();
+ });
+
+ it('should disable delete button', () => {
+ expect(ctrl.canDelete).toBeFalsy();
+ });
+ });
+
+ describe('and one dashboard in root is selected', () => {
+ beforeEach(() => {
+ ctrl.dashboards = [
+ {id: 1, type: 'dash-folder'},
+ {id: 2, type: 'dash-db', checked: true}
+ ];
+ ctrl.selectionChanged();
+ });
+
+ it('should enable Move To button', () => {
+ expect(ctrl.canMove).toBeTruthy();
+ });
+
+ it('should enable delete button', () => {
+ expect(ctrl.canDelete).toBeTruthy();
+ });
+ });
+
+ describe('and one child dashboard is selected', () => {
+ beforeEach(() => {
+ ctrl.dashboards = [
+ {id: 1, type: 'dash-folder'},
+ {id: 2, type: 'dash-child', checked: true}
+ ];
+ ctrl.selectionChanged();
+ });
+
+ it('should enable Move To button', () => {
+ expect(ctrl.canMove).toBeTruthy();
+ });
+
+ it('should enable delete button', () => {
+ expect(ctrl.canDelete).toBeTruthy();
+ });
+ });
+
+ describe('and one child dashboard and one dashboard is selected', () => {
+ beforeEach(() => {
+ ctrl.dashboards = [
+ {id: 1, type: 'dash-folder'},
+ {id: 2, type: 'dash-child', checked: true}
+ ];
+ ctrl.selectionChanged();
+ });
+
+ it('should enable Move To button', () => {
+ expect(ctrl.canMove).toBeTruthy();
+ });
+
+ it('should enable delete button', () => {
+ expect(ctrl.canDelete).toBeTruthy();
+ });
+ });
+
+ describe('and one child dashboard and one folder is selected', () => {
+ beforeEach(() => {
+ ctrl.dashboards = [
+ {id: 1, type: 'dash-folder', checked: true},
+ {id: 2, type: 'dash-child', checked: true}
+ ];
+ ctrl.selectionChanged();
+ });
+
+ it('should disable Move To button', () => {
+ expect(ctrl.canMove).toBeFalsy();
+ });
+
+ it('should enable delete button', () => {
+ expect(ctrl.canDelete).toBeTruthy();
+ });
+ });
+ });
+});
diff --git a/public/sass/components/_dash_list.scss b/public/sass/components/_dash_list.scss
new file mode 100644
index 00000000000..e69de29bb2d