diff --git a/public/app/core/routes/routes.ts b/public/app/core/routes/routes.ts index f2e4fa1ec88..666ca811037 100644 --- a/public/app/core/routes/routes.ts +++ b/public/app/core/routes/routes.ts @@ -65,7 +65,7 @@ function setupAngularRoutes($routeProvider, $locationProvider) { }) .when("/dashboard/import", { templateUrl: - "public/app/features/dashboard/partials/dashboardImport.html", + "public/app/features/dashboard/partials/dashboard_import.html", controller: "DashboardImportCtrl", controllerAs: "ctrl" }) diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index 5b276a53e4f..bc34d9d37ab 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -251,7 +251,7 @@ export class BackendSrv { createDashboardFolder(name) { const dash = { schemaVersion: 16, - title: name, + title: name.trim(), editable: true, panels: [] }; diff --git a/public/app/features/dashboard/all.ts b/public/app/features/dashboard/all.ts index 7bf0a980205..14517bea7bb 100644 --- a/public/app/features/dashboard/all.ts +++ b/public/app/features/dashboard/all.ts @@ -1,31 +1,32 @@ -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 "./view_state_srv"; -import "./time_srv"; -import "./unsavedChangesSrv"; -import "./unsaved_changes_modal"; -import "./timepicker/timepicker"; -import "./upload"; -import "./export/export_modal"; -import "./export_data/export_data_modal"; -import "./ad_hoc_filters"; -import "./repeat_option/repeat_option"; -import "./dashgrid/DashboardGridDirective"; -import "./dashgrid/PanelLoader"; -import "./dashgrid/RowOptions"; -import "./acl/acl"; -import "./folder_picker/picker"; -import "./move_to_folder_modal/move_to_folder"; -import "./settings/settings"; +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 './view_state_srv'; +import './validation_srv'; +import './time_srv'; +import './unsavedChangesSrv'; +import './unsaved_changes_modal'; +import './timepicker/timepicker'; +import './upload'; +import './export/export_modal'; +import './export_data/export_data_modal'; +import './ad_hoc_filters'; +import './repeat_option/repeat_option'; +import './dashgrid/DashboardGridDirective'; +import './dashgrid/PanelLoader'; +import './dashgrid/RowOptions'; +import './acl/acl'; +import './folder_picker/folder_picker'; +import './move_to_folder_modal/move_to_folder'; +import './settings/settings'; import coreModule from "app/core/core_module"; import { DashboardListCtrl } from "./dashboard_list_ctrl"; diff --git a/public/app/features/dashboard/create_folder_ctrl.ts b/public/app/features/dashboard/create_folder_ctrl.ts index c4a8429f45e..d9eaf95d693 100644 --- a/public/app/features/dashboard/create_folder_ctrl.ts +++ b/public/app/features/dashboard/create_folder_ctrl.ts @@ -3,23 +3,22 @@ import appEvents from "app/core/app_events"; export class CreateFolderCtrl { title = ""; navModel: any; - nameExists = false; titleTouched = false; + hasValidationError: boolean; + validationError: any; /** @ngInject **/ - constructor(private backendSrv, private $location, navModelSrv) { - this.navModel = navModelSrv.getNav("dashboards", "manage-dashboards", 0); + constructor(private backendSrv, private $location, private validationSrv, navModelSrv) { + this.navModel = navModelSrv.getNav('dashboards', 'manage-dashboards', 0); } create() { - if (!this.title || this.title.trim().length === 0) { + if (this.hasValidationError) { return; } - const title = this.title.trim(); - - return this.backendSrv.createDashboardFolder(title).then(result => { - appEvents.emit("alert-success", ["Folder Created", "OK"]); + return this.backendSrv.createDashboardFolder(this.title).then(result => { + appEvents.emit('alert-success', ['Folder Created', 'OK']); var folderUrl = `/dashboards/folder/${result.dashboard.id}/${ result.meta.slug @@ -31,14 +30,13 @@ export class CreateFolderCtrl { titleChanged() { this.titleTouched = true; - this.backendSrv.search({ query: this.title }).then(res => { - this.nameExists = false; - for (let hit of res) { - if (this.title === hit.title) { - this.nameExists = true; - break; - } - } - }); + this.validationSrv.validateNewDashboardOrFolderName(this.title) + .then(() => { + this.hasValidationError = false; + }) + .catch(err => { + this.hasValidationError = true; + this.validationError = err.message; + }); } } diff --git a/public/app/features/dashboard/dashboard_import_ctrl.ts b/public/app/features/dashboard/dashboard_import_ctrl.ts index 51b2d696dac..2dc33fe07f7 100644 --- a/public/app/features/dashboard/dashboard_import_ctrl.ts +++ b/public/app/features/dashboard/dashboard_import_ctrl.ts @@ -13,16 +13,13 @@ export class DashboardImportCtrl { gnetUrl: string; gnetError: string; gnetInfo: any; + titleTouched: boolean; + hasNameValidationError: boolean; + nameValidationError: any; /** @ngInject */ - constructor( - private backendSrv, - navModelSrv, - private $location, - private $scope, - $routeParams - ) { - this.navModel = navModelSrv.getNav("create", "import"); + constructor(private backendSrv, private validationSrv, navModelSrv, private $location, private $scope, $routeParams) { + this.navModel = navModelSrv.getNav('create', 'import'); this.step = 1; this.nameExists = false; @@ -93,15 +90,21 @@ export class DashboardImportCtrl { } titleChanged() { - this.backendSrv.search({ query: this.dash.title }).then(res => { - this.nameExists = false; - for (let hit of res) { - if (this.dash.title === hit.title) { + this.titleTouched = true; + this.nameExists = false; + + this.validationSrv.validateNewDashboardOrFolderName(this.dash.title) + .then(() => { + this.hasNameValidationError = false; + }) + .catch(err => { + if (err.type === 'EXISTING') { this.nameExists = true; - break; } - } - }); + + this.hasNameValidationError = true; + this.nameValidationError = err.message; + }); } saveDashboard() { diff --git a/public/app/features/dashboard/folder_picker/folder_picker.html b/public/app/features/dashboard/folder_picker/folder_picker.html new file mode 100644 index 00000000000..8722a19a5f8 --- /dev/null +++ b/public/app/features/dashboard/folder_picker/folder_picker.html @@ -0,0 +1,46 @@ +
+
+ + + +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
diff --git a/public/app/features/dashboard/folder_picker/folder_picker.ts b/public/app/features/dashboard/folder_picker/folder_picker.ts new file mode 100644 index 00000000000..150f29a0dac --- /dev/null +++ b/public/app/features/dashboard/folder_picker/folder_picker.ts @@ -0,0 +1,170 @@ +import _ from "lodash"; +import coreModule from "app/core/core_module"; +import appEvents from "app/core/app_events"; + +export class FolderPickerCtrl { + initialTitle: string; + initialFolderId?: number; + labelClass: string; + onChange: any; + onLoad: any; + onCreateFolder: any; + enterFolderCreation: any; + exitFolderCreation: any; + enableCreateNew: boolean; + rootName = "Root"; + folder: any; + createNewFolder: boolean; + newFolderName: string; + newFolderNameTouched: boolean; + hasValidationError: boolean; + validationError: any; + + /** @ngInject */ + constructor(private backendSrv, private validationSrv) { + if (!this.labelClass) { + this.labelClass = "width-7"; + } + + this.loadInitialValue(); + } + + getOptions(query) { + var params = { + query: query, + type: "dash-folder" + }; + + return this.backendSrv.search(params).then(result => { + if ( + query === "" || + query.toLowerCase() === "r" || + query.toLowerCase() === "ro" || + query.toLowerCase() === "roo" || + query.toLowerCase() === "root" + ) { + result.unshift({ title: this.rootName, id: 0 }); + } + + if (this.enableCreateNew && query === "") { + result.unshift({ title: "-- New Folder --", id: -1 }); + } + + return _.map(result, item => { + return { text: item.title, value: item.id }; + }); + }); + } + + onFolderChange(option) { + if (option.value === -1) { + this.createNewFolder = true; + this.enterFolderCreation(); + return; + } + this.onChange({ $folder: { id: option.value, title: option.text } }); + } + + newFolderNameChanged() { + this.newFolderNameTouched = true; + + this.validationSrv + .validateNewDashboardOrFolderName(this.newFolderName) + .then(() => { + this.hasValidationError = false; + }) + .catch(err => { + this.hasValidationError = true; + this.validationError = err.message; + }); + } + + createFolder(evt) { + if (evt) { + evt.stopPropagation(); + evt.preventDefault(); + } + + return this.backendSrv + .createDashboardFolder(this.newFolderName) + .then(result => { + appEvents.emit("alert-success", ["Folder Created", "OK"]); + + this.closeCreateFolder(); + this.folder = { + text: result.dashboard.title, + value: result.dashboard.id + }; + this.onFolderChange(this.folder); + }); + } + + cancelCreateFolder(evt) { + if (evt) { + evt.stopPropagation(); + evt.preventDefault(); + } + + this.closeCreateFolder(); + this.loadInitialValue(); + } + + private closeCreateFolder() { + this.exitFolderCreation(); + this.createNewFolder = false; + this.hasValidationError = false; + this.validationError = null; + this.newFolderName = ""; + this.newFolderNameTouched = false; + } + + private loadInitialValue() { + if (this.initialFolderId && this.initialFolderId > 0) { + this.getOptions("").then(result => { + this.folder = _.find(result, { value: this.initialFolderId }); + this.onFolderLoad(); + }); + } else { + if (this.initialTitle) { + this.folder = { text: this.initialTitle, value: null }; + } else { + this.folder = { text: this.rootName, value: 0 }; + } + + this.onFolderLoad(); + } + } + + private onFolderLoad() { + if (this.onLoad) { + this.onLoad({ + $folder: { id: this.folder.value, title: this.folder.text } + }); + } + } +} + +export function folderPicker() { + return { + restrict: "E", + templateUrl: + "public/app/features/dashboard/folder_picker/folder_picker.html", + controller: FolderPickerCtrl, + bindToController: true, + controllerAs: "ctrl", + scope: { + initialTitle: "<", + initialFolderId: "<", + labelClass: "@", + rootName: "@", + onChange: "&", + onLoad: "&", + onCreateFolder: "&", + enterFolderCreation: "&", + exitFolderCreation: "&", + enableCreateNew: "@" + } + }; +} + +coreModule.directive("folderPicker", folderPicker); diff --git a/public/app/features/dashboard/folder_picker/picker.ts b/public/app/features/dashboard/folder_picker/picker.ts deleted file mode 100644 index f04065b36f5..00000000000 --- a/public/app/features/dashboard/folder_picker/picker.ts +++ /dev/null @@ -1,103 +0,0 @@ -/// - -import coreModule from "app/core/core_module"; -import _ from "lodash"; - -export class FolderPickerCtrl { - initialTitle: string; - initialFolderId?: number; - labelClass: string; - onChange: any; - onLoad: any; - rootName = "Root"; - folder: any; - - /** @ngInject */ - constructor(private backendSrv) { - if (!this.labelClass) { - this.labelClass = "width-7"; - } - - if (this.initialFolderId && this.initialFolderId > 0) { - this.getOptions("").then(result => { - this.folder = _.find(result, { value: this.initialFolderId }); - this.onFolderLoad(); - }); - } else { - if (this.initialTitle) { - this.folder = { text: this.initialTitle, value: null }; - } else { - this.folder = { text: this.rootName, value: 0 }; - } - - this.onFolderLoad(); - } - } - - getOptions(query) { - var params = { - query: query, - type: "dash-folder" - }; - - return this.backendSrv.search(params).then(result => { - if ( - query === "" || - query.toLowerCase() === "r" || - query.toLowerCase() === "ro" || - query.toLowerCase() === "roo" || - query.toLowerCase() === "root" - ) { - result.unshift({ title: this.rootName, id: 0 }); - } - - return _.map(result, item => { - return { text: item.title, value: item.id }; - }); - }); - } - - onFolderLoad() { - if (this.onLoad) { - this.onLoad({ - $folder: { id: this.folder.value, title: this.folder.text } - }); - } - } - - onFolderChange(option) { - this.onChange({ $folder: { id: option.value, title: option.text } }); - } -} - -const template = ` -
- - -
-`; - -export function folderPicker() { - return { - restrict: "E", - template: template, - controller: FolderPickerCtrl, - bindToController: true, - controllerAs: "ctrl", - scope: { - initialTitle: "<", - initialFolderId: "<", - labelClass: "@", - rootName: "@", - onChange: "&", - onLoad: "&" - } - }; -} - -coreModule.directive("folderPicker", folderPicker); 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 index 8f04d393a5d..8a67517aa92 100644 --- 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 @@ -18,12 +18,15 @@
- + Cancel
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 index 9f252783cf7..84fc16833a1 100644 --- 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 @@ -6,6 +6,7 @@ export class MoveToFolderCtrl { folder: any; dismiss: any; afterSave: any; + isValidFolderSelection = true; /** @ngInject */ constructor(private backendSrv) {} @@ -39,6 +40,14 @@ export class MoveToFolderCtrl { return this.afterSave(); }); } + + onEnterFolderCreation() { + this.isValidFolderSelection = false; + } + + onExitFolderCreation() { + this.isValidFolderSelection = true; + } } export function moveToFolderModal() { diff --git a/public/app/features/dashboard/partials/create_folder.html b/public/app/features/dashboard/partials/create_folder.html index 22dbaab1419..21b6dfc1661 100644 --- a/public/app/features/dashboard/partials/create_folder.html +++ b/public/app/features/dashboard/partials/create_folder.html @@ -7,34 +7,25 @@
- - -
-
+
-
-
- -
-
-
-
diff --git a/public/app/features/dashboard/partials/dashboardImport.html b/public/app/features/dashboard/partials/dashboard_import.html similarity index 89% rename from public/app/features/dashboard/partials/dashboardImport.html rename to public/app/features/dashboard/partials/dashboard_import.html index b740b8f38bc..020bb98e8b0 100644 --- a/public/app/features/dashboard/partials/dashboardImport.html +++ b/public/app/features/dashboard/partials/dashboard_import.html @@ -65,26 +65,17 @@
-
-
+
-
-
- -
-
-
diff --git a/public/app/features/dashboard/save_as_modal.ts b/public/app/features/dashboard/save_as_modal.ts index a749b085b8b..ee9ef9cbc3c 100644 --- a/public/app/features/dashboard/save_as_modal.ts +++ b/public/app/features/dashboard/save_as_modal.ts @@ -22,13 +22,16 @@ const template = `
- + Cancel
@@ -38,6 +41,7 @@ const template = ` export class SaveDashboardAsModalCtrl { clone: any; folderId: any; + isValidFolderSelection = true; dismiss: () => void; /** @ngInject */ @@ -68,8 +72,16 @@ export class SaveDashboardAsModalCtrl { return this.dashboardSrv.save(this.clone).then(this.dismiss); } + onEnterFolderCreation() { + this.isValidFolderSelection = false; + } + + onExitFolderCreation() { + this.isValidFolderSelection = true; + } + keyDown(evt) { - if (evt.keyCode === 13) { + if (this.isValidFolderSelection && evt.keyCode === 13) { this.save(); } } diff --git a/public/app/features/dashboard/settings/settings.html b/public/app/features/dashboard/settings/settings.html index 03d8b8592a9..0411850c543 100644 --- a/public/app/features/dashboard/settings/settings.html +++ b/public/app/features/dashboard/settings/settings.html @@ -45,9 +45,11 @@ + initial-folder-id="ctrl.dashboard.meta.folderId" + on-change="ctrl.onFolderChange($folder)" + enable-create-new="true" + is-valid-selection="true" + label-class="width-7"> diff --git a/public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts b/public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts index a8590f5cbd3..0abef382de8 100644 --- a/public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts +++ b/public/app/features/dashboard/specs/dashboard_import_ctrl.jest.ts @@ -6,6 +6,7 @@ describe("DashboardImportCtrl", function() { let navModelSrv; let backendSrv; + let validationSrv; beforeEach(() => { navModelSrv = { @@ -17,7 +18,11 @@ describe("DashboardImportCtrl", function() { get: jest.fn() }; - ctx.ctrl = new DashboardImportCtrl(backendSrv, navModelSrv, {}, {}, {}); + validationSrv = { + validateNewDashboardOrFolderName: jest.fn().mockReturnValue(Promise.resolve()) + }; + + ctx.ctrl = new DashboardImportCtrl(backendSrv, validationSrv, navModelSrv, {}, {}, {}); }); describe("when uploading json", function() { diff --git a/public/app/features/dashboard/validation_srv.ts b/public/app/features/dashboard/validation_srv.ts new file mode 100644 index 00000000000..4200cf1b6ba --- /dev/null +++ b/public/app/features/dashboard/validation_srv.ts @@ -0,0 +1,46 @@ +import coreModule from "app/core/core_module"; + +export class ValidationSrv { + rootName = "root"; + + /** @ngInject */ + constructor(private $q, private backendSrv) {} + + validateNewDashboardOrFolderName(name) { + name = (name || "").trim(); + + if (name.length === 0) { + return this.$q.reject({ + type: "REQUIRED", + message: "Name is required" + }); + } + + if (name.toLowerCase() === this.rootName) { + return this.$q.reject({ + type: "EXISTING", + message: "A folder or dashboard with the same name already exists" + }); + } + + let deferred = this.$q.defer(); + + this.backendSrv.search({ query: name }).then(res => { + for (let hit of res) { + if (name.toLowerCase() === hit.title.toLowerCase()) { + deferred.reject({ + type: "EXISTING", + message: "A folder or dashboard with the same name already exists" + }); + break; + } + } + + deferred.resolve(); + }); + + return deferred.promise; + } +} + +coreModule.service("validationSrv", ValidationSrv); diff --git a/public/sass/components/_gf-form.scss b/public/sass/components/_gf-form.scss index 3a61246c494..10a900f82f9 100644 --- a/public/sass/components/_gf-form.scss +++ b/public/sass/components/_gf-form.scss @@ -109,6 +109,10 @@ $input-border: 1px solid $input-border-color; &--error { color: $critical; } + + &:disabled { + color: $text-color-weak + } } .gf-form-label + .gf-form-label {