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,
InvalidTeamMemberException, user, _basequery)
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=''):
@ -186,7 +188,32 @@ def get_matching_teams(team_prefix, 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):

View file

@ -8,13 +8,12 @@ import features
from app import billing as stripe, avatar
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)
from endpoints.exception import Unauthorized, NotFound
from endpoints.api.team import team_view
from endpoints.api.user import User, PrivateRepositories
from auth.permissions import (AdministerOrganizationPermission, OrganizationMemberPermission,
CreateRepositoryPermission)
CreateRepositoryPermission, ViewTeamPermission)
from auth.auth_context import get_authenticated_user
from auth import scopes
from data import model
@ -24,6 +23,18 @@ from data.billing import get_plan
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):
is_admin = AdministerOrganizationPermission(o.username).can()

View file

@ -15,39 +15,3 @@
margin-left: 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;
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 {
@ -20,6 +34,10 @@
color: #ccc;
}
.teams-manager .co-table .avatar {
margin-right: 6px;
}
.teams-manager .cor-confirm-dialog .entity-reference .avatar {
margin-left: 4px;
margin-right: 0px;
@ -36,14 +54,20 @@
}
@media (max-width: 767px) {
.teams-manager .control-col {
padding-left: 55px;
padding-bottom: 10px;
.teams-manager .co-filter-box {
display: block;
float: none;
}
}
.teams-manager .header-col .info-icon {
.teams-manager .info-icon {
margin-left: 4px;
}
.teams-manager .popover-content {
color: black;
font-size: 16px;
text-transform: none;
}
.teams-manager .header-col .header-text {

View file

@ -634,13 +634,6 @@ i.toggle-icon:hover {
padding: 6px;
}
.info-icon {
display: inline-block;
float: right;
vertical-align: middle;
font-size: 20px;
}
.accordion-toggle {
cursor: pointer;
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="filter-message" ng-if="options.filter">
Showing {{ orderedRepositories.entries.length }} of {{ repositories.length }} repositories
@ -8,11 +8,11 @@
<label>
Select repositories in
<span class="avatar" size="16" data="namespace.avatar"></span>
<span class="avatar" size="16" data="namespaceInfo.avatar"></span>
{{ namespace }}:
</label>
<table class="co-table" style="margin-bottom: 210px;">
<table class="co-table">
<thead>
<td class="checkbox-col checkbox-menu-col">
<span class="cor-checkable-menu" controller="checkedRepos">

View file

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

View file

@ -1,6 +1,14 @@
<div class="teams-manager-element">
<div class="feedback-bar" feedback="feedback"></div>
<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="btn-group btn-group-sm" ng-show="organization.is_admin">
<button class="btn"
@ -17,66 +25,69 @@
<!-- Teams List -->
<div ng-show="!showingMembers">
<div class="row" style="margin-left: 0px; margin-right: 0px;">
<button class="btn btn-primary hidden-xs"
ng-show="organization.is_admin"
style="margin-bottom: 10px; float: right;"
ng-click="askCreateTeam()">
<i class="fa fa-plus" style="margin-right: 4px;"></i> Create New Team
</button>
</div>
<button class="btn btn-primary hidden-xs"
ng-show="organization.is_admin"
style="margin-bottom: 10px; "
ng-click="askCreateTeam()">
<i class="fa fa-plus" style="margin-right: 4px;"></i> Create New Team
</button>
<div class="row hidden-xs">
<div class="col-sm-7 col-md-8 header-col">
<span class="header-text">Team Summary</span>
</div>
<div class="col-md-4 col-sm-5 header-col" ng-show="organization.is_admin">
<span class="header-text">Team Permissions</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>
<span class="co-filter-box">
<span class="filter-message" ng-if="options.filter">
Showing {{ orderedTeams.entries.length }} of {{ teams.length }} teams
</span>
<input class="form-control" type="text" ng-model="options.filter" placeholder="Filter Teams...">
</span>
<div class="team-listing" ng-repeat="team in orderedTeams">
<div id="team-{{team.name}}" class="row">
<div class="col-sm-7 col-md-8">
<div class="team-title">
<span class="avatar" data="team.avatar" size="30"></span>
<span ng-show="team.can_view">
<a href="/organization/{{ organization.name }}/teams/{{ team.name }}">{{ team.name }}</a>
</span>
<span ng-show="!team.can_view">
{{ team.name }}
</span>
</div>
<table class="co-table" style="margin-top: 10px;">
<thead>
<td ng-class="TableService.tablePredicateClass('name', options.predicate, options.reverse)">
<a ng-click="TableService.orderBy('name', options)">Team Name</a>
</td>
<td ng-class="TableService.tablePredicateClass('member_count', options.predicate, options.reverse)">
<a ng-click="TableService.orderBy('member_count', options)">Members</a>
</td>
<td class="hidden-xs" ng-class="TableService.tablePredicateClass('repo_count', options.predicate, options.reverse)">
<a ng-click="TableService.orderBy('repo_count', options)">Repositories</a>
</td>
<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]">
<div class="cor-loader" ng-if="!members[team.name].members"></div>
<span class="team-member"
ng-repeat="member in members[team.name].members | orderBy:'is_robot' | limitTo: 20">
<span data-title="{{ member.name }}" bs-tooltip>
<a href="/user/{{ member.name }}" ng-if="!member.is_robot">
<span class="avatar" data="member.avatar" size="26"></span>
</a>
<i class="fa ci-robot fa-lg" ng-if="member.is_robot"></i>
</span>
</span>
<span class="team-member-more"
ng-if="members[team.name].members.length > 20">+ {{ members[team.name].members.length - 20 }} more team members.</span>
<span class="team-member-more"
ng-if="members[team.name].members && !members[team.name].members.length">(Empty Team)</span>
</div>
</div>
<div class="col-sm-5 col-md-4 control-col" ng-show="organization.is_admin">
<tr class="co-checkable-row"
ng-repeat="team in orderedTeams.visibleEntries"
bindonce>
<td style="white-space: nowrap;">
<span class="avatar" data="team.avatar" size="24"></span>
<span bo-show="team.can_view">
<a href="/organization/{{ organization.name }}/teams/{{ team.name }}"><span bo-text="team.name"></span></a>
</span>
<span bo-show="!team.can_view" bo-text="team.name"></span>
</td>
<td>
<span bo-show="team.can_view">
<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>
<span bo-show="!team.can_view">
<span bo-text="team.member_count"></span> <span class="hidden-xs">member<span bo-if="team.member_count != 1">s</span></span>
</span>
</td>
<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"
role-changed="setRole(role, team.name)" roles="teamRoles"></span>
</td>
<td>
<span class="cor-options-menu">
<span class="cor-option" option-click="viewTeam(team.name)">
<i class="fa fa-user"></i> Manage Team Members
@ -85,8 +96,14 @@
<i class="fa fa-times"></i> Delete Team {{ team.name }}
</span>
</span>
</div>
</div>
</td>
</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>

View file

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

View file

@ -12,7 +12,15 @@ angular.module('quay').directive('teamsManager', function () {
'organization': '=organization',
'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 = [
{ 'id': 'member', 'title': 'Member', 'kind': 'default' },
{ 'id': 'creator', 'title': 'Creator', 'kind': 'success' },
@ -21,65 +29,42 @@ angular.module('quay').directive('teamsManager', function () {
UserService.updateUserIn($scope);
$scope.members = {};
$scope.orderedTeams = [];
$scope.teams = null;
$scope.orderedTeams = null;
$scope.showingMembers = false;
$scope.fullMemberList = null;
$scope.feedback = null;
$scope.createTeamInfo = null;
var loadTeamMembers = function() {
if (!$scope.organization || !$scope.isEnabled) { return; }
// Skip loading team members on mobile.
if (!window.matchMedia('(min-width: 768px)').matches) {
return;
var getRoleIndex = function(name) {
for (var i = 0; i < $scope.teamRoles.length; ++i) {
if ($scope.teamRoles[i]['id'] == name) {
return i;
}
}
for (var name in $scope.organization.teams) {
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);
}
return -1;
};
var loadMembersOfTeam = function(name) {
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() {
var setTeamsState = function() {
if (!$scope.organization || !$scope.organization.ordered_teams || !$scope.isEnabled) {
return;
}
$scope.orderedTeams = [];
$scope.organization.ordered_teams.map(function(name) {
$scope.orderedTeams.push($scope.organization.teams[name]);
$scope.teams = [];
$scope.organization.ordered_teams.map(function(name, index) {
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) {
var previousRole = $scope.organization.teams[teamname].role;
$scope.organization.teams[teamname].role = role;
@ -115,9 +100,9 @@ angular.module('quay').directive('teamsManager', function () {
$scope.handleTeamCreated = function(created) {
var teamname = created.name;
created['member_count'] = 0;
$scope.organization.teams[teamname] = created;
$scope.members[teamname] = {};
$scope.members[teamname].members = [];
$scope.organization.ordered_teams.push(teamname);
$scope.orderedTeams.push(created);
@ -150,8 +135,8 @@ angular.module('quay').directive('teamsManager', function () {
$scope.organization.ordered_teams.splice(index, 1);
}
loadOrderedTeams();
delete $scope.organization.teams[teamname];
setTeamsState();
$scope.feedback = {
'kind': 'success',
@ -192,12 +177,7 @@ angular.module('quay').directive('teamsManager', function () {
ApiService.removeOrganizationMember(null, params).then(function(resp) {
// Reset the state of the directive.
$scope.members = {};
$scope.orderedTeams = [];
$scope.fullMemberList = null;
loadOrderedTeams();
loadTeamMembers();
$scope.showMembers(true);
callback(true);
@ -215,6 +195,13 @@ angular.module('quay').directive('teamsManager', function () {
$scope.askRemoveMember = function(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);
}
};