Group approval ui (#8184)

* Added all ui components back

* Added group ui items back and initial group approval directive

* Added ability to mark tasks as requires approval. Added approvals ctrl. Added get approvals method to tasks service

* Added approval list view with approving functionality

* Added error to produce message when task requests approval

* Added notification display for group approvals

* Fixed notification read and adding task

* Fixed syncing with group approval required

* Added group id to notifications for redirect on client side

* Fixed approval request tests

* Fixed linting issues

* Removed expectation from beforeEach

* Moved string to locale

* Added eslint ignore

* Updated notification for group approved, added new icons, and updated styles

* Hid group plan ui
This commit is contained in:
Keith Holliday
2016-11-12 16:47:45 -06:00
committed by Matteo Pagliazzi
parent 3ff7692528
commit 13df60e0dd
25 changed files with 218 additions and 43 deletions

View File

@@ -34,7 +34,12 @@ describe('GET /approvals/group/:groupId', () => {
let memberTasks = await member.get('/tasks/user');
syncedTask = find(memberTasks, findAssignedTask);
await member.post(`/tasks/${syncedTask._id}/score/up`);
try {
await member.post(`/tasks/${syncedTask._id}/score/up`);
} catch (e) {
// eslint-disable-next-line no-empty
}
});
it('errors when user is not the group leader', async () => {

View File

@@ -60,7 +60,7 @@ describe('POST /tasks/:id/approve/:userId', () => {
await member.sync();
expect(member.notifications.length).to.equal(1);
expect(member.notifications[0].type).to.equal('GROUP_TASK_APPROVAL');
expect(member.notifications[0].type).to.equal('GROUP_TASK_APPROVED');
expect(member.notifications[0].data.message).to.equal(t('yourTaskHasBeenApproved'));
expect(syncedTask.group.approval.approved).to.be.true;

View File

@@ -37,7 +37,12 @@ describe('POST /tasks/:id/score/:direction', () => {
let memberTasks = await member.get('/tasks/user');
let syncedTask = find(memberTasks, findAssignedTask);
let response = await member.post(`/tasks/${syncedTask._id}/score/up`);
await expect(member.post(`/tasks/${syncedTask._id}/score/up`))
.to.eventually.be.rejected.and.to.eql({
code: 401,
error: 'NotAuthorized',
message: t('taskApprovalHasBeenRequested'),
});
let updatedTask = await member.get(`/tasks/${syncedTask._id}`);
await user.sync();
@@ -48,8 +53,8 @@ describe('POST /tasks/:id/score/:direction', () => {
user: member.auth.local.username,
taskName: updatedTask.text,
}));
expect(user.notifications[0].data.groupId).to.equal(guild._id);
expect(response.message).to.equal(t('taskApprovalHasBeenRequested'));
expect(updatedTask.group.approval.requested).to.equal(true);
expect(updatedTask.group.approval.requestedDate).to.be.a('string'); // date gets converted to a string as json doesn't have a Date type
});
@@ -58,7 +63,12 @@ describe('POST /tasks/:id/score/:direction', () => {
let memberTasks = await member.get('/tasks/user');
let syncedTask = find(memberTasks, findAssignedTask);
await member.post(`/tasks/${syncedTask._id}/score/up`);
await expect(member.post(`/tasks/${syncedTask._id}/score/up`))
.to.eventually.be.rejected.and.to.eql({
code: 401,
error: 'NotAuthorized',
message: t('taskApprovalHasBeenRequested'),
});
await expect(member.post(`/tasks/${syncedTask._id}/score/up`))
.to.eventually.be.rejected.and.eql({

View File

@@ -107,6 +107,7 @@ describe('Group Task Methods', () => {
let updatedTaskName = 'Update Task name';
task.text = updatedTaskName;
task.group.approval.required = true;
await guild.updateTask(task);
@@ -121,10 +122,12 @@ describe('Group Task Methods', () => {
expect(task.group.assignedUsers).to.contain(leader._id);
expect(syncedTask).to.exist;
expect(syncedTask.text).to.equal(task.text);
expect(syncedTask.group.approval.required).to.equal(true);
expect(task.group.assignedUsers).to.contain(newMember._id);
expect(syncedMemberTask).to.exist;
expect(syncedMemberTask.text).to.equal(task.text);
expect(syncedMemberTask.group.approval.required).to.equal(true);
});
it('removes an assigned task and unlinks assignees', async () => {

View File

@@ -178,15 +178,15 @@ window.habitrpg = angular.module('habitrpg',
$scope.group.challenges = response.data.data;
});
//@TODO: Add this back when group tasks go live
// return Tasks.getGroupTasks($scope.group._id);
return Tasks.getGroupTasks($scope.group._id);
})
// .then(function (response) {
// var tasks = response.data.data;
// tasks.forEach(function (element, index, array) {
// if (!$scope.group[element.type + 's']) $scope.group[element.type + 's'] = [];
// $scope.group[element.type + 's'].push(element);
// })
// });
.then(function (response) {
var tasks = response.data.data;
tasks.forEach(function (element, index, array) {
if (!$scope.group[element.type + 's']) $scope.group[element.type + 's'] = [];
$scope.group[element.type + 's'].push(element);
})
});
}]
})

View File

@@ -0,0 +1,21 @@
habitrpg.controller('GroupApprovalsCtrl', ['$scope', 'Tasks',
function ($scope, Tasks) {
$scope.approvals = [];
Tasks.getGroupApprovals($scope.group._id)
.then(function (response) {
$scope.approvals = response.data.data;
});
$scope.approve = function (taskId, userId, $index) {
if (!confirm(env.t('confirmTaskApproval'))) return;
Tasks.approve(taskId, userId)
.then(function (response) {
$scope.approvals.splice($index, 1);
});
};
$scope.approvalTitle = function (approval) {
return env.t('approvalTitle', {text: approval.text, userName: approval.userId.profile.name});
};
}]);

View File

@@ -0,0 +1,21 @@
'use strict';
(function(){
angular
.module('habitrpg')
.directive('groupApprovals', groupApprovals);
groupApprovals.$inject = [
];
function groupApprovals() {
return {
scope: {
group: '=',
},
templateUrl: 'partials/groups.tasks.approvals.html',
controller: 'GroupApprovalsCtrl',
};
}
}());

View File

@@ -2,6 +2,11 @@ habitrpg.controller('GroupTaskActionsCtrl', ['$scope', 'Shared', 'Tasks', 'User'
function ($scope, Shared, Tasks, User) {
$scope.assignedMembers = [];
$scope.user = User.user;
$scope.task._edit.requiresApproval = false;
if ($scope.task.group.approval.required) {
$scope.task._edit.requiresApproval = $scope.task.group.approval.required;
}
$scope.$on('addedGroupMember', function(evt, userId) {
Tasks.assignTask($scope.task.id, userId);

View File

@@ -3,14 +3,16 @@ habitrpg.controller('GroupTasksCtrl', ['$scope', 'Shared', 'Tasks', 'User', func
$scope.toggleBulk = Tasks.toggleBulk;
$scope.cancelTaskEdit = Tasks.cancelTaskEdit;
function addTask (listDef, task) {
var task = Shared.taskDefaults({text: task, type: listDef.type});
//If the group has not been created, we bulk add tasks on save
var group = $scope.obj;
if (group._id) Tasks.createGroupTasks(group._id, task);
if (!group[task.type + 's']) group[task.type + 's'] = [];
group[task.type + 's'].unshift(task);
delete listDef.newTask;
function addTask (listDef, taskTexts) {
taskTexts.forEach(function (taskText) {
var task = Shared.taskDefaults({text: taskText, type: listDef.type});
//If the group has not been created, we bulk add tasks on save
var group = $scope.obj;
if (group._id) Tasks.createGroupTasks(group._id, task);
if (!group[task.type + 's']) group[task.type + 's'] = [];
group[task.type + 's'].unshift(task);
});
};
$scope.addTask = function(listDef) {

View File

@@ -1,15 +1,15 @@
'use strict';
angular.module('habitrpg')
.controller('MenuCtrl', ['$scope', '$rootScope', '$http', 'Chat', 'Content',
function($scope, $rootScope, $http, Chat, Content) {
.controller('MenuCtrl', ['$scope', '$rootScope', '$http', 'Chat', 'Content', 'User', '$state',
function($scope, $rootScope, $http, Chat, Content, User, $state) {
$scope.logout = function() {
localStorage.clear();
window.location.href = '/logout';
};
function selectNotificationValue(mysteryValue, invitationValue, cardValue, unallocatedValue, messageValue, noneValue) {
function selectNotificationValue(mysteryValue, invitationValue, cardValue, unallocatedValue, messageValue, noneValue, groupApprovalRequested, groupApproved) {
var user = $scope.user;
if (user.purchased && user.purchased.plan && user.purchased.plan.mysteryItems && user.purchased.plan.mysteryItems.length) {
return mysteryValue;
@@ -21,6 +21,14 @@ angular.module('habitrpg')
return unallocatedValue;
} else if (!(_.isEmpty(user.newMessages))) {
return messageValue;
} else if (!_.isEmpty(user.groupNotifications)) {
var groupNotificationTypes = _.pluck(user.groupNotifications, 'type');
if (groupNotificationTypes.indexOf('GROUP_TASK_APPROVAL') !== -1) {
return groupApprovalRequested;
} else if (groupNotificationTypes.indexOf('GROUP_TASK_APPROVED') !== -1) {
return groupApproved;
}
return noneValue;
} else {
return noneValue;
}
@@ -97,12 +105,28 @@ angular.module('habitrpg')
'glyphicon-envelope',
'glyphicon-plus-sign',
'glyphicon-comment',
'glyphicon-comment inactive'
'glyphicon-comment inactive',
'glyphicon-question-sign',
'glyphicon glyphicon-ok-sign'
);
};
$scope.hasNoNotifications = function() {
return selectNotificationValue(false, false, false, false, false, true);
}
return selectNotificationValue(false, false, false, false, false, true, false);
};
$scope.viewGroupApprovalNotification = function (notification, $index) {
User.readNotification(notification.id);
User.user.groupNotifications.splice($index, 1);
$state.go("options.social.guilds.detail", {gid: notification.data.groupId});
};
$scope.groupApprovalNotificationIcon = function (notification) {
if (notification.type === 'GROUP_TASK_APPROVAL') {
return 'glyphicon glyphicon-question-sign';
} else if (notification.type === 'GROUP_TASK_APPROVED') {
return 'glyphicon glyphicon-ok-sign';
}
};
}
]);

View File

@@ -74,6 +74,11 @@ habitrpg.controller('NotificationCtrl',
// Avoid showing the same notiication more than once
var lastShownNotifications = [];
function trasnferGroupNotification(notification) {
if (!User.user.groupNotifications) User.user.groupNotifications = [];
User.user.groupNotifications.push(notification);
}
function handleUserNotifications (after) {
if (!after || after.length === 0) return;
@@ -123,6 +128,14 @@ habitrpg.controller('NotificationCtrl',
if (notification.data.mp) Notification.mp(notification.data.mp);
}
break;
case 'GROUP_TASK_APPROVAL':
trasnferGroupNotification(notification);
markAsRead = false;
break;
case 'GROUP_TASK_APPROVED':
trasnferGroupNotification(notification);
markAsRead = false;
break;
default:
markAsRead = false; // If the notification is not implemented, skip it
break;

View File

@@ -106,6 +106,20 @@ angular.module('habitrpg')
});
};
function getGroupApprovals (groupId) {
return $http({
method: 'GET',
url: '/api/v3/approvals/group/' + groupId,
});
};
function approve (taskId, userId) {
return $http({
method: 'POST',
url: '/api/v3/tasks/' + taskId + '/approve/' + userId,
});
};
function getTask (taskId) {
return $http({
method: 'GET',
@@ -367,5 +381,8 @@ angular.module('habitrpg')
navigateChecklist: navigateChecklist,
checklistCompletion: checklistCompletion,
collapseChecklist: collapseChecklist,
getGroupApprovals: getGroupApprovals,
approve: approve,
};
}]);

View File

@@ -107,7 +107,17 @@
"js/controllers/sortableInventoryCtrl.js",
"js/controllers/tavernCtrl.js",
"js/controllers/tasksCtrl.js",
"js/controllers/userCtrl.js"
"js/controllers/userCtrl.js",
"js/components/groupTasks/groupTasksController.js",
"js/components/groupTasks/groupTasksDirective.js",
"js/components/groupTaskActions/groupTaskActionsController.js",
"js/components/groupTaskActions/groupTaskActionsDirective.js",
"js/components/groupMembersAutocomplete/groupMembersAutocompleteDirective.js",
"js/components/groupTaskMetaActions/groupTaskMetaActionsDirective.js",
"js/components/groupTaskMetaActions/groupTaskMetaActionsController.js",
"js/components/groupApprovals/groupApprovalsDirective.js",
"js/components/groupApprovals/groupApprovalsController.js"
],
"css": [
"bower_components/bootstrap/dist/css/bootstrap.css",
@@ -118,7 +128,11 @@
"bower_components/pnotify/jquery.pnotify.default.icons.css",
"css/habitrpg-shared.css",
"bower_components/bootstrap-tour/build/css/bootstrap-tour.css",
"fontello/css/fontelico.css"
"fontello/css/fontelico.css",
"bower_components/jquery-ui/themes/base/minified/jquery.ui.autocomplete.min.css",
"bower_components/jquery-ui/themes/base/minified/jquery.ui.menu.min.css",
"bower_components/jquery-ui/themes/ui-lightness/jquery-ui.min.css"
]
},
"static": {

View File

@@ -218,5 +218,8 @@
"claim": "Claim",
"onlyGroupLeaderCanManageSubscription": "Only the group leader can manage the group's subscription",
"yourTaskHasBeenApproved": "Your task has been approved",
"userHasRequestedTaskApproval": "<%= user %> has requested task approval for <%= taskName %>"
"userHasRequestedTaskApproval": "<%= user %> has requested task approval for <%= taskName %>",
"confirmTaskApproval": "Are you sure you want to approve this task?",
"approve": "Approve",
"approvalTitle": "<%= text %> for user: <%= userName %>"
}

View File

@@ -136,5 +136,7 @@
"perceptionExample": "Relating to work or financial tasks",
"constitutionExample": "Relating to health, wellness, and social interaction",
"taskRequiresApproval": "This task must be approved before you can complete it. Approval has already been requested",
"taskApprovalHasBeenRequested": "Approval has been requested"
"taskApprovalHasBeenRequested": "Approval has been requested",
"approvals": "Approvals",
"approvalRequired": "Approval Required"
}

View File

@@ -263,6 +263,10 @@ api.updateTask = {
// repeat is always among modifiedPaths because mongoose changes the other of the keys when using .toObject()
// see https://github.com/Automattic/mongoose/issues/2749
if (sanitizedObj.requiresApproval) {
task.group.approval.required = true;
}
let savedTask = await task.save();
if (group && task.group.id && task.group.assignedUsers.length > 0) {
@@ -331,11 +335,12 @@ api.scoreTask = {
user: user.profile.name,
taskName: task.text,
}),
groupId: group._id,
});
await Bluebird.all([groupLeader.save(), task.save()]);
return res.respond(200, {message: res.t('taskApprovalHasBeenRequested'), task});
throw new NotAuthorized(res.t('taskApprovalHasBeenRequested'));
}
let wasCompleted = task.completed;

View File

@@ -225,7 +225,10 @@ api.approveTask = {
task.group.approval.approvingUser = user._id;
task.group.approval.approved = true;
assignedUser.addNotification('GROUP_TASK_APPROVAL', {message: res.t('yourTaskHasBeenApproved')});
assignedUser.addNotification('GROUP_TASK_APPROVED', {
message: res.t('yourTaskHasBeenApproved'),
groupId: group._id,
});
await Bluebird.all([assignedUser.save(), task.save()]);
@@ -266,7 +269,9 @@ api.getGroupApprovals = {
'group.id': groupId,
'group.approval.approved': false,
'group.approval.requested': true,
}, 'userId group').exec();
}, 'userId group text')
.populate('userId', 'profile')
.exec();
res.respond(200, approvals);
},

View File

@@ -893,6 +893,8 @@ schema.methods.updateTask = async function updateTask (taskToSync) {
updateCmd.$set[key] = syncableAttributes[key];
}
updateCmd.$set['group.approval.required'] = taskToSync.group.approval.required;
let taskSchema = Tasks[taskToSync.type];
// Updating instead of loading and saving for performances, risks becoming a problem if we introduce more complexity in tasks
await taskSchema.update({

View File

@@ -13,6 +13,7 @@ const NOTIFICATION_TYPES = [
'NEW_CONTRIBUTOR_LEVEL',
'CRON',
'GROUP_TASK_APPROVAL',
'GROUP_TASK_APPROVED',
];
const Schema = mongoose.Schema;

View File

@@ -147,14 +147,16 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter
h3.popover-title {{group.leader.profile.name}}
.popover-content
markdown(text='group._editing ? groupCopy.leaderMessage : group.leaderMessage')
ul.options-menu(ng-init="groupPane = 'chat'", style="display:none;")
//- li
//- a(ng-click="groupPane = 'chat'")=env.t('chat')
//- li
//- a(ng-click="groupPane = 'tasks'", ng-if='group.purchased.active')=env.t('tasks')
//- li
//- a(ng-click="groupPane = 'subscription'", ng-show='group.leader._id === user._id')=env.t('subscription')
ul.options-menu(ng-init="groupPane = 'chat'", ng-hide="true")
//- li
//- a(ng-click="groupPane = 'chat'")=env.t('chat')
//- li
//- a(ng-click="groupPane = 'tasks'", ng-show='group.purchased.active')=env.t('tasks')
//- li
//- a(ng-click="groupPane = 'approvals'", ng-show='group.purchased.active && group.leader._id === user._id')=env.t('approvals')
//- li
//- a(ng-click="groupPane = 'subscription'", ng-show='group.leader._id === user._id')=env.t('subscription')
.tab-content
.tab-pane.active
@@ -170,6 +172,8 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter
group-tasks(ng-show="groupPane == 'tasks'")
group-approvals(ng-show="groupPane == 'approvals'", ng-if="group.leader._id === user._id", group="group")
//TODO: This can be a directive and the group/user can be an object passed via attribute
div(ng-show="groupPane == 'subscription'")
.col-md-12

View File

@@ -2,3 +2,9 @@ script(type='text/ng-template', id='partials/groups.tasks.actions.html')
div(ng-if="group.leader._id === user._id", class="col-md-12")
strong=env.t('assignTask')
group-members-autocomplete(ng-model="assignedMembers")
ul.priority-multiplier
li {{requiresApproval}}
button(type='button', ng-class='{active: task._edit.requiresApproval==true}',
ng-click='task._edit.requiresApproval = !task._edit.requiresApproval')
=env.t('approvalRequired')

View File

@@ -0,0 +1,6 @@
script(type='text/ng-template', id='partials/groups.tasks.approvals.html')
.panel-group(ng-repeat="approval in approvals")
.panel.panel-default
.panel-heading
span {{approvalTitle(approval)}}
a.btn.btn-sm.btn-success.pull-right(ng-click="approve(approval.group.taskId, approval.userId._id)")=env.t('approve')

View File

@@ -1,6 +1,7 @@
include ./group-tasks-actions
include ./group-tasks-meta-actions
include ./group-members-autocomplete
include ./group-tasks-approvals
script(type='text/ng-template', id='partials/groups.tasks.html')
habitrpg-tasks(main=false)

View File

@@ -218,6 +218,11 @@ nav.toolbar(ng-controller='MenuCtrl')
span {{v.name}}
a(ng-click='clearMessages(k)', popover=env.t('clear'),popover-placement='right',popover-trigger='mouseenter',popover-append-to-body='true')
span.glyphicon.glyphicon-remove-circle
li(ng-repeat='notification in user.groupNotifications')
a(ng-click='viewGroupApprovalNotification(notification, $index)', data-close-menu)
span(class="{{::groupApprovalNotificationIcon(notification)}}")
span
| {{notification.data.message}}
ul.toolbar-controls
li.toolbar-controls-button

View File

@@ -6,7 +6,7 @@ include ./task_view/mixins
script(id='templates/habitrpg-tasks.html', type="text/ng-template")
.tasks-lists.container-fluid
.row
.col-md-3.col-sm-6(ng-repeat='list in lists', ng-class='::{ "rewards-module": list.type==="reward", "new-row-md": list.type==="todo", "col-md-2": obj.type }')
.col-md-3.col-sm-6(ng-repeat='list in lists', ng-class='::{ "rewards-module": list.type==="reward", "new-row-md": list.type==="todo", "col-md-3": obj.type }')
.task-column(class='{{::list.type}}s')
include ./task_view/graph