Commit af34f997 by Marcus Efraimsson Committed by Torkel Ödegaard

Add avatar to team and team members page (#10305)

* teams: add db migration for email column in teams table

* teams: /teams should render index page with a 200 OK

* teams: additional backend functionality for team and team members

Possibility to save/update email for teams.
Possibility to retrive avatar url when searching for teams.
Possibility to retrive avatar url when searching for team members.

* teams: display team avatar and team member avatars

Possibility to save and update email for a team

* teams: create team on separate page instead of modal dialog
parent d41ce4f9
...@@ -40,8 +40,11 @@ func (hs *HttpServer) registerRoutes() { ...@@ -40,8 +40,11 @@ func (hs *HttpServer) registerRoutes() {
r.Get("/datasources/", reqSignedIn, Index) r.Get("/datasources/", reqSignedIn, Index)
r.Get("/datasources/new", reqSignedIn, Index) r.Get("/datasources/new", reqSignedIn, Index)
r.Get("/datasources/edit/*", reqSignedIn, Index) r.Get("/datasources/edit/*", reqSignedIn, Index)
r.Get("/org/users", reqSignedIn, Index)
r.Get("/org/users/new", reqSignedIn, Index) r.Get("/org/users/new", reqSignedIn, Index)
r.Get("/org/users/invite", reqSignedIn, Index) r.Get("/org/users/invite", reqSignedIn, Index)
r.Get("/org/teams", reqSignedIn, Index)
r.Get("/org/teams/*", reqSignedIn, Index)
r.Get("/org/apikeys/", reqSignedIn, Index) r.Get("/org/apikeys/", reqSignedIn, Index)
r.Get("/dashboard/import/", reqSignedIn, Index) r.Get("/dashboard/import/", reqSignedIn, Index)
r.Get("/configuration", reqGrafanaAdmin, Index) r.Get("/configuration", reqGrafanaAdmin, Index)
......
...@@ -3,6 +3,7 @@ package dtos ...@@ -3,6 +3,7 @@ package dtos
import ( import (
"crypto/md5" "crypto/md5"
"fmt" "fmt"
"regexp"
"strings" "strings"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
...@@ -57,3 +58,19 @@ func GetGravatarUrl(text string) string { ...@@ -57,3 +58,19 @@ func GetGravatarUrl(text string) string {
hasher.Write([]byte(strings.ToLower(text))) hasher.Write([]byte(strings.ToLower(text)))
return fmt.Sprintf(setting.AppSubUrl+"/avatar/%x", hasher.Sum(nil)) return fmt.Sprintf(setting.AppSubUrl+"/avatar/%x", hasher.Sum(nil))
} }
func GetGravatarUrlWithDefault(text string, defaultText string) string {
if text != "" {
return GetGravatarUrl(text)
}
reg, err := regexp.Compile("[^a-zA-Z0-9]+")
if err != nil {
return ""
}
text = reg.ReplaceAllString(defaultText, "") + "@localhost"
return GetGravatarUrl(text)
}
package api package api
import ( import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/middleware"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
...@@ -70,6 +71,10 @@ func SearchTeams(c *middleware.Context) Response { ...@@ -70,6 +71,10 @@ func SearchTeams(c *middleware.Context) Response {
return ApiError(500, "Failed to search Teams", err) return ApiError(500, "Failed to search Teams", err)
} }
for _, team := range query.Result.Teams {
team.AvatarUrl = dtos.GetGravatarUrlWithDefault(team.Email, team.Name)
}
query.Result.Page = page query.Result.Page = page
query.Result.PerPage = perPage query.Result.PerPage = perPage
......
package api package api
import ( import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/middleware"
m "github.com/grafana/grafana/pkg/models" m "github.com/grafana/grafana/pkg/models"
...@@ -15,6 +16,10 @@ func GetTeamMembers(c *middleware.Context) Response { ...@@ -15,6 +16,10 @@ func GetTeamMembers(c *middleware.Context) Response {
return ApiError(500, "Failed to get Team Members", err) return ApiError(500, "Failed to get Team Members", err)
} }
for _, member := range query.Result {
member.AvatarUrl = dtos.GetGravatarUrl(member.Email)
}
return Json(200, query.Result) return Json(200, query.Result)
} }
......
...@@ -16,6 +16,7 @@ type Team struct { ...@@ -16,6 +16,7 @@ type Team struct {
Id int64 `json:"id"` Id int64 `json:"id"`
OrgId int64 `json:"orgId"` OrgId int64 `json:"orgId"`
Name string `json:"name"` Name string `json:"name"`
Email string `json:"email"`
Created time.Time `json:"created"` Created time.Time `json:"created"`
Updated time.Time `json:"updated"` Updated time.Time `json:"updated"`
...@@ -26,14 +27,16 @@ type Team struct { ...@@ -26,14 +27,16 @@ type Team struct {
type CreateTeamCommand struct { type CreateTeamCommand struct {
Name string `json:"name" binding:"Required"` Name string `json:"name" binding:"Required"`
Email string `json:"email"`
OrgId int64 `json:"-"` OrgId int64 `json:"-"`
Result Team `json:"-"` Result Team `json:"-"`
} }
type UpdateTeamCommand struct { type UpdateTeamCommand struct {
Id int64 Id int64
Name string Name string
Email string
} }
type DeleteTeamCommand struct { type DeleteTeamCommand struct {
...@@ -64,6 +67,8 @@ type SearchTeamDto struct { ...@@ -64,6 +67,8 @@ type SearchTeamDto struct {
Id int64 `json:"id"` Id int64 `json:"id"`
OrgId int64 `json:"orgId"` OrgId int64 `json:"orgId"`
Name string `json:"name"` Name string `json:"name"`
Email string `json:"email"`
AvatarUrl string `json:"avatarUrl"`
MemberCount int64 `json:"memberCount"` MemberCount int64 `json:"memberCount"`
} }
......
...@@ -47,9 +47,10 @@ type GetTeamMembersQuery struct { ...@@ -47,9 +47,10 @@ type GetTeamMembersQuery struct {
// Projections and DTOs // Projections and DTOs
type TeamMemberDTO struct { type TeamMemberDTO struct {
OrgId int64 `json:"orgId"` OrgId int64 `json:"orgId"`
TeamId int64 `json:"teamId"` TeamId int64 `json:"teamId"`
UserId int64 `json:"userId"` UserId int64 `json:"userId"`
Email string `json:"email"` Email string `json:"email"`
Login string `json:"login"` Login string `json:"login"`
AvatarUrl string `json:"avatarUrl"`
} }
...@@ -45,4 +45,9 @@ func addTeamMigrations(mg *Migrator) { ...@@ -45,4 +45,9 @@ func addTeamMigrations(mg *Migrator) {
//------- indexes ------------------ //------- indexes ------------------
mg.AddMigration("add index team_member.org_id", NewAddIndexMigration(teamMemberV1, teamMemberV1.Indices[0])) mg.AddMigration("add index team_member.org_id", NewAddIndexMigration(teamMemberV1, teamMemberV1.Indices[0]))
mg.AddMigration("add unique index team_member_org_id_team_id_user_id", NewAddIndexMigration(teamMemberV1, teamMemberV1.Indices[1])) mg.AddMigration("add unique index team_member_org_id_team_id_user_id", NewAddIndexMigration(teamMemberV1, teamMemberV1.Indices[1]))
// add column email
mg.AddMigration("Add column email to team table", NewAddColumnMigration(teamV1, &Column{
Name: "email", Type: DB_NVarchar, Nullable: true, Length: 190,
}))
} }
...@@ -33,6 +33,7 @@ func CreateTeam(cmd *m.CreateTeamCommand) error { ...@@ -33,6 +33,7 @@ func CreateTeam(cmd *m.CreateTeamCommand) error {
team := m.Team{ team := m.Team{
Name: cmd.Name, Name: cmd.Name,
Email: cmd.Email,
OrgId: cmd.OrgId, OrgId: cmd.OrgId,
Created: time.Now(), Created: time.Now(),
Updated: time.Now(), Updated: time.Now(),
...@@ -57,9 +58,12 @@ func UpdateTeam(cmd *m.UpdateTeamCommand) error { ...@@ -57,9 +58,12 @@ func UpdateTeam(cmd *m.UpdateTeamCommand) error {
team := m.Team{ team := m.Team{
Name: cmd.Name, Name: cmd.Name,
Email: cmd.Email,
Updated: time.Now(), Updated: time.Now(),
} }
sess.MustCols("email")
affectedRows, err := sess.Id(cmd.Id).Update(&team) affectedRows, err := sess.Id(cmd.Id).Update(&team)
if err != nil { if err != nil {
...@@ -125,6 +129,7 @@ func SearchTeams(query *m.SearchTeamsQuery) error { ...@@ -125,6 +129,7 @@ func SearchTeams(query *m.SearchTeamsQuery) error {
sql.WriteString(`select sql.WriteString(`select
team.id as id, team.id as id,
team.name as name, team.name as name,
team.email as email,
(select count(*) from team_member where team_member.team_id = team.id) as member_count (select count(*) from team_member where team_member.team_id = team.id) as member_count
from team as team from team as team
where team.org_id = ?`) where team.org_id = ?`)
......
...@@ -27,8 +27,8 @@ func TestTeamCommandsAndQueries(t *testing.T) { ...@@ -27,8 +27,8 @@ func TestTeamCommandsAndQueries(t *testing.T) {
userIds = append(userIds, userCmd.Result.Id) userIds = append(userIds, userCmd.Result.Id)
} }
group1 := m.CreateTeamCommand{Name: "group1 name"} group1 := m.CreateTeamCommand{Name: "group1 name", Email: "test1@test.com"}
group2 := m.CreateTeamCommand{Name: "group2 name"} group2 := m.CreateTeamCommand{Name: "group2 name", Email: "test2@test.com"}
err := CreateTeam(&group1) err := CreateTeam(&group1)
So(err, ShouldBeNil) So(err, ShouldBeNil)
...@@ -43,6 +43,7 @@ func TestTeamCommandsAndQueries(t *testing.T) { ...@@ -43,6 +43,7 @@ func TestTeamCommandsAndQueries(t *testing.T) {
team1 := query.Result.Teams[0] team1 := query.Result.Teams[0]
So(team1.Name, ShouldEqual, "group1 name") So(team1.Name, ShouldEqual, "group1 name")
So(team1.Email, ShouldEqual, "test1@test.com")
err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: team1.Id, UserId: userIds[0]}) err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: 1, TeamId: team1.Id, UserId: userIds[0]})
So(err, ShouldBeNil) So(err, ShouldBeNil)
...@@ -76,6 +77,7 @@ func TestTeamCommandsAndQueries(t *testing.T) { ...@@ -76,6 +77,7 @@ func TestTeamCommandsAndQueries(t *testing.T) {
So(err, ShouldBeNil) So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 1) So(len(query.Result), ShouldEqual, 1)
So(query.Result[0].Name, ShouldEqual, "group2 name") So(query.Result[0].Name, ShouldEqual, "group2 name")
So(query.Result[0].Email, ShouldEqual, "test2@test.com")
}) })
Convey("Should be able to remove users from a group", func() { Convey("Should be able to remove users from a group", func() {
......
...@@ -145,6 +145,12 @@ function setupAngularRoutes($routeProvider, $locationProvider) { ...@@ -145,6 +145,12 @@ function setupAngularRoutes($routeProvider, $locationProvider) {
controllerAs: 'ctrl', controllerAs: 'ctrl',
resolve: loadOrgBundle, resolve: loadOrgBundle,
}) })
.when('/org/teams/new', {
templateUrl: 'public/app/features/org/partials/create_team.html',
controller: 'CreateTeamCtrl',
controllerAs: 'ctrl',
resolve: loadOrgBundle,
})
.when('/org/teams/edit/:id', { .when('/org/teams/edit/:id', {
templateUrl: 'public/app/features/org/partials/team_details.html', templateUrl: 'public/app/features/org/partials/team_details.html',
controller: 'TeamDetailsCtrl', controller: 'TeamDetailsCtrl',
......
import './org_users_ctrl'; import "./org_users_ctrl";
import './profile_ctrl'; import "./profile_ctrl";
import './org_users_ctrl'; import "./org_users_ctrl";
import './select_org_ctrl'; import "./select_org_ctrl";
import './change_password_ctrl'; import "./change_password_ctrl";
import './new_org_ctrl'; import "./new_org_ctrl";
import './user_invite_ctrl'; import "./user_invite_ctrl";
import './teams_ctrl'; import "./teams_ctrl";
import './team_details_ctrl'; import "./team_details_ctrl";
import './create_team_modal'; import "./create_team_ctrl";
import './org_api_keys_ctrl'; import "./org_api_keys_ctrl";
import './org_details_ctrl'; import "./org_details_ctrl";
import './prefs_control'; import "./prefs_control";
import coreModule from "app/core/core_module";
export default class CreateTeamCtrl {
name: string;
email: string;
navModel: any;
/** @ngInject **/
constructor(private backendSrv, private $location, navModelSrv) {
this.navModel = navModelSrv.getNav("cfg", "teams", 0);
}
create() {
const payload = {
name: this.name,
email: this.email
};
this.backendSrv.post("/api/teams", payload).then(result => {
if (result.teamId) {
this.$location.path("/org/teams/edit/" + result.teamId);
}
});
}
}
coreModule.controller("CreateTeamCtrl", CreateTeamCtrl);
///<reference path="../../headers/common.d.ts" />
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
export class CreateTeamCtrl {
teamName = '';
/** @ngInject */
constructor(private backendSrv, private $location) {}
createTeam() {
this.backendSrv.post('/api/teams', { name: this.teamName }).then(result => {
if (result.teamId) {
this.$location.path('/org/teams/edit/' + result.teamId);
}
this.dismiss();
});
}
dismiss() {
appEvents.emit('hide-modal');
}
}
export function createTeamModal() {
return {
restrict: 'E',
templateUrl: 'public/app/features/org/partials/create_team.html',
controller: CreateTeamCtrl,
bindToController: true,
controllerAs: 'ctrl',
};
}
coreModule.directive('createTeamModal', createTeamModal);
<div class="modal-body"> <page-header model="ctrl.navModel"></page-header>
<div class="modal-header">
<h2 class="modal-header-title">
<i class="gicon gicon-team"></i>
<span class="p-l-1">Create Team</span>
</h2>
<a class="modal-header-close" ng-click="ctrl.dismiss();"> <div class="page-container page-body" ng-cloak>
<i class="fa fa-remove"></i> <h3 class="page-sub-heading">New Team</h3>
</a>
</div>
<div class="modal-content"> <form name="ctrl.saveForm" class="gf-form-group" ng-submit="ctrl.create()">
<form name="ctrl.createTeamForm" class="gf-form-group" novalidate> <div class="gf-form max-width-30">
<div class="p-t-2"> <span class="gf-form-label width-10">Name</span>
<div class="gf-form-inline"> <input type="text" required ng-model="ctrl.name" class="gf-form-input max-width-22" give-focus="true">
<div class="gf-form max-width-21"> </div>
<input type="text" class="gf-form-input" ng-model='ctrl.teamName' required give-focus="true" placeholder="Enter Team Name"></input> <div class="gf-form max-width-30">
</div> <span class="gf-form-label width-10">
<div class="gf-form"> Email
<button class="btn gf-form-btn btn-success" ng-click="ctrl.createTeam();ctrl.dismiss();">Create</button> <info-popover mode="right-normal">
</div> This is optional and is primarily used for allowing custom team avatars.
</div> </info-popover>
</div> </span>
</form> <input class="gf-form-input max-width-22" type="email" ng-model="ctrl.email" placeholder="email@test.com">
</div> </div>
<div class="gf-form-button-row">
<button type="submit" class="btn btn-success width-12">
<i class="fa fa-save"></i> Create
</button>
</div>
</form>
</div> </div>
...@@ -3,13 +3,22 @@ ...@@ -3,13 +3,22 @@
<div class="page-container page-body"> <div class="page-container page-body">
<h3 class="page-sub-heading">Team Details</h3> <h3 class="page-sub-heading">Team Details</h3>
<form name="teamDetailsForm" class="gf-form-group gf-form-inline"> <form name="teamDetailsForm" class="gf-form-group">
<div class="gf-form"> <div class="gf-form max-width-30">
<span class="gf-form-label width-10">Name</span> <span class="gf-form-label width-10">Name</span>
<input type="text" required ng-model="ctrl.team.name" class="gf-form-input max-width-14"> <input type="text" required ng-model="ctrl.team.name" class="gf-form-input max-width-22">
</div> </div>
<div class="gf-form max-width-30">
<span class="gf-form-label width-10">
Email
<info-popover mode="right-normal">
This is optional and is primarily used for allowing custom team avatars.
</info-popover>
</span>
<input class="gf-form-input max-width-22" type="email" ng-model="ctrl.team.email" placeholder="email@test.com">
</div>
<div class="gf-form"> <div class="gf-form-button-row">
<button type="submit" class="btn btn-success" ng-click="ctrl.update()">Update</button> <button type="submit" class="btn btn-success" ng-click="ctrl.update()">Update</button>
</div> </div>
</form> </form>
...@@ -28,12 +37,16 @@ ...@@ -28,12 +37,16 @@
<table class="filter-table" ng-show="ctrl.teamMembers.length > 0"> <table class="filter-table" ng-show="ctrl.teamMembers.length > 0">
<thead> <thead>
<tr> <tr>
<th></th>
<th>Username</th> <th>Username</th>
<th>Email</th> <th>Email</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tr ng-repeat="member in ctrl.teamMembers"> <tr ng-repeat="member in ctrl.teamMembers">
<td class="width-4 text-center link-td">
<img class="filter-table__avatar" ng-src="{{member.avatarUrl}}"></img>
</td>
<td>{{member.login}}</td> <td>{{member.login}}</td>
<td>{{member.email}}</td> <td>{{member.email}}</td>
<td style="width: 1%"> <td style="width: 1%">
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
</label> </label>
<div class="page-action-bar__spacer"></div> <div class="page-action-bar__spacer"></div>
<a class="btn btn-success" ng-click="ctrl.openTeamModal()"> <a class="btn btn-success" href="/org/teams/new">
<i class="fa fa-plus"></i> <i class="fa fa-plus"></i>
Add Team Add Team
</a> </a>
...@@ -18,17 +18,27 @@ ...@@ -18,17 +18,27 @@
<table class="filter-table filter-table--hover form-inline" ng-show="ctrl.teams.length > 0"> <table class="filter-table filter-table--hover form-inline" ng-show="ctrl.teams.length > 0">
<thead> <thead>
<tr> <tr>
<th></th>
<th>Name</th> <th>Name</th>
<th>Email</th>
<th>Members</th> <th>Members</th>
<th style="width: 1%"></th> <th style="width: 1%"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr ng-repeat="team in ctrl.teams"> <tr ng-repeat="team in ctrl.teams">
<td class="width-4 text-center link-td">
<a href="org/teams/edit/{{team.id}}">
<img class="filter-table__avatar" ng-src="{{team.avatarUrl}}"></img>
</a>
</td>
<td class="link-td"> <td class="link-td">
<a href="org/teams/edit/{{team.id}}">{{team.name}}</a> <a href="org/teams/edit/{{team.id}}">{{team.name}}</a>
</td> </td>
<td class="link-td"> <td class="link-td">
<a href="org/teams/edit/{{team.id}}">{{team.email}}</a>
</td>
<td class="link-td">
<a href="org/teams/edit/{{team.id}}">{{team.memberCount}}</a> <a href="org/teams/edit/{{team.id}}">{{team.memberCount}}</a>
</td> </td>
<td class="text-right"> <td class="text-right">
......
...@@ -55,7 +55,10 @@ export default class TeamDetailsCtrl { ...@@ -55,7 +55,10 @@ export default class TeamDetailsCtrl {
return; return;
} }
this.backendSrv.put('/api/teams/' + this.team.id, { name: this.team.name }); this.backendSrv.put('/api/teams/' + this.team.id, {
name: this.team.name,
email: this.team.email,
});
} }
userPicked(user) { userPicked(user) {
...@@ -71,6 +74,7 @@ export default class TeamDetailsCtrl { ...@@ -71,6 +74,7 @@ export default class TeamDetailsCtrl {
export interface Team { export interface Team {
id: number; id: number;
name: string; name: string;
email: string;
} }
export interface User { export interface User {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment