Redesign the teams page to use a table

Allows for faster loading and easier viewing of important information about teams
This commit is contained in:
Joseph Schorr 2016-08-18 17:44:36 -04:00
parent 98206310bd
commit 6ebb417923
11 changed files with 194 additions and 168 deletions

View file

@ -1,7 +1,9 @@
from data.database import Team, TeamMember, TeamRole, User, TeamMemberInvite from data.database import Team, TeamMember, TeamRole, User, TeamMemberInvite, RepositoryPermission
from data.model import (DataModelException, InvalidTeamException, UserAlreadyInTeam, from data.model import (DataModelException, InvalidTeamException, UserAlreadyInTeam,
InvalidTeamMemberException, user, _basequery) InvalidTeamMemberException, user, _basequery)
from util.validation import validate_username from util.validation import validate_username
from peewee import fn, JOIN_LEFT_OUTER
from util.morecollections import AttrDict
def create_team(name, org_obj, team_role_name, description=''): def create_team(name, org_obj, team_role_name, description=''):
@ -186,7 +188,32 @@ def get_matching_teams(team_prefix, organization):
def get_teams_within_org(organization): def get_teams_within_org(organization):
return Team.select().where(Team.organization == organization) """ Returns a AttrDict of team info (id, name, description), its role under the org,
the number of repositories on which it has permission, and the number of members.
"""
query = (Team.select(Team.id, Team.name, Team.description, TeamRole.name,
fn.Count(RepositoryPermission.id), fn.Count(TeamMember.id))
.where(Team.organization == organization)
.join(TeamRole)
.switch(Team)
.join(RepositoryPermission, JOIN_LEFT_OUTER)
.switch(Team)
.join(TeamMember, JOIN_LEFT_OUTER)
.group_by(Team.id)
.tuples())
def _team_view(team_tuple):
return AttrDict({
'id': team_tuple[0],
'name': team_tuple[1],
'description': team_tuple[2],
'role_name': team_tuple[3],
'repo_count': team_tuple[4],
'member_count': team_tuple[5],
})
return [_team_view(team_tuple) for team_tuple in query]
def get_user_teams_within_org(username, organization): def get_user_teams_within_org(username, organization):

View file

@ -8,13 +8,12 @@ import features
from app import billing as stripe, avatar from app import billing as stripe, avatar
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error, from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error,
related_user_resource, internal_only, require_user_admin, log_action, related_user_resource, internal_only, require_user_admin, log_action,
show_if, path_param, require_scope) show_if, path_param, require_scope)
from endpoints.exception import Unauthorized, NotFound from endpoints.exception import Unauthorized, NotFound
from endpoints.api.team import team_view
from endpoints.api.user import User, PrivateRepositories from endpoints.api.user import User, PrivateRepositories
from auth.permissions import (AdministerOrganizationPermission, OrganizationMemberPermission, from auth.permissions import (AdministerOrganizationPermission, OrganizationMemberPermission,
CreateRepositoryPermission) CreateRepositoryPermission, ViewTeamPermission)
from auth.auth_context import get_authenticated_user from auth.auth_context import get_authenticated_user
from auth import scopes from auth import scopes
from data import model from data import model
@ -24,6 +23,18 @@ from data.billing import get_plan
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def team_view(orgname, team):
return {
'name': team.name,
'description': team.description,
'role': team.role_name,
'avatar': avatar.get_data_for_team(team),
'can_view': ViewTeamPermission(orgname, team.name).can(),
'repo_count': team.repo_count,
'member_count': team.member_count,
}
def org_view(o, teams): def org_view(o, teams):
is_admin = AdministerOrganizationPermission(o.username).can() is_admin = AdministerOrganizationPermission(o.username).can()

View file

@ -15,39 +15,3 @@
margin-left: 4px; margin-left: 4px;
margin-right: 4px; margin-right: 4px;
} }
.create-entity-dialog-element label {
margin-top: 4px;
}
.create-entity-dialog-element .co-table {
margin-top: 20px;
}
.create-entity-dialog-element .fa-hdd-o {
margin-right: 4px;
vertical-align: middle;
}
.create-entity-dialog-element .co-filter-box {
display: block;
float: right;
margin-bottom: 20px;
}
.create-entity-dialog-element .co-filter-box .filter-message {
left: -180px;
top: 4px;
}
.create-entity-dialog-element .co-filter-box input {
width: 100%;
padding-top: 2px;
padding-bottom: 2px;
height: 28px;
}
.create-entity-dialog-element label .avatar {
vertical-align: text-bottom;
margin-left: 4px;
}

View file

@ -1,5 +1,19 @@
.teams-manager .popup-input-button { .teams-manager .co-filter-box {
display: block;
float: right; float: right;
margin-bottom: 20px;
}
.teams-manager.co-filter-box .filter-message {
left: -180px;
top: 4px;
}
.teams-manager .co-filter-box input {
width: 100%;
padding-top: 2px;
padding-bottom: 2px;
height: 28px;
} }
.teams-manager .manager-header { .teams-manager .manager-header {
@ -20,6 +34,10 @@
color: #ccc; color: #ccc;
} }
.teams-manager .co-table .avatar {
margin-right: 6px;
}
.teams-manager .cor-confirm-dialog .entity-reference .avatar { .teams-manager .cor-confirm-dialog .entity-reference .avatar {
margin-left: 4px; margin-left: 4px;
margin-right: 0px; margin-right: 0px;
@ -36,14 +54,20 @@
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.teams-manager .control-col { .teams-manager .co-filter-box {
padding-left: 55px; display: block;
padding-bottom: 10px; float: none;
} }
} }
.teams-manager .header-col .info-icon { .teams-manager .info-icon {
margin-left: 4px;
}
.teams-manager .popover-content {
color: black;
font-size: 16px; font-size: 16px;
text-transform: none;
} }
.teams-manager .header-col .header-text { .teams-manager .header-col .header-text {

View file

@ -634,13 +634,6 @@ i.toggle-icon:hover {
padding: 6px; padding: 6px;
} }
.info-icon {
display: inline-block;
float: right;
vertical-align: middle;
font-size: 20px;
}
.accordion-toggle { .accordion-toggle {
cursor: pointer; cursor: pointer;
text-decoration: none !important; text-decoration: none !important;

View file

@ -1,4 +1,4 @@
<div class="add-repo-permissions-entity"> <div class="add-repo-permissions-element">
<span class="co-filter-box"> <span class="co-filter-box">
<span class="filter-message" ng-if="options.filter"> <span class="filter-message" ng-if="options.filter">
Showing {{ orderedRepositories.entries.length }} of {{ repositories.length }} repositories Showing {{ orderedRepositories.entries.length }} of {{ repositories.length }} repositories
@ -8,11 +8,11 @@
<label> <label>
Select repositories in Select repositories in
<span class="avatar" size="16" data="namespace.avatar"></span> <span class="avatar" size="16" data="namespaceInfo.avatar"></span>
{{ namespace }}: {{ namespace }}:
</label> </label>
<table class="co-table" style="margin-bottom: 210px;"> <table class="co-table">
<thead> <thead>
<td class="checkbox-col checkbox-menu-col"> <td class="checkbox-col checkbox-menu-col">
<span class="cor-checkable-menu" controller="checkedRepos"> <span class="cor-checkable-menu" controller="checkedRepos">

View file

@ -23,7 +23,7 @@
has-checked-repositories="context.hasCheckedRepositories" has-checked-repositories="context.hasCheckedRepositories"
repositories-loaded="repositoriesLoaded(repositories)" repositories-loaded="repositoriesLoaded(repositories)"
adding-permissions="addingPermissions()" adding-permissions="addingPermissions()"
permissions-added="permissionsAdded()" permissions-added="permissionsAdded(repositories)"
add-permissions="context.addPermissionsCounter" add-permissions="context.addPermissionsCounter"
ng-if="entity"></div> ng-if="entity"></div>
</div> </div>

View file

@ -1,6 +1,14 @@
<div class="teams-manager-element"> <div class="teams-manager-element">
<div class="feedback-bar" feedback="feedback"></div> <div class="feedback-bar" feedback="feedback"></div>
<div class="manager-header" header-title="Teams and Membership"> <div class="manager-header" header-title="Teams and Membership">
<div class="tab-header-controls visible-xs">
<button class="btn btn-primary"
ng-show="organization.is_admin"
ng-click="askCreateTeam()">
<i class="fa fa-plus" style="margin-right: 4px;"></i> Create New Team
</button>
</div>
<div class="tab-header-controls hidden-xs"> <div class="tab-header-controls hidden-xs">
<div class="btn-group btn-group-sm" ng-show="organization.is_admin"> <div class="btn-group btn-group-sm" ng-show="organization.is_admin">
<button class="btn" <button class="btn"
@ -17,66 +25,69 @@
<!-- Teams List --> <!-- Teams List -->
<div ng-show="!showingMembers"> <div ng-show="!showingMembers">
<div class="row" style="margin-left: 0px; margin-right: 0px;"> <button class="btn btn-primary hidden-xs"
<button class="btn btn-primary hidden-xs" ng-show="organization.is_admin"
ng-show="organization.is_admin" style="margin-bottom: 10px; "
style="margin-bottom: 10px; float: right;" ng-click="askCreateTeam()">
ng-click="askCreateTeam()"> <i class="fa fa-plus" style="margin-right: 4px;"></i> Create New Team
<i class="fa fa-plus" style="margin-right: 4px;"></i> Create New Team </button>
</button>
</div>
<div class="row hidden-xs"> <span class="co-filter-box">
<div class="col-sm-7 col-md-8 header-col"> <span class="filter-message" ng-if="options.filter">
<span class="header-text">Team Summary</span> Showing {{ orderedTeams.entries.length }} of {{ teams.length }} teams
</div> </span>
<div class="col-md-4 col-sm-5 header-col" ng-show="organization.is_admin"> <input class="form-control" type="text" ng-model="options.filter" placeholder="Filter Teams...">
<span class="header-text">Team Permissions</span> </span>
<i class="info-icon fa fa-info-circle" data-placement="bottom" data-original-title="" data-title=""
data-content="Global permissions for the team and its members<br><br><dl><dt>Member</dt><dd>Permissions are assigned on a per repository basis</dd><dt>Creator</dt><dd>A team can create its own repositories</dd><dt>Admin</dt><dd>A team has full control of the organization</dd></dl>"
data-html="true"
data-trigger="hover"
bs-popover></i>
</div>
</div>
<div class="team-listing" ng-repeat="team in orderedTeams"> <table class="co-table" style="margin-top: 10px;">
<div id="team-{{team.name}}" class="row"> <thead>
<div class="col-sm-7 col-md-8"> <td ng-class="TableService.tablePredicateClass('name', options.predicate, options.reverse)">
<div class="team-title"> <a ng-click="TableService.orderBy('name', options)">Team Name</a>
<span class="avatar" data="team.avatar" size="30"></span> </td>
<span ng-show="team.can_view"> <td ng-class="TableService.tablePredicateClass('member_count', options.predicate, options.reverse)">
<a href="/organization/{{ organization.name }}/teams/{{ team.name }}">{{ team.name }}</a> <a ng-click="TableService.orderBy('member_count', options)">Members</a>
</span> </td>
<span ng-show="!team.can_view"> <td class="hidden-xs" ng-class="TableService.tablePredicateClass('repo_count', options.predicate, options.reverse)">
{{ team.name }} <a ng-click="TableService.orderBy('repo_count', options)">Repositories</a>
</span> </td>
</div> <td ng-class="TableService.tablePredicateClass('role_index', options.predicate, options.reverse)">
<a ng-click="TableService.orderBy('role_index', options)">Team Role</a>
<div class="team-description markdown-view" content="team.description" first-line-only="true"></div> <i class="info-icon fa fa-info-circle" data-placement="bottom" data-original-title="" data-title=""
data-content="Global permissions for the team and its members<br><br><dl><dt>Member</dt><dd>Permissions are assigned on a per repository basis</dd><dt>Creator</dt><dd>A team can create its own repositories</dd><dt>Admin</dt><dd>A team has full control of the organization</dd></dl>"
data-html="true"
data-trigger="hover"
bs-popover></i>
</td>
<td class="options-col"></td>
</thead>
<div class="team-member-list hidden-xs" ng-if="members[team.name]"> <tr class="co-checkable-row"
<div class="cor-loader" ng-if="!members[team.name].members"></div> ng-repeat="team in orderedTeams.visibleEntries"
<span class="team-member" bindonce>
ng-repeat="member in members[team.name].members | orderBy:'is_robot' | limitTo: 20"> <td style="white-space: nowrap;">
<span data-title="{{ member.name }}" bs-tooltip> <span class="avatar" data="team.avatar" size="24"></span>
<a href="/user/{{ member.name }}" ng-if="!member.is_robot"> <span bo-show="team.can_view">
<span class="avatar" data="member.avatar" size="26"></span> <a href="/organization/{{ organization.name }}/teams/{{ team.name }}"><span bo-text="team.name"></span></a>
</a> </span>
<i class="fa ci-robot fa-lg" ng-if="member.is_robot"></i> <span bo-show="!team.can_view" bo-text="team.name"></span>
</span> </td>
</span> <td>
<span class="team-member-more" <span bo-show="team.can_view">
ng-if="members[team.name].members.length > 20">+ {{ members[team.name].members.length - 20 }} more team members.</span> <a href="/organization/{{ organization.name }}/teams/{{ team.name }}"><span bo-text="team.member_count"></span> <span class="hidden-xs">member<span bo-if="team.member_count != 1">s</span></span></a>
<span class="team-member-more" </span>
ng-if="members[team.name].members && !members[team.name].members.length">(Empty Team)</span> <span bo-show="!team.can_view">
</div> <span bo-text="team.member_count"></span> <span class="hidden-xs">member<span bo-if="team.member_count != 1">s</span></span>
</div> </span>
</td>
<div class="col-sm-5 col-md-4 control-col" ng-show="organization.is_admin"> <td class="hidden-xs">
<span bo-text="team.repo_count"></span> repositories
</td>
<td>
<span class="role-group" current-role="team.role" pull-left="true" <span class="role-group" current-role="team.role" pull-left="true"
role-changed="setRole(role, team.name)" roles="teamRoles"></span> role-changed="setRole(role, team.name)" roles="teamRoles"></span>
</td>
<td>
<span class="cor-options-menu"> <span class="cor-options-menu">
<span class="cor-option" option-click="viewTeam(team.name)"> <span class="cor-option" option-click="viewTeam(team.name)">
<i class="fa fa-user"></i> Manage Team Members <i class="fa fa-user"></i> Manage Team Members
@ -85,8 +96,14 @@
<i class="fa fa-times"></i> Delete Team {{ team.name }} <i class="fa fa-times"></i> Delete Team {{ team.name }}
</span> </span>
</span> </span>
</div> </td>
</div> </tr>
</table>
<div class="empty" ng-if="!orderedTeams.entries.length"
style="margin-top: 20px;">
<div class="empty-primary-msg">No matching teams found.</div>
<div class="empty-secondary-msg">Try expanding your filtering terms.</div>
</div> </div>
</div> </div>

View file

@ -25,7 +25,7 @@ angular.module('quay').directive('addRepoPermissions', function () {
'permissionsAdded': '&permissionsAdded', 'permissionsAdded': '&permissionsAdded',
}, },
controller: function($scope, $element, ApiService, UIService, TableService, RolesService) { controller: function($scope, $element, ApiService, UIService, TableService, RolesService, UserService) {
$scope.TableService = TableService; $scope.TableService = TableService;
$scope.options = { $scope.options = {
@ -71,6 +71,8 @@ angular.module('quay').directive('addRepoPermissions', function () {
return; return;
} }
$scope.namespaceInfo = UserService.getNamespace($scope.namespace);
// Load the repositories under the entity's namespace. // Load the repositories under the entity's namespace.
var params = { var params = {
'namespace': $scope.namespace, 'namespace': $scope.namespace,
@ -127,7 +129,7 @@ angular.module('quay').directive('addRepoPermissions', function () {
var addPerm = function() { var addPerm = function() {
if (counter >= repos.length) { if (counter >= repos.length) {
$scope.permissionsAdded(); $scope.permissionsAdded({'repositories': repos});
return; return;
} }

View file

@ -71,7 +71,8 @@ angular.module('quay').directive('createEntityDialog', function () {
}); });
}; };
$scope.permissionsAdded = function() { $scope.permissionsAdded = function(repositories) {
$scope.entity['repo_count'] = repositories.length;
$scope.hide(); $scope.hide();
}; };

View file

@ -12,7 +12,15 @@ angular.module('quay').directive('teamsManager', function () {
'organization': '=organization', 'organization': '=organization',
'isEnabled': '=isEnabled' 'isEnabled': '=isEnabled'
}, },
controller: function($scope, $element, ApiService, $timeout, UserService) { controller: function($scope, $element, ApiService, $timeout, UserService, TableService, UIService) {
$scope.TableService = TableService;
$scope.options = {
'predicate': 'ordered_team_index',
'reverse': false,
'filter': ''
};
$scope.teamRoles = [ $scope.teamRoles = [
{ 'id': 'member', 'title': 'Member', 'kind': 'default' }, { 'id': 'member', 'title': 'Member', 'kind': 'default' },
{ 'id': 'creator', 'title': 'Creator', 'kind': 'success' }, { 'id': 'creator', 'title': 'Creator', 'kind': 'success' },
@ -21,65 +29,42 @@ angular.module('quay').directive('teamsManager', function () {
UserService.updateUserIn($scope); UserService.updateUserIn($scope);
$scope.members = {}; $scope.teams = null;
$scope.orderedTeams = []; $scope.orderedTeams = null;
$scope.showingMembers = false; $scope.showingMembers = false;
$scope.fullMemberList = null; $scope.fullMemberList = null;
$scope.feedback = null; $scope.feedback = null;
$scope.createTeamInfo = null; $scope.createTeamInfo = null;
var loadTeamMembers = function() { var getRoleIndex = function(name) {
if (!$scope.organization || !$scope.isEnabled) { return; } for (var i = 0; i < $scope.teamRoles.length; ++i) {
if ($scope.teamRoles[i]['id'] == name) {
// Skip loading team members on mobile. return i;
if (!window.matchMedia('(min-width: 768px)').matches) { }
return;
} }
for (var name in $scope.organization.teams) { return -1;
if (!$scope.organization.teams.hasOwnProperty(name) || $scope.members[name]) { continue; }
// Load fully async to prevent it from blocking the UI.
(function(teamname) {
$timeout(function() {
loadMembersOfTeam(teamname);
}, 1);
})(name);
}
}; };
var loadMembersOfTeam = function(name) { var setTeamsState = function() {
var params = {
'orgname': $scope.organization.name,
'teamname': name
};
$scope.members[name] = {};
ApiService.getOrganizationTeamMembers(null, params).then(function(resp) {
$scope.members[name].members = resp.members;
}, function() {
delete $scope.members[name];
});
};
var loadOrderedTeams = function() {
if (!$scope.organization || !$scope.organization.ordered_teams || !$scope.isEnabled) { if (!$scope.organization || !$scope.organization.ordered_teams || !$scope.isEnabled) {
return; return;
} }
$scope.orderedTeams = []; $scope.teams = [];
$scope.organization.ordered_teams.map(function(name) { $scope.organization.ordered_teams.map(function(name, index) {
$scope.orderedTeams.push($scope.organization.teams[name]); var team = $scope.organization.teams[name];
team['ordered_team_index'] = $scope.organization.ordered_teams.length - index;
team['role_index'] = getRoleIndex(team['role']);
$scope.teams.push(team);
}); });
$scope.orderedTeams = TableService.buildOrderedItems(
$scope.teams, $scope.options,
['name'],
['ordered_team_index', 'member_count', 'repo_count', 'role_index']);
}; };
$scope.$watch('organization', loadOrderedTeams);
$scope.$watch('organization', loadTeamMembers);
$scope.$watch('isEnabled', loadOrderedTeams);
$scope.$watch('isEnabled', loadTeamMembers);
$scope.setRole = function(role, teamname) { $scope.setRole = function(role, teamname) {
var previousRole = $scope.organization.teams[teamname].role; var previousRole = $scope.organization.teams[teamname].role;
$scope.organization.teams[teamname].role = role; $scope.organization.teams[teamname].role = role;
@ -115,9 +100,9 @@ angular.module('quay').directive('teamsManager', function () {
$scope.handleTeamCreated = function(created) { $scope.handleTeamCreated = function(created) {
var teamname = created.name; var teamname = created.name;
created['member_count'] = 0;
$scope.organization.teams[teamname] = created; $scope.organization.teams[teamname] = created;
$scope.members[teamname] = {};
$scope.members[teamname].members = [];
$scope.organization.ordered_teams.push(teamname); $scope.organization.ordered_teams.push(teamname);
$scope.orderedTeams.push(created); $scope.orderedTeams.push(created);
@ -150,8 +135,8 @@ angular.module('quay').directive('teamsManager', function () {
$scope.organization.ordered_teams.splice(index, 1); $scope.organization.ordered_teams.splice(index, 1);
} }
loadOrderedTeams();
delete $scope.organization.teams[teamname]; delete $scope.organization.teams[teamname];
setTeamsState();
$scope.feedback = { $scope.feedback = {
'kind': 'success', 'kind': 'success',
@ -192,12 +177,7 @@ angular.module('quay').directive('teamsManager', function () {
ApiService.removeOrganizationMember(null, params).then(function(resp) { ApiService.removeOrganizationMember(null, params).then(function(resp) {
// Reset the state of the directive. // Reset the state of the directive.
$scope.members = {};
$scope.orderedTeams = [];
$scope.fullMemberList = null; $scope.fullMemberList = null;
loadOrderedTeams();
loadTeamMembers();
$scope.showMembers(true); $scope.showMembers(true);
callback(true); callback(true);
@ -215,6 +195,13 @@ angular.module('quay').directive('teamsManager', function () {
$scope.askRemoveMember = function(memberInfo) { $scope.askRemoveMember = function(memberInfo) {
$scope.removeMemberInfo = $.extend({}, memberInfo); $scope.removeMemberInfo = $.extend({}, memberInfo);
}; };
$scope.$watch('organization', setTeamsState);
$scope.$watch('isEnabled', setTeamsState);
$scope.$watch('options.predicate', setTeamsState);
$scope.$watch('options.reverse', setTeamsState);
$scope.$watch('options.filter', setTeamsState);
} }
}; };