Teams Updates 201908 (#11347)

* fix(teams): no hover bg change for noninteractive checkboxes

* feat(teams): send notification to managers on task claim
Also fix client unit test broken by prev commit

* feat(groups): don't penalize for tasks assigned since last activity

* fix(tests): actually fix client unit

* fix(teams): improve task styles

* fix(teams): let people other than leader see relevant approvals
Also more style fixes

* fix(approvals): better filtering and task headings for approval data

* fix(test): correct test expectations for new GET /approvals behavior

* fix(groups): style tweaks

* different border for group and normal tasks

* fix(teams): remove extra click for claiming

* fix(teams): leaders & managers can check off approval-required tasks

* fix(teams): don't notify user of own claim

* fix group task margin and z-index on hover

* fix(menu): sporadic error in top bar

* fix(teams): more approval header and footer adjustments

* fix(tests): adjust expectations for self-approval

* fix(teams): address PR comments

* refactor(timestamps): date user activity on authenticated requests

* refactor(timestamps): update local user instead of direct db update
This commit is contained in:
Sabe Jones 2019-09-26 14:49:11 -04:00 committed by GitHub
parent 5f2032a9d5
commit 01d272d2c4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 314 additions and 145 deletions

View file

@ -21,13 +21,13 @@ describe('POST /group/:groupId/remove-manager', () => {
type: groupType,
privacy: 'public',
},
members: 1,
members: 2,
});
groupToUpdate = group;
leader = groupLeader;
nonLeader = members[0];
nonManager = members[0];
nonManager = members[1];
});
it('returns an error when a non group leader tries to add member', async () => {
@ -71,10 +71,10 @@ describe('POST /group/:groupId/remove-manager', () => {
type: 'todo',
requiresApproval: true,
});
await nonLeader.post(`/tasks/${task._id}/assign/${leader._id}`);
let memberTasks = await leader.get('/tasks/user');
await nonLeader.post(`/tasks/${task._id}/assign/${nonManager._id}`);
let memberTasks = await nonManager.get('/tasks/user');
let syncedTask = find(memberTasks, findAssignedTask);
await expect(leader.post(`/tasks/${syncedTask._id}/score/up`))
await expect(nonManager.post(`/tasks/${syncedTask._id}/score/up`))
.to.eventually.be.rejected.and.to.eql({
code: 401,
error: 'NotAuthorized',

View file

@ -1,11 +1,10 @@
import {
createAndPopulateGroup,
translate as t,
} from '../../../../../helpers/api-integration/v3';
import { find } from 'lodash';
describe('GET /approvals/group/:groupId', () => {
let user, guild, member, task, syncedTask;
let user, guild, member, addlMember, task, syncedTask, addlSyncedTask;
function findAssignedTask (memberTask) {
return memberTask.group.id === guild._id;
@ -17,12 +16,13 @@ describe('GET /approvals/group/:groupId', () => {
name: 'Test Guild',
type: 'guild',
},
members: 1,
members: 2,
});
guild = group;
user = groupLeader;
member = members[0];
addlMember = members[1];
task = await user.post(`/tasks/group/${guild._id}`, {
text: 'test todo',
@ -31,37 +31,46 @@ describe('GET /approvals/group/:groupId', () => {
});
await user.post(`/tasks/${task._id}/assign/${member._id}`);
await user.post(`/tasks/${task._id}/assign/${addlMember._id}`);
let memberTasks = await member.get('/tasks/user');
syncedTask = find(memberTasks, findAssignedTask);
let addlMemberTasks = await addlMember.get('/tasks/user');
addlSyncedTask = find(addlMemberTasks, findAssignedTask);
try {
await member.post(`/tasks/${syncedTask._id}/score/up`);
} catch (e) {
// eslint-disable-next-line no-empty
}
try {
await addlMember.post(`/tasks/${addlSyncedTask._id}/score/up`);
} catch (e) {
// eslint-disable-next-line no-empty
}
});
it('errors when user is not the group leader', async () => {
await expect(member.get(`/approvals/group/${guild._id}`))
.to.eventually.be.rejected.and.to.eql({
code: 401,
error: 'NotAuthorized',
message: t('onlyGroupLeaderCanEditTasks'),
});
it('provides only user\'s own tasks when user is not the group leader', async () => {
let approvals = await member.get(`/approvals/group/${guild._id}`);
expect(approvals[0]._id).to.equal(syncedTask._id);
expect(approvals[1]).to.not.exist;
});
it('gets a list of task that need approval', async () => {
it('allows group leaders to get a list of tasks that need approval', async () => {
let approvals = await user.get(`/approvals/group/${guild._id}`);
expect(approvals[0]._id).to.equal(syncedTask._id);
expect(approvals[1]._id).to.equal(addlSyncedTask._id);
});
it('allows managers to get a list of task that need approval', async () => {
it('allows managers to get a list of tasks that need approval', async () => {
await user.post(`/groups/${guild._id}/add-manager`, {
managerId: member._id,
});
let approvals = await member.get(`/approvals/group/${guild._id}`);
expect(approvals[0]._id).to.equal(syncedTask._id);
expect(approvals[1]._id).to.equal(addlSyncedTask._id);
});
});

View file

@ -93,6 +93,25 @@ describe('POST /tasks/:taskId/assign/:memberId', () => {
expect(syncedTask).to.exist;
});
it('sends notifications to group leader and managers when a task is claimed', async () => {
await user.post(`/groups/${guild._id}/add-manager`, {
managerId: member2._id,
});
await member.post(`/tasks/${task._id}/assign/${member._id}`);
await user.sync();
await member2.sync();
let groupTask = await user.get(`/tasks/group/${guild._id}`);
expect(user.notifications.length).to.equal(2); // includes Guild Joined achievement
expect(user.notifications[1].type).to.equal('GROUP_TASK_CLAIMED');
expect(user.notifications[1].data.taskId).to.equal(groupTask[0]._id);
expect(user.notifications[1].data.groupId).to.equal(guild._id);
expect(member2.notifications.length).to.equal(1);
expect(member2.notifications[0].type).to.equal('GROUP_TASK_CLAIMED');
expect(member2.notifications[0].data.taskId).to.equal(groupTask[0]._id);
expect(member2.notifications[0].data.groupId).to.equal(guild._id);
});
it('assigns a task to a user', async () => {
await user.post(`/tasks/${task._id}/assign/${member._id}`);

View file

@ -56,11 +56,11 @@ describe('PUT /tasks/:id', () => {
requiresApproval: true,
});
let memberTasks = await member.get('/tasks/user');
let memberTasks = await member2.get('/tasks/user');
let syncedTask = find(memberTasks, (memberTask) => memberTask.group.taskId === task._id);
// score up to trigger approval
await expect(member.post(`/tasks/${syncedTask._id}/score/up`))
await expect(member2.post(`/tasks/${syncedTask._id}/score/up`))
.to.eventually.be.rejected.and.to.eql({
code: 401,
error: 'NotAuthorized',

View file

@ -109,12 +109,13 @@ describe('getTaskClasses getter', () => {
});
});
xit('returns good todo classes', () => {
it('returns good todo classes', () => {
const task = {type: 'todo', value: 2};
expect(getTaskClasses(task, 'control')).to.deep.equal({
bg: 'task-good-control-bg',
checkbox: 'task-good-control-checkbox',
inner: 'task-good-control-inner-daily-todo`',
inner: 'task-good-control-inner-daily-todo',
icon: 'task-good-control-icon',
});
});
@ -140,4 +141,14 @@ describe('getTaskClasses getter', () => {
},
});
});
it('returns noninteractive classes and padlock icons for group board tasks', () => {
const task = {type: 'todo', value: 2, group: {id: 'group-id'}};
expect(getTaskClasses(task, 'control')).to.deep.equal({
bg: 'task-good-control-bg-noninteractive',
checkbox: 'task-good-control-checkbox',
inner: 'task-good-control-inner-daily-todo',
icon: 'task-good-control-icon',
});
});
});

View file

@ -74,10 +74,10 @@
}
.btn-success {
background: $green-10;
background: $green-100;
&:disabled {
background: $green-10;
background: $green-100;
}
&:hover:not(:disabled), &:active, &.active {

View file

@ -8,6 +8,9 @@
.daily-todo-control { background: rgba(255, 255, 255, 0.72) !important; }
}
}
&-bg-noninteractive {
background: $maroon-100 !important;
}
&-inner-habit { background: rgba(26, 24, 29, 0.24) !important; }
&-inner-daily-todo { background: $maroon-500 !important; }
&-checkbox { color: $maroon-100 !important; }
@ -38,6 +41,9 @@
.daily-todo-control { background: rgba(255, 255, 255, 0.72) !important; }
}
}
&-bg-noninteractive {
background: $red-100 !important;
}
&-inner-habit { background: rgba(26, 24, 29, 0.24) !important; }
&-inner-daily-todo { background: $red-500 !important; }
&-checkbox { color: $red-100 !important; }
@ -69,6 +75,9 @@
.daily-todo-control { background: rgba(255, 255, 255, 0.72) !important; }
}
}
&-bg-noninteractive {
background: $orange-100 !important;
}
&-inner-habit { background: rgba(183, 90, 28, 0.4) !important; }
&-inner-daily-todo { background: $orange-500 !important; }
&-checkbox { color: $orange-100 !important; }
@ -109,6 +118,9 @@
.daily-todo-control { background: rgba(255, 255, 255, 0.72) !important; }
}
}
&-bg-noninteractive {
background: $yellow-100 !important;
}
&-inner-habit { background: rgba(183, 90, 28, 0.32) !important; }
&-inner-daily-todo { background: $yellow-500 !important; }
&-checkbox { color: $yellow-100 !important; }
@ -149,6 +161,9 @@
.daily-todo-control { background: rgba(255, 255, 255, 0.72) !important; }
}
}
&-bg-noninteractive {
background: $green-100 !important;
}
&-inner-habit { background: rgba(26, 24, 29, 0.24) !important; }
&-inner-daily-todo { background: #77f4c7 !important; }
&-checkbox { color: $green-10 !important; }
@ -179,6 +194,9 @@
.daily-todo-control { background: rgba(255, 255, 255, 0.72) !important; }
}
}
&-bg-noninteractive {
background: $teal-100 !important;
}
&-inner-habit { background: rgba(26, 24, 29, 0.24) !important; }
&-inner-daily-todo { background: #8dedf6 !important; }
&-checkbox { color: $teal-100 !important; }
@ -209,6 +227,9 @@
.daily-todo-control { background: rgba(255, 255, 255, 0.72) !important; }
}
}
&-bg-noninteractive {
background: $blue-100 !important;
}
&-inner-habit { background: rgba(26, 24, 29, 0.24) !important; }
&-inner-daily-todo { background: $blue-500 !important; }
&-checkbox { color: $blue-100 !important; }
@ -267,6 +288,10 @@
&:hover { background: rgba(255, 217, 160, 0.48) !important; }
}
&-bg-noninteractive {
background: rgba(255, 217, 160, 0.32) !important;
.small-text { color: $orange-10 !important; }
}
}
}

View file

@ -205,8 +205,6 @@ export default {
});
},
async loadApprovals () {
if (this.group.leader._id !== this.user._id) return [];
let approvalRequests = await this.$store.dispatch('tasks:getGroupApprovals', {
groupId: this.searchId,
});

View file

@ -349,25 +349,6 @@ export default {
isMember () {
return this.isMemberOfGroup(this.user, this.group);
},
memberProfileName (memberId) {
let foundMember = find(this.group.members, function findMember (member) {
return member._id === memberId;
});
return foundMember.profile.name;
},
isManager (memberId, group) {
return Boolean(group.managers[memberId]);
},
userCanApprove (userId, group) {
if (!group) return false;
let leader = group.leader._id === userId;
let userIsManager = Boolean(group.managers[userId]);
return leader || userIsManager;
},
hasChallenges () {
if (!this.group.challenges) return false;
return this.group.challenges.length === 0;
},
showNoNotificationsMessage () {
return this.group.memberCount > this.$store.state.constants.LARGE_GROUP_COUNT_MESSAGE_CUTOFF;
},

View file

@ -503,7 +503,9 @@ export default {
let droppedElement = document.getElementsByClassName('down')[0];
if (droppedElement && droppedElement !== element) {
droppedElement.classList.remove('down');
droppedElement.lastChild.style.maxHeight = 0;
if (droppedElement.lastChild) {
droppedElement.lastChild.style.maxHeight = 0;
}
}
element.classList.toggle('down');

View file

@ -0,0 +1,27 @@
<template lang="pug">
base-notification(
:can-remove="canRemove",
:has-icon="false",
:notification="notification",
:read-after-click="true",
@click="action",
)
div(slot="content", v-html="notification.data.message")
</template>
<script>
import BaseNotification from './base';
export default {
props: ['notification', 'canRemove'],
components: {
BaseNotification,
},
methods: {
action () {
const groupId = this.notification.data.groupId;
this.$router.push({ name: 'groupPlanDetailTaskInformation', params: { groupId }});
},
},
};
</script>

View file

@ -88,6 +88,7 @@ import QUEST_INVITATION from './notifications/questInvitation';
import GROUP_TASK_APPROVAL from './notifications/groupTaskApproval';
import GROUP_TASK_APPROVED from './notifications/groupTaskApproved';
import GROUP_TASK_ASSIGNED from './notifications/groupTaskAssigned';
import GROUP_TASK_CLAIMED from './notifications/groupTaskClaimed';
import UNALLOCATED_STATS_POINTS from './notifications/unallocatedStatsPoints';
import NEW_MYSTERY_ITEMS from './notifications/newMysteryItems';
import CARD_RECEIVED from './notifications/cardReceived';
@ -106,7 +107,7 @@ export default {
// One component for each type
NEW_STUFF, GROUP_TASK_NEEDS_WORK,
GUILD_INVITATION, PARTY_INVITATION, CHALLENGE_INVITATION,
QUEST_INVITATION, GROUP_TASK_APPROVAL, GROUP_TASK_APPROVED, GROUP_TASK_ASSIGNED,
QUEST_INVITATION, GROUP_TASK_APPROVAL, GROUP_TASK_APPROVED, GROUP_TASK_ASSIGNED, GROUP_TASK_CLAIMED,
UNALLOCATED_STATS_POINTS, NEW_MYSTERY_ITEMS, CARD_RECEIVED,
NEW_INBOX_MESSAGE, NEW_CHAT_MESSAGE,
ACHIEVEMENT_JUST_ADD_WATER, ACHIEVEMENT_LOST_MASTERCLASSER, ACHIEVEMENT_MIND_OVER_MATTER,
@ -131,7 +132,7 @@ export default {
handledNotifications: [
'NEW_STUFF', 'GROUP_TASK_NEEDS_WORK',
'GUILD_INVITATION', 'PARTY_INVITATION', 'CHALLENGE_INVITATION',
'QUEST_INVITATION', 'GROUP_TASK_ASSIGNED', 'GROUP_TASK_APPROVAL', 'GROUP_TASK_APPROVED',
'QUEST_INVITATION', 'GROUP_TASK_ASSIGNED', 'GROUP_TASK_APPROVAL', 'GROUP_TASK_APPROVED', 'GROUP_TASK_CLAIMED',
'NEW_MYSTERY_ITEMS', 'CARD_RECEIVED',
'NEW_INBOX_MESSAGE', 'NEW_CHAT_MESSAGE', 'UNALLOCATED_STATS_POINTS',
'ACHIEVEMENT_JUST_ADD_WATER', 'ACHIEVEMENT_LOST_MASTERCLASSER', 'ACHIEVEMENT_MIND_OVER_MATTER',

View file

@ -1,34 +1,41 @@
<template lang="pug">
div
approval-modal(:task='task')
.claim-bottom-message.col-12
.task-unclaimed.d-flex.justify-content-between(v-if='!approvalRequested && !multipleApprovalsRequested')
span {{ message }}
a.text-right(@click='claim()', v-if='!userIsAssigned') {{ $t('claim') }}
a.text-right(@click='unassign()', v-if='userIsAssigned') {{ $t('removeClaim') }}
.row.task-single-approval(v-if='approvalRequested')
.col-6.text-center
a(@click='approve()') {{ $t('approveTask') }}
.col-6.text-center
a(@click='needsWork()') {{ $t('needsWork') }}
.text-center.task-multi-approval(v-if='multipleApprovalsRequested')
a(@click='showRequests()') {{ $t('viewRequests') }}
.claim-bottom-message.d-flex.align-items-center(v-if='!approvalRequested && !multipleApprovalsRequested')
.mr-auto.ml-2(v-html='message')
.ml-auto.mr-2(v-if='!userIsAssigned')
a(@click='claim()').claim-color {{ $t('claim') }}
.ml-auto.mr-2(v-if='userIsAssigned')
a(@click='unassign()').unclaim-color {{ $t('removeClaim') }}
.claim-bottom-message.d-flex.align-items-center.justify-content-around(v-if='approvalRequested && userIsManager')
a(@click='approve()').approve-color {{ $t('approveTask') }}
a(@click='needsWork()') {{ $t('needsWork') }}
.claim-bottom-message.d-flex.align-items-center(v-if='multipleApprovalsRequested && userIsManager')
a(@click='showRequests()') {{ $t('viewRequests') }}
</template>
<style lang="scss", scoped>
.claim-bottom-message {
z-index: 9;
}
.task-unclaimed {
span {
margin-right: 0.25rem;
@import '~client/assets/scss/colors.scss';
.claim-bottom-message {
background-color: $gray-700;
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
color: $gray-200;
font-size: 12px;
padding-bottom: 0.25rem;
padding-top: 0.25rem;
z-index: 9;
}
a {
display: inline-block;
.approve-color {
color: $green-10 !important;
}
.claim-color {
color: $blue-10 !important;
}
.unclaim-color {
color: $red-50 !important;
}
}
</style>
<script>
@ -76,8 +83,11 @@ export default {
return this.$t('taskIsUnassigned');
}
},
userIsManager () {
if (this.group && (this.group.leader.id === this.user._id || this.group.managers[this.user._id])) return true;
},
approvalRequested () {
if (this.task.approvals && this.task.approvals.length === 1) return true;
if (this.task.approvals && this.task.approvals.length === 1 || this.task.group && this.task.group.approval && this.task.group.approval.requested) return true;
},
multipleApprovalsRequested () {
if (this.task.approvals && this.task.approvals.length > 1) return true;
@ -85,8 +95,6 @@ export default {
},
methods: {
async claim () {
if (!confirm(this.$t('confirmClaim'))) return;
let taskId = this.task._id;
// If we are on the user task
if (this.task.userId) {

View file

@ -1,18 +1,27 @@
<template lang="pug">
.claim-bottom-message.col-12.text-center(v-if='task.approvals && task.approvals.length > 0', :class="{approval: userIsAdmin}")
.task-unclaimed
| {{ message }}
.claim-top-message.d-flex.align-content-center(v-if='message', :class="{'approval-action': userIsAdmin, 'approval-pending': !userIsAdmin}")
.m-auto(v-html='message')
</template>
<style lang="scss" scoped>
.claim-bottom-message {
z-index: 9;
}
@import '~client/assets/scss/colors.scss';
.claim-top-message {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
color: #fff;
font-size: 12px;
padding-bottom: 0.25rem;
padding-top: 0.25rem;
z-index: 9;
}
.approval {
background: #24cc8f;
color: #fff;
}
.approval-action {
background: $green-100;
}
.approval-pending {
background: $gray-300;
}
</style>
<script>
@ -22,20 +31,22 @@ export default {
computed: {
...mapState({user: 'user.data'}),
message () {
let approvals = this.task.approvals;
let approvals = this.task.approvals || [];
let approvalsLength = approvals.length;
let userIsRequesting = this.task.group.approvals && this.task.group.approvals.indexOf(this.user._id) !== -1;
let userIsRequesting = approvals.findIndex((approval) => {
return approval.userId.id === this.user._id;
}) !== -1;
if (approvalsLength === 1 && !userIsRequesting) {
return this.$t('userRequestsApproval', {userName: approvals[0].userId.profile.name});
} else if (approvalsLength > 1 && !userIsRequesting) {
return this.$t('userCountRequestsApproval', {userCount: approvalsLength});
} else if (approvalsLength === 1 && userIsRequesting) {
} else if (approvalsLength === 1 && userIsRequesting || this.task.group.approval && this.task.group.approval.requested && !this.task.group.approval.approved) {
return this.$t('youAreRequestingApproval');
}
},
userIsAdmin () {
return this.group.leader.id === this.user._id;
return this.group && (this.group.leader.id === this.user._id || this.group.managers[this.user._id]);
},
},
};

View file

@ -1,15 +1,15 @@
<template lang="pug">
.task-wrapper
.task(@click='castEnd($event, task)', :class="`type_${task.type}`")
approval-header(:task='task', v-if='this.task.group.id', :group='group')
.task(@click='castEnd($event, task)', :class="[{'groupTask': task.group.id}, `type_${task.type}`]")
approval-header(:task='task', v-if='task.group.id', :group='group')
.d-flex(:class="{'task-not-scoreable': isUser !== true}")
// Habits left side control
.left-control.d-flex.align-items-center.justify-content-center(v-if="task.type === 'habit'", :class="controlClass.up.bg")
.left-control.d-flex.align-items-center.justify-content-center(v-if="task.type === 'habit'", :class="[{'control-bottom-box': this.task.group.id, 'control-top-box': approvalsClass}, controlClass.up.bg]")
.task-control.habit-control(:class="controlClass.up.inner", @click="(isUser && task.up) ? score('up') : null")
.svg-icon.lock(v-if="this.task.group.id && !isUser", v-html="icons.lock", :class="controlClass.up.icon")
.svg-icon.positive(v-else, v-html="icons.positive")
// Dailies and todos left side control
.left-control.d-flex.justify-content-center(v-if="task.type === 'daily' || task.type === 'todo'", :class="controlClass.bg")
.left-control.d-flex.justify-content-center(v-if="task.type === 'daily' || task.type === 'todo'", :class="[{'control-bottom-box': this.task.group.id, 'control-top-box': approvalsClass}, controlClass.bg]")
.task-control.daily-todo-control(:class="controlClass.inner", @click="isUser ? score(task.completed ? 'down' : 'up') : null")
.svg-icon.lock(v-html="icons.lock", v-if="this.task.group.id && !isUser && !task.completed", :class="controlClass.icon")
.svg-icon.check(v-else, v-html="icons.check", :class="{'display-check-icon': task.completed, [controlClass.checkbox]: true}")
@ -99,7 +99,7 @@
.tag-label(v-for="tag in getTagsFor(task)", v-markdown="tag")
// Habits right side control
.right-control.d-flex.align-items-center.justify-content-center(v-if="task.type === 'habit'", :class="controlClass.down.bg")
.right-control.d-flex.align-items-center.justify-content-center(v-if="task.type === 'habit'", :class="[{'control-bottom-box': this.task.group.id, 'control-top-box': approvalsClass}, controlClass.down.bg]")
.task-control.habit-control(:class="controlClass.down.inner", @click="(isUser && task.down) ? score('down') : null")
.svg-icon.lock(v-if="this.task.group.id && !isUser", v-html="icons.lock", :class="controlClass.down.icon")
.svg-icon.negative(v-else, v-html="icons.negative")
@ -107,12 +107,23 @@
.right-control.d-flex.align-items-center.justify-content-center.reward-control(v-if="task.type === 'reward'", :class="controlClass.bg", @click="isUser ? score('down') : null")
.svg-icon(v-html="icons.gold")
.small-text {{task.value}}
approval-footer(:task='task', v-if='this.task.group.id', :group='group')
approval-footer(:task='task', v-if='task.group.id', :group='group')
</template>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
.control-bottom-box {
border-bottom-left-radius: 0px !important;
border-bottom-right-radius: 0px !important;
}
.control-top-box {
border-top-left-radius: 0px !important;
border-top-right-radius: 0px !important;
}
.task {
margin-bottom: 2px;
box-shadow: 0 2px 2px 0 rgba($black, 0.16), 0 1px 4px 0 rgba($black, 0.12);
@ -122,13 +133,29 @@
&:hover {
box-shadow: 0 1px 8px 0 rgba($black, 0.12), 0 4px 4px 0 rgba($black, 0.16);
z-index: 10;
}
}
.task:not(.groupTask) {
&:hover {
.left-control, .right-control, .task-content {
border-color: $purple-400;
}
}
}
.task.groupTask {
&:hover {
border: $purple-400 solid 1px;
border-radius: 3px;
margin: -1px; // to counter the border width
margin-bottom: 1px;
transition: none; // with transition, the border color switches from black to $purple-400
}
}
.task-habit-disabled-control-habit:hover {
cursor: initial;
}
@ -622,6 +649,9 @@ export default {
if (task.type === 'habit') return true;
return false;
},
approvalsClass () {
return this.group && this.task.approvals && this.task.approvals.length > 0;
},
controlClass () {
return this.getTaskClasses(this.task, 'control', this.dueDate);
},
@ -744,6 +774,16 @@ export default {
const user = this.user;
const task = this.task;
if (task.group.approval.required) {
task.group.approval.requested = true;
const groupResponse = await axios.get(`/api/v4/groups/${task.group.id}`);
let managers = Object.keys(groupResponse.data.data.managers);
managers.push(groupResponse.data.data.leader._id);
if (managers.indexOf(user._id) !== -1) {
task.group.approval.approved = true;
}
}
try {
scoreTask({task, user, direction});
} catch (err) {
@ -767,8 +807,6 @@ export default {
}
if (task.group.approval.required) task.group.approval.requested = true;
Analytics.updateUser();
const response = await axios.post(`/api/v4/tasks/${task._id}/score/${direction}`);
const tmp = response.data.data._tmp || {}; // used to notify drops, critical hits and other bonuses

View file

@ -144,22 +144,22 @@ export function getTaskClasses (store) {
}
return {
bg: `task-${color}-control-bg`,
bg: task.group && task.group.id && !task.userId ? `task-${color}-control-bg-noninteractive` : `task-${color}-control-bg`,
checkbox: `task-${color}-control-checkbox`,
inner: `task-${color}-control-inner-daily-todo`,
icon: `task-${color}-control-icon`,
};
} else if (type === 'reward') {
return {
bg: 'task-reward-control-bg',
bg: task.group && task.group.id && !task.userId ? 'task-reward-control-bg-noninteractive' : 'task-reward-control-bg',
};
} else if (type === 'habit') {
return {
up: task.up ?
{ bg: `task-${color}-control-bg`, inner: `task-${color}-control-inner-habit`, icon: `task-${color}-control-icon`} :
{ bg: task.group && task.group.id && !task.userId ? `task-${color}-control-bg-noninteractive` : `task-${color}-control-bg`, inner: `task-${color}-control-inner-habit`, icon: `task-${color}-control-icon`} :
{ bg: 'task-disabled-habit-control-bg', inner: 'task-disabled-habit-control-inner', icon: `task-${color}-control-icon` },
down: task.down ?
{ bg: `task-${color}-control-bg`, inner: `task-${color}-control-inner-habit`, icon: `task-${color}-control-icon`} :
{ bg: task.group && task.group.id && !task.userId ? `task-${color}-control-bg-noninteractive` : `task-${color}-control-bg`, inner: `task-${color}-control-inner-habit`, icon: `task-${color}-control-icon`} :
{ bg: 'task-disabled-habit-control-bg', inner: 'task-disabled-habit-control-inner', icon: `task-${color}-control-icon` },
};
}

View file

@ -253,17 +253,17 @@
"onlyGroupLeaderCanEditTasks": "Not authorized to manage tasks!",
"onlyGroupTasksCanBeAssigned": "Only group tasks can be assigned",
"assignedTo": "Assigned To",
"assignedToUser": "Assigned to <%= userName %>",
"assignedToMembers": "Assigned to <%= userCount %> members",
"assignedToYouAndMembers": "Assigned to you and <%= userCount %> members",
"assignedToUser": "Assigned to <strong><%= userName %></strong>",
"assignedToMembers": "Assigned to <strong><%= userCount %> members</strong>",
"assignedToYouAndMembers": "Assigned to you and <strong><%= userCount %> members</strong>",
"youAreAssigned": "You are assigned to this task",
"taskIsUnassigned": "This task is unassigned",
"confirmClaim": "Are you sure you want to claim this task?",
"confirmUnClaim": "Are you sure you want to unclaim this task?",
"confirmApproval": "Are you sure you want to approve this task?",
"confirmNeedsWork": "Are you sure you want to mark this task as needing work?",
"userRequestsApproval": "<%= userName %> requests approval",
"userCountRequestsApproval": "<%= userCount %> members request approval",
"userRequestsApproval": "<strong><%= userName %></strong> requests approval",
"userCountRequestsApproval": "<strong><%= userCount %> members</strong> request approval",
"youAreRequestingApproval": "You are requesting approval",
"chatPrivilegesRevoked": "You cannot do this because your chat privileges have been removed. For details or to ask if your privileges can be returned, please email our Community Manager at admin@habitica.com or ask your parent or guardian to email them. Please include your @Username in the email. If a moderator has already told you that your chat ban is temporary, you do not need to send an email.",
"newChatMessagePlainNotification": "New message in <%= groupName %> by <%= authorName %>. Click here to open the chat page!",
@ -279,11 +279,12 @@
"groupHomeTitle": "Home",
"assignTask": "Assign Task",
"desktopNotificationsText": "We need your permission to enable desktop notifications for new messages in party chat! Follow your browser's instructions to turn them on.<br><br>You'll receive these notifications only while you have Habitica open. If you decide you don't like them, they can be disabled in your browser's settings.<br><br>This box will close automatically when a decision is made.",
"claim": "Claim",
"claim": "Claim Task",
"removeClaim": "Remove Claim",
"onlyGroupLeaderCanManageSubscription": "Only the group leader can manage the group's subscription",
"youHaveBeenAssignedTask": "<%= managerName %> has assigned you the task <span class=\"notification-bold\"><%= taskText %></span>.",
"yourTaskHasBeenApproved": "Your task <span class=\"notification-green notification-bold\"><%= taskText %></span> has been approved.",
"taskClaimed": "<%= userName %> has claimed the task <span class=\"notification-bold\"><%= taskText %></span>.",
"taskNeedsWork": "<span class=\"notification-bold\"><%= managerName %></span> marked <span class=\"notification-bold\"><%= taskText %></span> as needing additional work.",
"userHasRequestedTaskApproval": "<span class=\"notification-bold\"><%= user %></span> requests approval for <span class=\"notification-bold\"><%= taskName %></span>",
"approve": "Approve",

View file

@ -327,6 +327,7 @@ module.exports = function scoreTask (options = {}, req = {}) {
}
}
req.yesterDailyScored = task.yesterDailyScored;
updateStats(user, stats, req);
return [delta];
};

View file

@ -567,41 +567,48 @@ api.scoreTask = {
}
if (task.group.approval.required && !task.group.approval.approved) {
if (task.group.approval.requested) {
throw new NotAuthorized(res.t('taskRequiresApproval'));
}
task.group.approval.requested = true;
task.group.approval.requestedDate = new Date();
let fields = requiredGroupFields.concat(' managers');
let group = await Group.getGroup({user, groupId: task.group.id, fields});
// @TODO: we can use the User.pushNotification function because we need to ensure notifications are translated
let managerIds = Object.keys(group.managers);
managerIds.push(group.leader);
let managers = await User.find({_id: managerIds}, 'notifications preferences').exec(); // Use this method so we can get access to notifications
let managerPromises = [];
managers.forEach((manager) => {
manager.addNotification('GROUP_TASK_APPROVAL', {
message: res.t('userHasRequestedTaskApproval', {
user: user.profile.name,
taskName: task.text,
}, manager.preferences.language),
groupId: group._id,
taskId: task._id, // user task id, used to match the notification when the task is approved
userId: user._id,
groupTaskId: task.group.taskId, // the original task id
direction,
if (managerIds.indexOf(user._id) !== -1) {
task.group.approval.approved = true;
task.group.approval.requested = true;
task.group.approval.requestedDate = new Date();
} else {
if (task.group.approval.requested) {
throw new NotAuthorized(res.t('taskRequiresApproval'));
}
task.group.approval.requested = true;
task.group.approval.requestedDate = new Date();
let managers = await User.find({_id: managerIds}, 'notifications preferences').exec(); // Use this method so we can get access to notifications
// @TODO: we can use the User.pushNotification function because we need to ensure notifications are translated
let managerPromises = [];
managers.forEach((manager) => {
manager.addNotification('GROUP_TASK_APPROVAL', {
message: res.t('userHasRequestedTaskApproval', {
user: user.profile.name,
taskName: task.text,
}, manager.preferences.language),
groupId: group._id,
taskId: task._id, // user task id, used to match the notification when the task is approved
userId: user._id,
groupTaskId: task.group.taskId, // the original task id
direction,
});
managerPromises.push(manager.save());
});
managerPromises.push(manager.save());
});
managerPromises.push(task.save());
await Promise.all(managerPromises);
managerPromises.push(task.save());
await Promise.all(managerPromises);
throw new NotAuthorized(res.t('taskApprovalHasBeenRequested'));
throw new NotAuthorized(res.t('taskApprovalHasBeenRequested'));
}
}
let wasCompleted = task.completed;

View file

@ -200,13 +200,25 @@ api.assignTask = {
if (canNotEditTasks(group, user, assignedUserId)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks'));
let promises = [];
const taskText = task.text;
const userName = user.profile.name;
if (user._id !== assignedUserId) {
const taskText = task.text;
const managerName = user.profile.name;
if (user._id === assignedUserId) {
const managerIds = Object.keys(group.managers);
managerIds.push(group.leader);
const managers = await User.find({_id: managerIds}, 'notifications preferences').exec();
managers.forEach((manager) => {
if (manager._id === user._id) return;
manager.addNotification('GROUP_TASK_CLAIMED', {
message: res.t('taskClaimed', {userName, taskText}, manager.preferences.language),
groupId: group._id,
taskId: task._id,
});
promises.push(manager.save());
});
} else {
assignedUser.addNotification('GROUP_TASK_ASSIGNED', {
message: res.t('youHaveBeenAssignedTask', {managerName, taskText}),
message: res.t('youHaveBeenAssignedTask', {managerName: userName, taskText}),
taskId: task._id,
});
}
@ -504,15 +516,26 @@ api.getGroupApprovals = {
let group = await Group.getGroup({user, groupId, fields});
if (!group) throw new NotFound(res.t('groupNotFound'));
if (canNotEditTasks(group, user)) throw new NotAuthorized(res.t('onlyGroupLeaderCanEditTasks'));
let approvals = await Tasks.Task.find({
'group.id': groupId,
'group.approval.approved': false,
'group.approval.requested': true,
}, 'userId group text')
.populate('userId', 'profile')
.exec();
let approvals;
if (canNotEditTasks(group, user)) {
approvals = await Tasks.Task.find({
'group.id': groupId,
'group.approval.approved': false,
'group.approval.requested': true,
'group.assignedUsers': user._id,
userId: user._id,
}, 'userId group text')
.populate('userId', 'profile')
.exec();
} else {
approvals = await Tasks.Task.find({
'group.id': groupId,
'group.approval.approved': false,
'group.approval.requested': true,
}, 'userId group text')
.populate('userId', 'profile')
.exec();
}
res.respond(200, approvals);
},

View file

@ -289,6 +289,7 @@ export function cron (options = {}) {
let todoTally = 0;
tasksByType.todos.forEach(task => { // make uncompleted To-Dos redder (further incentive to complete them)
if (task.group.assignedDate && moment(task.group.assignedDate).isAfter(user.auth.timestamps.updated)) return;
scoreTask({
task,
user,
@ -308,6 +309,7 @@ export function cron (options = {}) {
if (!user.party.quest.progress.down) user.party.quest.progress.down = 0;
tasksByType.dailys.forEach((task) => {
if (task.group.assignedDate && moment(task.group.assignedDate).isAfter(user.auth.timestamps.updated)) return;
let completed = task.completed;
// Deduct points for missed Daily tasks
let EvadeTask = 0;

View file

@ -79,7 +79,7 @@ export function authWithHeaders (options = {}) {
res.locals.user = user;
req.session.userId = user._id;
stackdriverTraceUserId(user._id);
user.auth.timestamps.updated = new Date();
return next();
})
.catch(next);
@ -108,6 +108,7 @@ export function authWithSession (req, res, next) {
res.locals.user = user;
stackdriverTraceUserId(user._id);
user.auth.timestamps.updated = new Date();
return next();
})
.catch(next);

View file

@ -1430,6 +1430,7 @@ schema.methods.syncTask = async function groupSyncTask (taskToSync, user) {
matchingTask.group.id = taskToSync.group.id;
matchingTask.userId = user._id;
matchingTask.group.taskId = taskToSync._id;
matchingTask.group.assignedDate = new Date();
user.tasksOrder[`${taskToSync.type}s`].unshift(matchingTask._id);
} else {
_.merge(matchingTask, syncableAttrs(taskToSync));

View file

@ -115,6 +115,7 @@ export let TaskSchema = new Schema({
id: {$type: String, ref: 'Group', validate: [v => validator.isUUID(v), 'Invalid uuid.']},
broken: {$type: String, enum: ['GROUP_DELETED', 'TASK_DELETED', 'UNSUBSCRIBED']},
assignedUsers: [{$type: String, ref: 'User', validate: [v => validator.isUUID(v), 'Invalid uuid.']}],
assignedDate: {$type: Date},
taskId: {$type: String, ref: 'Task', validate: [v => validator.isUUID(v), 'Invalid uuid.']},
approval: {
required: {$type: Boolean, default: false},

View file

@ -68,6 +68,7 @@ let schema = new Schema({
timestamps: {
created: {$type: Date, default: Date.now},
loggedin: {$type: Date, default: Date.now},
updated: {$type: Date, default: Date.now},
},
},
// We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which

View file

@ -16,6 +16,7 @@ const NOTIFICATION_TYPES = [
'GROUP_TASK_APPROVAL',
'GROUP_TASK_APPROVED',
'GROUP_TASK_ASSIGNED',
'GROUP_TASK_CLAIMED',
'GROUP_TASK_NEEDS_WORK',
'LOGIN_INCENTIVE',
'GROUP_INVITE_ACCEPTED',