Browse Source

feat(invite): more work on invite, basic creation works, added new tab directive from angular-ui and made new tab style, #2353

Torkel Ödegaard 10 years ago
parent
commit
0ffcce1b5d

+ 4 - 0
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

+ 9 - 0
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"`
+}

+ 39 - 0
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!")
+}

+ 15 - 13
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
 }

+ 3 - 3
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)
 }

+ 8 - 7
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")

+ 3 - 1
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'];

+ 2 - 0
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'],

+ 6 - 0
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) {

+ 49 - 32
public/app/features/org/partials/orgUsers.html

@@ -15,39 +15,56 @@
 
 		<br>
 
-		<div ng-model="editor.index" bs-tabs style="text-transform:capitalize;">
-			<div ng-repeat="tab in ['Users', 'Pending Invitations']" data-title="{{tab}}">
-			</div>
-		</div>
+		<tabset>
+			<tab heading="Users ({{users.length}})">
+				<table class="grafana-options-table form-inline">
+					<tr>
+						<th>Login</th>
+						<th>Email</th>
+						<th>Role</th>
+						<th></th>
+					</tr>
+					<tr ng-repeat="user in users">
+						<td>{{user.login}}</td>
+						<td>{{user.email}}</td>
+						<td>
+							<select type="text" ng-model="user.role" class="input-medium" ng-options="f for f in ['Viewer', 'Editor', 'Read Only Editor', 'Admin']" ng-change="updateOrgUser(user)">
+							</select>
+						</td>
+						<td style="width: 1%">
+							<a ng-click="removeUser(user)" class="btn btn-danger btn-mini">
+								<i class="fa fa-remove"></i>
+							</a>
+						</td>
+					</tr>
+				</table>
+			</tab>
+			<tab heading="Pending Invitations ({{pendingInvites.length}})">
+				<table class="grafana-options-table form-inline">
+					<tr>
+						<th>Email</th>
+						<th>Name</th>
+						<th>Role</th>
+						<th>Created on</th>
+						<th>Invited by</th>
+						<th></th>
+					</tr>
+					<tr ng-repeat="invite in pendingInvites">
+						<td>{{invite.email}}</td>
+						<td>{{invite.name}}</td>
+						<td>{{invite.role}}</td>
+						<td>{{invite.createdOn | date:'medium'}}</td>
+						<td>{{invite.invitedBy}}</td>
+						<td style="width: 1%">
+							<a ng-click="removeInvite(invite)" class="btn btn-danger btn-mini">
+								<i class="fa fa-remove"></i>
+							</a>
+						</td>
+					</tr>
+				</table>
+			</tab>
+		</tabset>
 
-		<div ng-if="editor.index == 0">
-			<table class="grafana-options-table form-inline">
-				<tr>
-					<th>Login</th>
-					<th>Email</th>
-					<th>Role</th>
-					<th></th>
-				</tr>
-				<tr ng-repeat="user in users">
-					<td>{{user.login}}</td>
-					<td>{{user.email}}</td>
-					<td>
-						<select type="text" ng-model="user.role" class="input-medium" ng-options="f for f in ['Viewer', 'Editor', 'Read Only Editor', 'Admin']" ng-change="updateOrgUser(user)">
-						</select>
-					</td>
-					<td style="width: 1%">
-						<a ng-click="removeUser(user)" class="btn btn-danger btn-mini">
-							<i class="fa fa-remove"></i>
-						</a>
-					</td>
-				</tr>
-			</table>
-
-		</div>
-
-		<div ng-if="editor.index == 1">
-			Pending invitaitons
-		</div>
 	</div>
 </div>
 

+ 5 - 1
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();
     };
   });

+ 3 - 0
public/app/partials/bootstrap/tab.html

@@ -0,0 +1,3 @@
+<li ng-class="{active: active, disabled: disabled}">
+  <a href ng-click="select()" tab-heading-transclude>{{heading}}</a>
+</li>

+ 10 - 0
public/app/partials/bootstrap/tabset.html

@@ -0,0 +1,10 @@
+<div>
+  <ul class="nav nav-{{type || 'tabs'}} nav-tabs-alt" ng-class="{'nav-stacked': vertical, 'nav-justified': justified}" ng-transclude></ul>
+  <div class="tab-content">
+    <div class="tab-pane"
+         ng-repeat="tab in tabs"
+         ng-class="{active: tab.active}"
+         tab-content-transclude="tab">
+    </div>
+  </div>
+</div>

+ 1 - 0
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;

+ 1 - 0
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;

+ 1 - 0
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;

+ 28 - 0
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;
+	}
+}
+

+ 2 - 0
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'],

+ 6 - 0
public/vendor/angular-ui/angular-bootstrap.js

@@ -0,0 +1,6 @@
+define([
+  'angular',
+  '../vendor/angular-ui/tabs',
+], function() {
+});
+

+ 293 - 0
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
+<example module="ui.bootstrap">
+  <file name="index.html">
+    <tabset>
+      <tab heading="Tab 1"><b>First</b> Content!</tab>
+      <tab heading="Tab 2"><i>Second</i> Content!</tab>
+    </tabset>
+    <hr />
+    <tabset vertical="true">
+      <tab heading="Vertical Tab 1"><b>First</b> Vertical Content!</tab>
+      <tab heading="Vertical Tab 2"><i>Second</i> Vertical Content!</tab>
+    </tabset>
+    <tabset justified="true">
+      <tab heading="Justified Tab 1"><b>First</b> Justified Content!</tab>
+      <tab heading="Justified Tab 2"><i>Second</i> Justified Content!</tab>
+    </tabset>
+  </file>
+</example>
+ */
+.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
+<example module="ui.bootstrap">
+  <file name="index.html">
+    <div ng-controller="TabsDemoCtrl">
+      <button class="btn btn-small" ng-click="items[0].active = true">
+        Select item 1, using active binding
+      </button>
+      <button class="btn btn-small" ng-click="items[1].disabled = !items[1].disabled">
+        Enable/disable item 2, using disabled binding
+      </button>
+      <br />
+      <tabset>
+        <tab heading="Tab 1">First Tab</tab>
+        <tab select="alertMe()">
+          <tab-heading><i class="icon-bell"></i> Alert me!</tab-heading>
+          Second Tab, with alert callback and html heading!
+        </tab>
+        <tab ng-repeat="item in items"
+          heading="{{item.title}}"
+          disabled="item.disabled"
+          active="item.active">
+          {{item.content}}
+        </tab>
+      </tabset>
+    </div>
+  </file>
+  <file name="script.js">
+    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!");
+        });
+      };
+    };
+  </file>
+</example>
+ */
+
+/**
+ * @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
+<example module="ui.bootstrap">
+  <file name="index.html">
+    <tabset>
+      <tab>
+        <tab-heading><b>HTML</b> in my titles?!</tab-heading>
+        And some content, too!
+      </tab>
+      <tab>
+        <tab-heading><i class="icon-heart"></i> Icon heading?!?</tab-heading>
+        That's right.
+      </tab>
+    </tabset>
+  </file>
+</example>
+ */
+.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'
+    );
+  }
+})
+
+;