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

This commit is contained in:
Torkel Ödegaard
2015-07-17 09:51:34 +02:00
parent 444807c35b
commit 0ffcce1b5d
20 changed files with 488 additions and 57 deletions

View File

@ -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
pkg/api/dtos/invite.go Normal file
View File

@ -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
pkg/api/org_invite.go Normal file
View File

@ -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!")
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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")

View File

@ -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'];

View File

@ -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'],

View File

@ -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) {

View File

@ -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>

View File

@ -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();
};
});

View File

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

View File

@ -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>

View File

@ -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;

View File

@ -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;

View File

@ -15,6 +15,7 @@
@import "admin.less";
@import "validation.less";
@import "fonts.less";
@import "tabs.less";
.row-control-inner {
padding:0px;

28
public/css/less/tabs.less Normal file
View File

@ -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;
}
}

View File

@ -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'],

View File

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

293
public/vendor/angular-ui/tabs.js vendored Normal file
View File

@ -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'
);
}
})
;