From 0ffcce1b5d79a11684530eadf58d61b32bc477e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Fri, 17 Jul 2015 09:51:34 +0200 Subject: [PATCH] feat(invite): more work on invite, basic creation works, added new tab directive from angular-ui and made new tab style, #2353 --- pkg/api/api.go | 4 + pkg/api/dtos/invite.go | 9 + pkg/api/org_invite.go | 39 +++ pkg/models/temp_user.go | 28 +- pkg/services/sqlstore/migrations/temp_user.go | 6 +- pkg/services/sqlstore/temp_user.go | 15 +- public/app/app.js | 4 +- public/app/components/require.config.js | 2 + public/app/features/org/orgUsersCtrl.js | 6 + .../app/features/org/partials/orgUsers.html | 81 +++-- public/app/features/org/userInviteCtrl.js | 6 +- public/app/partials/bootstrap/tab.html | 3 + public/app/partials/bootstrap/tabset.html | 10 + public/css/less/bootswatch.dark.less | 1 + public/css/less/bootswatch.light.less | 1 + public/css/less/grafana.less | 1 + public/css/less/tabs.less | 28 ++ public/test/test-main.js | 2 + public/vendor/angular-ui/angular-bootstrap.js | 6 + public/vendor/angular-ui/tabs.js | 293 ++++++++++++++++++ 20 files changed, 488 insertions(+), 57 deletions(-) create mode 100644 pkg/api/dtos/invite.go create mode 100644 pkg/api/org_invite.go create mode 100644 public/app/partials/bootstrap/tab.html create mode 100644 public/app/partials/bootstrap/tabset.html create mode 100644 public/css/less/tabs.less create mode 100644 public/vendor/angular-ui/angular-bootstrap.js create mode 100644 public/vendor/angular-ui/tabs.js diff --git a/pkg/api/api.go b/pkg/api/api.go index fd4160f9993..e1cb22dcae3 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -89,6 +89,10 @@ func Register(r *macaron.Macaron) { r.Get("/users", wrap(GetOrgUsersForCurrentOrg)) r.Patch("/users/:userId", bind(m.UpdateOrgUserCommand{}), wrap(UpdateOrgUserForCurrentOrg)) r.Delete("/users/:userId", wrap(RemoveOrgUserForCurrentOrg)) + + // invites + r.Get("/invites", wrap(GetPendingOrgInvites)) + r.Post("/invites", bind(dtos.AddInviteForm{}), wrap(AddOrgInvite)) }, regOrgAdmin) // create new org diff --git a/pkg/api/dtos/invite.go b/pkg/api/dtos/invite.go new file mode 100644 index 00000000000..d2d4129fa5b --- /dev/null +++ b/pkg/api/dtos/invite.go @@ -0,0 +1,9 @@ +package dtos + +import m "github.com/grafana/grafana/pkg/models" + +type AddInviteForm struct { + Email string `json:"email" binding:"Required"` + Name string `json:"name"` + Role m.RoleType `json:"role" binding:"Required"` +} diff --git a/pkg/api/org_invite.go b/pkg/api/org_invite.go new file mode 100644 index 00000000000..25740d00f1b --- /dev/null +++ b/pkg/api/org_invite.go @@ -0,0 +1,39 @@ +package api + +import ( + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/middleware" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/util" +) + +func GetPendingOrgInvites(c *middleware.Context) Response { + query := m.GetTempUsersForOrgQuery{OrgId: c.OrgId} + + if err := bus.Dispatch(&query); err != nil { + return ApiError(500, "Failed to get invites from db", err) + } + + return Json(200, query.Result) +} + +func AddOrgInvite(c *middleware.Context, inviteDto dtos.AddInviteForm) Response { + if !inviteDto.Role.IsValid() { + return ApiError(400, "Invalid role specified", nil) + } + + cmd := m.CreateTempUserCommand{} + cmd.OrgId = c.OrgId + cmd.Email = inviteDto.Email + cmd.Name = inviteDto.Name + cmd.IsInvite = true + cmd.InvitedByUserId = c.UserId + cmd.Code = util.GetRandomString(30) + + if err := bus.Dispatch(&cmd); err != nil { + return ApiError(500, "Failed to save invite to database", err) + } + + return ApiSuccess("ok, done!") +} diff --git a/pkg/models/temp_user.go b/pkg/models/temp_user.go index 0a59e914836..a1dd69a98e7 100644 --- a/pkg/models/temp_user.go +++ b/pkg/models/temp_user.go @@ -10,15 +10,16 @@ var ( ErrTempUserNotFound = errors.New("User not found") ) -// TempUser holds data for org invites and new sign ups +// TempUser holds data for org invites and unconfirmed sign ups type TempUser struct { - Id int64 - OrgId int64 - Version int - Email string - Name string - Role string - IsInvite bool + Id int64 + OrgId int64 + Version int + Email string + Name string + Role string + IsInvite bool + InvitedByUserId int64 EmailSent bool EmailSentOn time.Time @@ -32,11 +33,12 @@ type TempUser struct { // COMMANDS type CreateTempUserCommand struct { - Email string - Name string - OrgId int64 - IsInvite bool - Code string + Email string + Name string + OrgId int64 + IsInvite bool + InvitedByUserId int64 + Code string Result *TempUser } diff --git a/pkg/services/sqlstore/migrations/temp_user.go b/pkg/services/sqlstore/migrations/temp_user.go index 139ab569fb7..6453e659676 100644 --- a/pkg/services/sqlstore/migrations/temp_user.go +++ b/pkg/services/sqlstore/migrations/temp_user.go @@ -14,7 +14,7 @@ func addTempUserMigrations(mg *Migrator) { {Name: "role", Type: DB_NVarchar, Length: 20, Nullable: true}, {Name: "code", Type: DB_NVarchar, Length: 255}, {Name: "is_invite", Type: DB_Bool}, - {Name: "invited_by", Type: DB_NVarchar, Length: 255, Nullable: true}, + {Name: "invited_by_user_id", Type: DB_BigInt, Nullable: true}, {Name: "email_sent", Type: DB_Bool}, {Name: "email_sent_on", Type: DB_DateTime, Nullable: true}, {Name: "created", Type: DB_DateTime}, @@ -28,7 +28,7 @@ func addTempUserMigrations(mg *Migrator) { } // create table - mg.AddMigration("create temp user table v1", NewAddTableMigration(tempUserV1)) + mg.AddMigration("create temp user table v1-3", NewAddTableMigration(tempUserV1)) - addTableIndicesMigrations(mg, "v1-1", tempUserV1) + addTableIndicesMigrations(mg, "v1-3", tempUserV1) } diff --git a/pkg/services/sqlstore/temp_user.go b/pkg/services/sqlstore/temp_user.go index 0c92ad6d7ba..511cc1b1afc 100644 --- a/pkg/services/sqlstore/temp_user.go +++ b/pkg/services/sqlstore/temp_user.go @@ -17,13 +17,14 @@ func CreateTempUser(cmd *m.CreateTempUserCommand) error { // create user user := &m.TempUser{ - Email: cmd.Email, - Name: cmd.Name, - OrgId: cmd.OrgId, - Code: cmd.Code, - IsInvite: cmd.IsInvite, - Created: time.Now(), - Updated: time.Now(), + Email: cmd.Email, + Name: cmd.Name, + OrgId: cmd.OrgId, + Code: cmd.Code, + IsInvite: cmd.IsInvite, + InvitedByUserId: cmd.InvitedByUserId, + Created: time.Now(), + Updated: time.Now(), } sess.UseBool("is_invite") diff --git a/public/app/app.js b/public/app/app.js index 4d1dec4f673..8adc04d1ef7 100644 --- a/public/app/app.js +++ b/public/app/app.js @@ -12,6 +12,7 @@ define([ 'angular-sanitize', 'angular-strap', 'angular-dragdrop', + 'angular-ui', 'extend-jquery', 'bindonce', ], @@ -64,7 +65,8 @@ function (angular, $, _, appLevelRequire) { '$strap.directives', 'ang-drag-drop', 'grafana', - 'pasvaz.bindonce' + 'pasvaz.bindonce', + 'ui.bootstrap.tabs', ]; var module_types = ['controllers', 'directives', 'factories', 'services', 'filters', 'routes']; diff --git a/public/app/components/require.config.js b/public/app/components/require.config.js index 12a72dd42e3..31825804d04 100644 --- a/public/app/components/require.config.js +++ b/public/app/components/require.config.js @@ -17,6 +17,7 @@ require.config({ 'angular-sanitize': '../vendor/angular-sanitize/angular-sanitize', 'angular-dragdrop': '../vendor/angular-native-dragdrop/draganddrop', 'angular-strap': '../vendor/angular-other/angular-strap', + 'angular-ui': '../vendor/angular-ui/angular-bootstrap', timepicker: '../vendor/angular-other/timepicker', datepicker: '../vendor/angular-other/datepicker', bindonce: '../vendor/angular-bindonce/bindonce', @@ -90,6 +91,7 @@ require.config({ 'angular-dragdrop': ['jquery', 'angular'], 'angular-mocks': ['angular'], 'angular-sanitize': ['angular'], + 'angular-ui': ['angular'], 'angular-route': ['angular'], 'angular-strap': ['angular', 'bootstrap','timepicker', 'datepicker'], 'bindonce': ['angular'], diff --git a/public/app/features/org/orgUsersCtrl.js b/public/app/features/org/orgUsersCtrl.js index bc124856bab..1a743c9713e 100644 --- a/public/app/features/org/orgUsersCtrl.js +++ b/public/app/features/org/orgUsersCtrl.js @@ -13,6 +13,9 @@ function (angular) { role: 'Viewer', }; + $scope.users = []; + $scope.pendingInvites = []; + $scope.init = function() { $scope.get(); $scope.editor = { index: 0 }; @@ -22,6 +25,9 @@ function (angular) { backendSrv.get('/api/org/users').then(function(users) { $scope.users = users; }); + backendSrv.get('/api/org/invites').then(function(pendingInvites) { + $scope.pendingInvites = pendingInvites; + }); }; $scope.updateOrgUser = function(user) { diff --git a/public/app/features/org/partials/orgUsers.html b/public/app/features/org/partials/orgUsers.html index dcbb180b160..d8422502407 100644 --- a/public/app/features/org/partials/orgUsers.html +++ b/public/app/features/org/partials/orgUsers.html @@ -15,39 +15,56 @@
-
-
-
-
+ + + + + + + + + + + + + + + +
LoginEmailRole
{{user.login}}{{user.email}} + + + + + +
+
+ + + + + + + + + + + + + + + + + + +
EmailNameRoleCreated onInvited by
{{invite.email}}{{invite.name}}{{invite.role}}{{invite.createdOn | date:'medium'}}{{invite.invitedBy}} + + + +
+
+
-
- - - - - - - - - - - - - -
LoginEmailRole
{{user.login}}{{user.email}} - - - - - -
- -
- -
- Pending invitaitons -
diff --git a/public/app/features/org/userInviteCtrl.js b/public/app/features/org/userInviteCtrl.js index b93611d6df4..26ec89ddc94 100644 --- a/public/app/features/org/userInviteCtrl.js +++ b/public/app/features/org/userInviteCtrl.js @@ -7,7 +7,7 @@ function (angular, _) { var module = angular.module('grafana.controllers'); - module.controller('UserInviteCtrl', function($scope) { + module.controller('UserInviteCtrl', function($scope, backendSrv) { $scope.invites = [ {name: '', email: '', role: 'Editor'}, @@ -27,6 +27,10 @@ function (angular, _) { $scope.sendInvites = function() { if (!$scope.inviteForm.$valid) { return; } + _.each($scope.invites, function(invite) { + backendSrv.post('/api/org/invites', invite); + }); + $scope.dismiss(); }; }); diff --git a/public/app/partials/bootstrap/tab.html b/public/app/partials/bootstrap/tab.html new file mode 100644 index 00000000000..d76dd67caf2 --- /dev/null +++ b/public/app/partials/bootstrap/tab.html @@ -0,0 +1,3 @@ +
  • + {{heading}} +
  • diff --git a/public/app/partials/bootstrap/tabset.html b/public/app/partials/bootstrap/tabset.html new file mode 100644 index 00000000000..acbad38390b --- /dev/null +++ b/public/app/partials/bootstrap/tabset.html @@ -0,0 +1,10 @@ +
    + +
    +
    +
    +
    +
    diff --git a/public/css/less/bootswatch.dark.less b/public/css/less/bootswatch.dark.less index b134a3d495a..4d83d04199c 100644 --- a/public/css/less/bootswatch.dark.less +++ b/public/css/less/bootswatch.dark.less @@ -220,6 +220,7 @@ div.subnav { li > a:hover, li.active > a, + li.active > a:focus, li.active > a:hover { border-color: transparent; background-color: transparent; diff --git a/public/css/less/bootswatch.light.less b/public/css/less/bootswatch.light.less index 42672d7f0b4..fd3c00cabd3 100644 --- a/public/css/less/bootswatch.light.less +++ b/public/css/less/bootswatch.light.less @@ -159,6 +159,7 @@ div.subnav { li > a:hover, li.active > a, + li.active > a:focus, li.active > a:hover { border-color: transparent; background-color: transparent; diff --git a/public/css/less/grafana.less b/public/css/less/grafana.less index 612cfd26818..b26858057ff 100644 --- a/public/css/less/grafana.less +++ b/public/css/less/grafana.less @@ -15,6 +15,7 @@ @import "admin.less"; @import "validation.less"; @import "fonts.less"; +@import "tabs.less"; .row-control-inner { padding:0px; diff --git a/public/css/less/tabs.less b/public/css/less/tabs.less new file mode 100644 index 00000000000..0938c853360 --- /dev/null +++ b/public/css/less/tabs.less @@ -0,0 +1,28 @@ + +.nav-tabs-alt { + border-bottom: @grafanaTriggerBorder; + padding-left: 10px; + + & > li > a { + .border-radius(3px); + } + + li > a:hover, + li.active > a, + li.active > a:focus, + li.active > a:hover { + border: @grafanaTriggerBorder; + background-color: transparent; + border-bottom: 1px solid @grafanaPanelBackground; + } + + li.disabled > a { + color: @textColor; + } + + .open .dropdown-toggle { + background-color: #060606; + border-color: transparent; + } +} + diff --git a/public/test/test-main.js b/public/test/test-main.js index e6e18512216..946cc6aad77 100644 --- a/public/test/test-main.js +++ b/public/test/test-main.js @@ -22,6 +22,7 @@ require.config({ 'angular-sanitize': '../vendor/angular-sanitize/angular-sanitize', angularMocks: '../vendor/angular-mocks/angular-mocks', 'angular-dragdrop': '../vendor/angular-native-dragdrop/draganddrop', + 'angular-ui': '../vendor/angular-ui/angular-bootstrap', 'angular-strap': '../vendor/angular-other/angular-strap', timepicker: '../vendor/angular-other/timepicker', datepicker: '../vendor/angular-other/datepicker', @@ -83,6 +84,7 @@ require.config({ 'angular-route': ['angular'], 'angular-sanitize': ['angular'], + 'angular-ui': ['angular'], 'angular-dragdrop': ['jquery', 'angular'], 'angular-mocks': ['angular'], 'angular-strap': ['angular', 'bootstrap','timepicker', 'datepicker'], diff --git a/public/vendor/angular-ui/angular-bootstrap.js b/public/vendor/angular-ui/angular-bootstrap.js new file mode 100644 index 00000000000..f90955af4e7 --- /dev/null +++ b/public/vendor/angular-ui/angular-bootstrap.js @@ -0,0 +1,6 @@ +define([ + 'angular', + '../vendor/angular-ui/tabs', +], function() { +}); + diff --git a/public/vendor/angular-ui/tabs.js b/public/vendor/angular-ui/tabs.js new file mode 100644 index 00000000000..4d87ac0bab1 --- /dev/null +++ b/public/vendor/angular-ui/tabs.js @@ -0,0 +1,293 @@ + +/** + * @ngdoc overview + * @name ui.bootstrap.tabs + * + * @description + * AngularJS version of the tabs directive. + */ + +angular.module('ui.bootstrap.tabs', []) + +.controller('TabsetController', ['$scope', function TabsetCtrl($scope) { + var ctrl = this, + tabs = ctrl.tabs = $scope.tabs = []; + + ctrl.select = function(selectedTab) { + angular.forEach(tabs, function(tab) { + if (tab.active && tab !== selectedTab) { + tab.active = false; + tab.onDeselect(); + } + }); + selectedTab.active = true; + selectedTab.onSelect(); + }; + + ctrl.addTab = function addTab(tab) { + tabs.push(tab); + // we can't run the select function on the first tab + // since that would select it twice + if (tabs.length === 1 && tab.active !== false) { + tab.active = true; + } else if (tab.active) { + ctrl.select(tab); + } + else { + tab.active = false; + } + }; + + ctrl.removeTab = function removeTab(tab) { + var index = tabs.indexOf(tab); + //Select a new tab if the tab to be removed is selected and not destroyed + if (tab.active && tabs.length > 1 && !destroyed) { + //If this is the last tab, select the previous tab. else, the next tab. + var newActiveIndex = index == tabs.length - 1 ? index - 1 : index + 1; + ctrl.select(tabs[newActiveIndex]); + } + tabs.splice(index, 1); + }; + + var destroyed; + $scope.$on('$destroy', function() { + destroyed = true; + }); +}]) + +/** + * @ngdoc directive + * @name ui.bootstrap.tabs.directive:tabset + * @restrict EA + * + * @description + * Tabset is the outer container for the tabs directive + * + * @param {boolean=} vertical Whether or not to use vertical styling for the tabs. + * @param {boolean=} justified Whether or not to use justified styling for the tabs. + * + * @example + + + + First Content! + Second Content! + +
    + + First Vertical Content! + Second Vertical Content! + + + First Justified Content! + Second Justified Content! + +
    +
    + */ +.directive('tabset', function() { + return { + restrict: 'EA', + transclude: true, + replace: true, + scope: { + type: '@' + }, + controller: 'TabsetController', + templateUrl: 'app/partials/bootstrap/tabset.html', + link: function(scope, element, attrs) { + scope.vertical = angular.isDefined(attrs.vertical) ? scope.$parent.$eval(attrs.vertical) : false; + scope.justified = angular.isDefined(attrs.justified) ? scope.$parent.$eval(attrs.justified) : false; + } + }; +}) + +/** + * @ngdoc directive + * @name ui.bootstrap.tabs.directive:tab + * @restrict EA + * + * @param {string=} heading The visible heading, or title, of the tab. Set HTML headings with {@link ui.bootstrap.tabs.directive:tabHeading tabHeading}. + * @param {string=} select An expression to evaluate when the tab is selected. + * @param {boolean=} active A binding, telling whether or not this tab is selected. + * @param {boolean=} disabled A binding, telling whether or not this tab is disabled. + * + * @description + * Creates a tab with a heading and content. Must be placed within a {@link ui.bootstrap.tabs.directive:tabset tabset}. + * + * @example + + +
    + + +
    + + First Tab + + Alert me! + Second Tab, with alert callback and html heading! + + + {{item.content}} + + +
    +
    + + function TabsDemoCtrl($scope) { + $scope.items = [ + { title:"Dynamic Title 1", content:"Dynamic Item 0" }, + { title:"Dynamic Title 2", content:"Dynamic Item 1", disabled: true } + ]; + + $scope.alertMe = function() { + setTimeout(function() { + alert("You've selected the alert tab!"); + }); + }; + }; + +
    + */ + +/** + * @ngdoc directive + * @name ui.bootstrap.tabs.directive:tabHeading + * @restrict EA + * + * @description + * Creates an HTML heading for a {@link ui.bootstrap.tabs.directive:tab tab}. Must be placed as a child of a tab element. + * + * @example + + + + + HTML in my titles?! + And some content, too! + + + Icon heading?!? + That's right. + + + + + */ +.directive('tab', ['$parse', '$log', function($parse, $log) { + return { + require: '^tabset', + restrict: 'EA', + replace: true, + templateUrl: 'app/partials/bootstrap/tab.html', + transclude: true, + scope: { + active: '=?', + heading: '@', + onSelect: '&select', //This callback is called in contentHeadingTransclude + //once it inserts the tab's content into the dom + onDeselect: '&deselect' + }, + controller: function() { + //Empty controller so other directives can require being 'under' a tab + }, + compile: function(elm, attrs, transclude) { + return function postLink(scope, elm, attrs, tabsetCtrl) { + scope.$watch('active', function(active) { + if (active) { + tabsetCtrl.select(scope); + } + }); + + scope.disabled = false; + if ( attrs.disable ) { + scope.$parent.$watch($parse(attrs.disable), function(value) { + scope.disabled = !! value; + }); + } + + // Deprecation support of "disabled" parameter + // fix(tab): IE9 disabled attr renders grey text on enabled tab #2677 + // This code is duplicated from the lines above to make it easy to remove once + // the feature has been completely deprecated + if ( attrs.disabled ) { + $log.warn('Use of "disabled" attribute has been deprecated, please use "disable"'); + scope.$parent.$watch($parse(attrs.disabled), function(value) { + scope.disabled = !! value; + }); + } + + scope.select = function() { + if ( !scope.disabled ) { + scope.active = true; + } + }; + + tabsetCtrl.addTab(scope); + scope.$on('$destroy', function() { + tabsetCtrl.removeTab(scope); + }); + + //We need to transclude later, once the content container is ready. + //when this link happens, we're inside a tab heading. + scope.$transcludeFn = transclude; + }; + } + }; +}]) + +.directive('tabHeadingTransclude', [function() { + return { + restrict: 'A', + require: '^tab', + link: function(scope, elm, attrs, tabCtrl) { + scope.$watch('headingElement', function updateHeadingElement(heading) { + if (heading) { + elm.html(''); + elm.append(heading); + } + }); + } + }; +}]) + +.directive('tabContentTransclude', function() { + return { + restrict: 'A', + require: '^tabset', + link: function(scope, elm, attrs) { + var tab = scope.$eval(attrs.tabContentTransclude); + + //Now our tab is ready to be transcluded: both the tab heading area + //and the tab content area are loaded. Transclude 'em both. + tab.$transcludeFn(tab.$parent, function(contents) { + angular.forEach(contents, function(node) { + if (isTabHeading(node)) { + //Let tabHeadingTransclude know. + tab.headingElement = node; + } else { + elm.append(node); + } + }); + }); + } + }; + function isTabHeading(node) { + return node.tagName && ( + node.hasAttribute('tab-heading') || + node.hasAttribute('data-tab-heading') || + node.tagName.toLowerCase() === 'tab-heading' || + node.tagName.toLowerCase() === 'data-tab-heading' + ); + } +}) + +;