Group Tasks Shared Completion (#10515)

* WIP(groups): add shared completion prop
Also fix an issue where the Needs Approval toggle would not read/save 
correctly.

* fix(groups): save group options on task create
Also, correct count of assigned members when viewing user is among 
assignments

* fix(groups): display correct messages in two places

* fix(tasks): eliminate console error related to filtering
Also localize a group plans string

* WIP(groups): implement single completion for approval workflow

* WIP(groups): Add shared completion handling to no-approval-needed flow

* WIP(groups): cover approval flow case for all-assigned
Also save new field on initial task creation

* fix(tasks): use default sharedCompletion value when creating tasks

* WIP(tests): non-working draft test

* Added completed todo to group query

* WIP(group-tasks): fix bugs, add tests

* refactor(group-tasks): deleteMany op, add more tests

* refactor(group-tasks): move shared completion handling to lib

* WIP(group-tasks): broken refactor

* WIP(group-tasks): await all the things

* Turned complete master task to save

* WIP(group-tasks): show completed

* fix(filtering): don't try to filter if no list is passed

* refactor(group-tasks): load completed to-dos on demand, not at start

* fix(group-tasks): don't double up on repeat visits

* fix(group-tasks): include brief explanation in dropdown

* fix(group-tasks): improve wording some more
This commit is contained in:
Sabe Jones
2018-07-20 12:29:44 -05:00
committed by GitHub
parent 8b69540e71
commit 284b2cc413
13 changed files with 341 additions and 14 deletions

View File

@@ -140,4 +140,89 @@ describe('POST /tasks/:id/approve/:userId', () => {
message: t('canOnlyApproveTaskOnce'),
});
});
it('completes master task when single-completion task is approved', async () => {
let sharedCompletionTask = await user.post(`/tasks/group/${guild._id}`, {
text: 'shared completion todo',
type: 'todo',
requiresApproval: true,
sharedCompletion: 'singleCompletion',
});
await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member._id}`);
await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member2._id}`);
await user.post(`/tasks/${sharedCompletionTask._id}/approve/${member._id}`);
let groupTasks = await user.get(`/tasks/group/${guild._id}?type=completedTodos`);
let masterTask = find(groupTasks, (groupTask) => {
return groupTask._id === sharedCompletionTask._id;
});
expect(masterTask.completed).to.equal(true);
});
it('deletes other assigned user tasks when single-completion task is approved', async () => {
let sharedCompletionTask = await user.post(`/tasks/group/${guild._id}`, {
text: 'shared completion todo',
type: 'todo',
requiresApproval: true,
sharedCompletion: 'singleCompletion',
});
await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member._id}`);
await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member2._id}`);
await user.post(`/tasks/${sharedCompletionTask._id}/approve/${member._id}`);
let member2Tasks = await member2.get('/tasks/user');
let syncedTask2 = find(member2Tasks, (memberTask) => {
return memberTask.group.taskId === sharedCompletionTask._id;
});
expect(syncedTask2).to.equal(undefined);
});
it('does not complete master task when not all user tasks are approved if all assigned must complete', async () => {
let sharedCompletionTask = await user.post(`/tasks/group/${guild._id}`, {
text: 'shared completion todo',
type: 'todo',
requiresApproval: true,
sharedCompletion: 'allAssignedCompletion',
});
await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member._id}`);
await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member2._id}`);
await user.post(`/tasks/${sharedCompletionTask._id}/approve/${member._id}`);
let groupTasks = await user.get(`/tasks/group/${guild._id}`);
let masterTask = find(groupTasks, (groupTask) => {
return groupTask._id === sharedCompletionTask._id;
});
expect(masterTask.completed).to.equal(false);
});
it('completes master task when all user tasks are approved if all assigned must complete', async () => {
let sharedCompletionTask = await user.post(`/tasks/group/${guild._id}`, {
text: 'shared completion todo',
type: 'todo',
requiresApproval: true,
sharedCompletion: 'allAssignedCompletion',
});
await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member._id}`);
await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member2._id}`);
await user.post(`/tasks/${sharedCompletionTask._id}/approve/${member._id}`);
await user.post(`/tasks/${sharedCompletionTask._id}/approve/${member2._id}`);
let groupTasks = await user.get(`/tasks/group/${guild._id}?type=completedTodos`);
let masterTask = find(groupTasks, (groupTask) => {
return groupTask._id === sharedCompletionTask._id;
});
expect(masterTask.completed).to.equal(true);
});
});

View File

@@ -125,7 +125,7 @@ describe('POST /tasks/:id/score/:direction', () => {
});
});
it('allows a user to score an apporoved task', async () => {
it('allows a user to score an approved task', async () => {
let memberTasks = await member.get('/tasks/user');
let syncedTask = find(memberTasks, findAssignedTask);
@@ -137,4 +137,112 @@ describe('POST /tasks/:id/score/:direction', () => {
expect(updatedTask.completed).to.equal(true);
expect(updatedTask.dateCompleted).to.be.a('string'); // date gets converted to a string as json doesn't have a Date type
});
it('completes master task when single-completion task is completed', async () => {
let sharedCompletionTask = await user.post(`/tasks/group/${guild._id}`, {
text: 'shared completion todo',
type: 'todo',
requiresApproval: false,
sharedCompletion: 'singleCompletion',
});
await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member._id}`);
let memberTasks = await member.get('/tasks/user');
let syncedTask = find(memberTasks, (memberTask) => {
return memberTask.group.taskId === sharedCompletionTask._id;
});
await member.post(`/tasks/${syncedTask._id}/score/up`);
let groupTasks = await user.get(`/tasks/group/${guild._id}?type=completedTodos`);
let masterTask = find(groupTasks, (groupTask) => {
return groupTask._id === sharedCompletionTask._id;
});
expect(masterTask.completed).to.equal(true);
});
it('deletes other assigned user tasks when single-completion task is completed', async () => {
let sharedCompletionTask = await user.post(`/tasks/group/${guild._id}`, {
text: 'shared completion todo',
type: 'todo',
requiresApproval: false,
sharedCompletion: 'singleCompletion',
});
await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member._id}`);
await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member2._id}`);
let memberTasks = await member.get('/tasks/user');
let syncedTask = find(memberTasks, (memberTask) => {
return memberTask.group.taskId === sharedCompletionTask._id;
});
await member.post(`/tasks/${syncedTask._id}/score/up`);
let member2Tasks = await member2.get('/tasks/user');
let syncedTask2 = find(member2Tasks, (memberTask) => {
return memberTask.group.taskId === sharedCompletionTask._id;
});
expect(syncedTask2).to.equal(undefined);
});
it('does not complete master task when not all user tasks are completed if all assigned must complete', async () => {
let sharedCompletionTask = await user.post(`/tasks/group/${guild._id}`, {
text: 'shared completion todo',
type: 'todo',
requiresApproval: false,
sharedCompletion: 'allAssignedCompletion',
});
await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member._id}`);
await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member2._id}`);
let memberTasks = await member.get('/tasks/user');
let syncedTask = find(memberTasks, (memberTask) => {
return memberTask.group.taskId === sharedCompletionTask._id;
});
await member.post(`/tasks/${syncedTask._id}/score/up`);
let groupTasks = await user.get(`/tasks/group/${guild._id}`);
let masterTask = find(groupTasks, (groupTask) => {
return groupTask._id === sharedCompletionTask._id;
});
expect(masterTask.completed).to.equal(false);
});
it('completes master task when all user tasks are completed if all assigned must complete', async () => {
let sharedCompletionTask = await user.post(`/tasks/group/${guild._id}`, {
text: 'shared completion todo',
type: 'todo',
requiresApproval: false,
sharedCompletion: 'allAssignedCompletion',
});
await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member._id}`);
await user.post(`/tasks/${sharedCompletionTask._id}/assign/${member2._id}`);
let memberTasks = await member.get('/tasks/user');
let member2Tasks = await member2.get('/tasks/user');
let syncedTask = find(memberTasks, (memberTask) => {
return memberTask.group.taskId === sharedCompletionTask._id;
});
let syncedTask2 = find(member2Tasks, (memberTask) => {
return memberTask.group.taskId === sharedCompletionTask._id;
});
await member.post(`/tasks/${syncedTask._id}/score/up`);
await member2.post(`/tasks/${syncedTask2._id}/score/up`);
let groupTasks = await user.get(`/tasks/group/${guild._id}?type=completedTodos`);
let masterTask = find(groupTasks, (groupTask) => {
return groupTask._id === sharedCompletionTask._id;
});
expect(masterTask.completed).to.equal(true);
});
});

View File

@@ -80,6 +80,7 @@
:key="column",
:taskListOverride='tasksByType[column]',
v-on:editTask="editTask",
v-on:loadGroupCompletedTodos="loadGroupCompletedTodos",
:group='group',
:searchText="searchText")
</template>
@@ -384,6 +385,20 @@ export default {
this.$root.$emit('bv::show::modal', 'task-modal');
});
},
async loadGroupCompletedTodos () {
const completedTodos = await this.$store.dispatch('tasks:getCompletedGroupTasks', {
groupId: this.searchId,
});
completedTodos.forEach((task) => {
const existingTaskIndex = findIndex(this.tasksByType.todo, (todo) => {
return todo._id === task._id;
});
if (existingTaskIndex === -1) {
this.tasksByType.todo.push(task);
}
});
},
createTask (type) {
this.taskFormPurpose = 'create';
this.creatingTask = taskDefaults({type, text: ''});

View File

@@ -362,7 +362,7 @@ export default {
type: this.type,
filterType: this.activeFilter.label,
}) :
this.taskListOverride;
this.filterByCompleted(this.taskListOverride, this.activeFilter.label);
let taggedList = this.filterByTagList(filteredTaskList, this.selectedTags);
let searchedList = this.filterBySearchText(taggedList, this.searchText);
@@ -556,7 +556,11 @@ export default {
activateFilter (type, filter = '') {
// Needs a separate API call as this data may not reside in store
if (type === 'todo' && filter === 'complete2') {
this.loadCompletedTodos();
if (this.group && this.group._id) {
this.$emit('loadGroupCompletedTodos');
} else {
this.loadCompletedTodos();
}
}
// the only time activateFilter is called with filter==='' is when the component is first created
@@ -594,6 +598,13 @@ export default {
}
});
},
filterByCompleted (taskList, filter) {
if (!taskList) return [];
return taskList.filter(task => {
if (filter === 'complete2') return task.completed;
return !task.completed;
});
},
filterByTagList (taskList, tagList = []) {
let filteredTaskList = taskList;
// filter requested tasks by tags

View File

@@ -185,6 +185,15 @@
:checked="requiresApproval",
@change="updateRequiresApproval"
)
.form-group(v-if="task.type === 'todo'")
label(v-once) {{ $t('sharedCompletion') }}
b-dropdown.inline-dropdown(:text="$t(sharedCompletion)")
b-dropdown-item(
v-for="completionOption in ['recurringCompletion', 'singleCompletion', 'allAssignedCompletion']",
:key="completionOption",
@click="sharedCompletion = completionOption",
:class="{active: sharedCompletion === completionOption}"
) {{ $t(completionOption) }}
.advanced-settings(v-if="task.type !== 'reward'")
.advanced-settings-toggle.d-flex.justify-content-between.align-items-center(@click = "showAdvancedOptions = !showAdvancedOptions")
@@ -691,6 +700,7 @@ export default {
calendar: calendarIcon,
}),
requiresApproval: false, // We can't set task.group fields so we use this field to toggle
sharedCompletion: 'recurringCompletion',
members: [],
memberNamesById: {},
assignedMembers: [],
@@ -811,6 +821,7 @@ export default {
});
this.assignedMembers = [];
if (this.task.group && this.task.group.assignedUsers) this.assignedMembers = this.task.group.assignedUsers;
if (this.task.group) this.sharedCompletion = this.task.group.sharedCompletion || 'recurringCompletion';
}
// @TODO: This whole component is mutating a prop and that causes issues. We need to not copy the prop similar to group modals
@@ -892,10 +903,13 @@ export default {
async submit () {
if (this.newChecklistItem) this.addChecklistItem();
// TODO Fix up permissions on task.group so we don't have to keep doing these hacks
if (this.groupId) {
this.task.group.assignedUsers = this.assignedMembers;
this.task.requiresApproval = this.requiresApproval;
this.task.group.approval.required = this.requiresApproval;
this.task.sharedCompletion = this.sharedCompletion;
this.task.group.sharedCompletion = this.sharedCompletion;
}
if (this.purpose === 'create') {

View File

@@ -176,6 +176,11 @@ export async function getGroupTasks (store, payload) {
return response.data.data;
}
export async function getCompletedGroupTasks (store, payload) {
let response = await axios.get(`/api/v4/tasks/group/${payload.groupId}?type=completedTodos`);
return response.data.data;
}
export async function createGroupTasks (store, payload) {
let response = await axios.post(`/api/v4/tasks/group/${payload.groupId}`, payload.tasks);
return response.data.data;

View File

@@ -475,5 +475,9 @@
"howToRequireApprovalDesc2": "Group Leaders and Managers can approve completed Tasks directly from the Task Board or from the Notifications panel.",
"whatIsGroupManager": "What is a Group Manager?",
"whatIsGroupManagerDesc": "A Group Manager is a user role that do not have access to the group's billing details, but can create, assign, and approve shared Tasks for the Group's members. Promote Group Managers from the Groups member list.",
"goToTaskBoard": "Go to Task Board"
"goToTaskBoard": "Go to Task Board",
"sharedCompletion": "Shared Completion",
"recurringCompletion": "None - Group task does not complete",
"singleCompletion": "Single - Completes when any assigned user finishes",
"allAssignedCompletion": "All - Completes when all assigned users finish"
}

View File

@@ -5,6 +5,7 @@ import {
} from '../../libs/webhook';
import { removeFromArray } from '../../libs/collectionManipulators';
import * as Tasks from '../../models/task';
import { handleSharedCompletion } from '../../libs/groupTasks';
import { model as Challenge } from '../../models/challenge';
import { model as Group } from '../../models/group';
import { model as User } from '../../models/user';
@@ -490,6 +491,9 @@ api.updateTask = {
if (sanitizedObj.requiresApproval) {
task.group.approval.required = true;
}
if (sanitizedObj.sharedCompletion) {
task.group.sharedCompletion = sanitizedObj.sharedCompletion;
}
setNextDue(task, user);
let savedTask = await task.save();
@@ -653,6 +657,12 @@ api.scoreTask = {
user.save(),
task.save(),
];
if (task.group && task.group.taskId) {
await handleSharedCompletion(task);
}
// Save results and handle request
if (taskOrderPromise) promises.push(taskOrderPromise);
let results = await Promise.all(promises);

View File

@@ -12,10 +12,13 @@ import {
getTasks,
moveTask,
} from '../../../libs/taskManager';
import { handleSharedCompletion } from '../../../libs/groupTasks';
import apiError from '../../../libs/apiError';
let requiredGroupFields = '_id leader tasksOrder name';
// @TODO: abstract to task lib
let types = Tasks.tasksTypes.map(type => `${type}s`);
types.push('completedTodos', '_allCompletedTodos'); // _allCompletedTodos is currently in BETA and is likely to be removed in future
function canNotEditTasks (group, user, assignedUserId) {
let isNotGroupLeader = group.leader !== user._id;
@@ -338,7 +341,7 @@ api.approveTask = {
}
// Remove old notifications
let managerPromises = [];
let approvalPromises = [];
managers.forEach((manager) => {
let notificationIndex = manager.notifications.findIndex(function findNotification (notification) {
return notification && notification.data && notification.data.taskId === task._id && notification.type === 'GROUP_TASK_APPROVAL';
@@ -346,7 +349,7 @@ api.approveTask = {
if (notificationIndex !== -1) {
manager.notifications.splice(notificationIndex, 1);
managerPromises.push(manager.save());
approvalPromises.push(manager.save());
}
});
@@ -362,9 +365,11 @@ api.approveTask = {
direction,
});
managerPromises.push(task.save());
managerPromises.push(assignedUser.save());
await Promise.all(managerPromises);
await handleSharedCompletion(task);
approvalPromises.push(task.save());
approvalPromises.push(assignedUser.save());
await Promise.all(approvalPromises);
res.respond(200, task);
},

View File

@@ -0,0 +1,61 @@
import * as Tasks from '../models/task';
const SHARED_COMPLETION = {
default: 'recurringCompletion',
single: 'singleCompletion',
every: 'allAssignedCompletion',
};
async function _completeMasterTask (masterTask) {
masterTask.completed = true;
await masterTask.save();
}
async function _deleteUnfinishedTasks (groupMemberTask) {
await Tasks.Task.deleteMany({
'group.taskId': groupMemberTask.group.taskId,
$and: [
{userId: {$exists: true}},
{userId: {$ne: groupMemberTask.userId}},
],
}).exec();
}
async function _evaluateAllAssignedCompletion (masterTask) {
let completions;
if (masterTask.group.approval && masterTask.group.approval.required) {
completions = await Tasks.Task.count({
'group.taskId': masterTask._id,
'group.approval.approved': true,
}).exec();
completions++;
} else {
completions = await Tasks.Task.count({
'group.taskId': masterTask._id,
completed: true,
}).exec();
}
if (completions >= masterTask.group.assignedUsers.length) {
await _completeMasterTask(masterTask);
}
}
async function handleSharedCompletion (groupMemberTask) {
let masterTask = await Tasks.Task.findOne({
_id: groupMemberTask.group.taskId,
}).exec();
if (!masterTask || !masterTask.group || masterTask.type !== 'todo') return;
if (masterTask.group.sharedCompletion === SHARED_COMPLETION.single) {
await _deleteUnfinishedTasks(groupMemberTask);
await _completeMasterTask(masterTask);
} else if (masterTask.group.sharedCompletion === SHARED_COMPLETION.every) {
await _evaluateAllAssignedCompletion(masterTask);
}
}
export {
SHARED_COMPLETION,
handleSharedCompletion,
};

View File

@@ -3,6 +3,9 @@ import * as Tasks from '../models/task';
import {
BadRequest,
} from './errors';
import {
SHARED_COMPLETION,
} from './groupTasks';
import _ from 'lodash';
import shared from '../../common';
@@ -96,6 +99,7 @@ export async function createTasks (req, res, options = {}) {
if (taskData.requiresApproval) {
newTask.group.approval.required = true;
}
newTask.group.sharedCompletion = taskData.sharedCompletion || SHARED_COMPLETION.default;
} else {
newTask.userId = user._id;
}
@@ -183,11 +187,12 @@ export async function getTasks (req, res, options = {}) {
limit = 0; // no limit
}
query = {
userId: user._id,
type: 'todo',
completed: true,
};
query.type = 'todo';
query.completed = true;
if (owner._id === user._id) {
query.userId = user._id;
}
sort = {
dateCompleted: -1,

View File

@@ -1319,6 +1319,7 @@ schema.methods.updateTask = async function updateTask (taskToSync, options = {})
updateCmd.$set['group.approval.required'] = taskToSync.group.approval.required;
updateCmd.$set['group.assignedUsers'] = taskToSync.group.assignedUsers;
updateCmd.$set['group.sharedCompletion'] = taskToSync.group.sharedCompletion;
let taskSchema = Tasks[taskToSync.type];
@@ -1414,6 +1415,7 @@ schema.methods.syncTask = async function groupSyncTask (taskToSync, user) {
matchingTask.group.approval.required = taskToSync.group.approval.required;
matchingTask.group.assignedUsers = taskToSync.group.assignedUsers;
matchingTask.group.sharedCompletion = taskToSync.group.sharedCompletion;
// sync checklist
if (taskToSync.checklist) {

View File

@@ -6,6 +6,7 @@ import baseModel from '../libs/baseModel';
import { InternalServerError } from '../libs/errors';
import _ from 'lodash';
import { preenHistory } from '../libs/preening';
import { SHARED_COMPLETION } from '../libs/groupTasks';
const Schema = mongoose.Schema;
@@ -111,6 +112,7 @@ export let TaskSchema = new Schema({
requested: {type: Boolean, default: false},
requestedDate: {type: Date},
},
sharedCompletion: {type: String, enum: _.values(SHARED_COMPLETION), default: SHARED_COMPLETION.default},
},
reminders: [{