diff --git a/src/backend/src/controllers/tasks.controllers.ts b/src/backend/src/controllers/tasks.controllers.ts index 5ba914ff20..07a04cf4db 100644 --- a/src/backend/src/controllers/tasks.controllers.ts +++ b/src/backend/src/controllers/tasks.controllers.ts @@ -80,6 +80,19 @@ export default class TasksController { } } + static async editTaskWbsElement(req: Request, res: Response, next: NextFunction) { + try { + const { wbsElementId } = req.body; + const { taskId } = req.params as Record; + + const updatedTask = await TasksService.editTaskWbsElement(req.currentUser, taskId, wbsElementId, req.organization); + + res.status(200).json(updatedTask); + } catch (error: unknown) { + next(error); + } + } + static async deleteTask(req: Request, res: Response, next: NextFunction) { try { const { taskId } = req.params as Record; @@ -122,4 +135,14 @@ export default class TasksController { next(error); } } + + static async getTasksByWbsNum(req: Request, res: Response, next: NextFunction) { + try { + const wbsNum: WbsNumber = validateWBS(req.params.wbsNum as string); + const tasks = await TasksService.getTasksByWbsNum(wbsNum, req.organization); + res.status(200).json(tasks); + } catch (error: unknown) { + next(error); + } + } } diff --git a/src/backend/src/prisma-query-args/projects.query-args.ts b/src/backend/src/prisma-query-args/projects.query-args.ts index c7efd06b11..3f66bcbec8 100644 --- a/src/backend/src/prisma-query-args/projects.query-args.ts +++ b/src/backend/src/prisma-query-args/projects.query-args.ts @@ -4,7 +4,7 @@ import { getDescriptionBulletQueryArgs } from './description-bullets.query-args. import { getTeamPreviewQueryArgs } from './teams.query-args.js'; import { getTaskQueryArgs } from './tasks.query-args.js'; import { getLinkQueryArgs } from './links.query-args.js'; -import { getWorkPackagePreviewQueryArgs, getWorkPackageQueryArgs } from './work-packages.query-args.js'; +import { getWorkPackageQueryArgs, getWorkPackagePreviewQueryArgs } from './work-packages.query-args.js'; export type ProjectQueryArgs = ReturnType; diff --git a/src/backend/src/prisma-query-args/tasks.query-args.ts b/src/backend/src/prisma-query-args/tasks.query-args.ts index 1f82c4b23d..84754aeb11 100644 --- a/src/backend/src/prisma-query-args/tasks.query-args.ts +++ b/src/backend/src/prisma-query-args/tasks.query-args.ts @@ -27,7 +27,8 @@ export const getCalendarTaskQueryArgs = (organizationId: string) => organizationId: true, dateDeleted: true, leadId: true, - managerId: true + managerId: true, + name: true } }, createdBy: getUserQueryArgs(organizationId), diff --git a/src/backend/src/prisma-query-args/work-packages.query-args.ts b/src/backend/src/prisma-query-args/work-packages.query-args.ts index 36ffe33223..694044c3cc 100644 --- a/src/backend/src/prisma-query-args/work-packages.query-args.ts +++ b/src/backend/src/prisma-query-args/work-packages.query-args.ts @@ -30,7 +30,7 @@ export const getWorkPackageQueryArgs = (organizationId: string) => orderBy: { dateImplemented: 'asc' } }, blocking: { where: { wbsElement: { dateDeleted: null } }, include: { wbsElement: true } }, - descriptionBullets: { where: { dateDeleted: null }, ...getDescriptionBulletQueryArgs(organizationId) } + descriptionBullets: { where: { dateDeleted: null }, ...getDescriptionBulletQueryArgs(organizationId) }, } }, blockedBy: { where: { dateDeleted: null } }, diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 8161ea418e..4b7b4c9345 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -1677,7 +1677,7 @@ const performSeed: () => Promise = async () => { "of the wheel and put pedal to the metal. Accelerating down straightaways and taking corners with finesse, it's " + 'easy to forget McCauley, in his blue racing jacket and jet black helmet, is racing laps around the roof of ' + "Columbus Parking Garage on Northeastern's Boston campus. But that's the reality of Northeastern Electric " + - 'Racing, a student club that has made due and found massive success in the world of electric racing despite its ' + + 'Racing, a student club that has made do and found massive success in the world of electric racing despite its ' + "relative rookie status. McCauley, NER's chief electrical engineer, has seen the club's car, Cinnamon, go from " + 'a 5-foot drive test to hitting 60 miles per hour in competitions. "It\'s a go-kart that has 110 kilowatts of ' + 'power, 109 kilowatts of power," says McCauley, a fourth-year electrical and computer engineering student. ' + diff --git a/src/backend/src/routes/tasks.routes.ts b/src/backend/src/routes/tasks.routes.ts index c6f6819a06..f0dd4709d7 100644 --- a/src/backend/src/routes/tasks.routes.ts +++ b/src/backend/src/routes/tasks.routes.ts @@ -48,17 +48,27 @@ tasksRouter.post( TasksController.editTask ); -tasksRouter.post('/:taskId/edit-status', isTaskStatus(body('status')), TasksController.editTaskStatus); +tasksRouter.post('/:taskId/edit-status', isTaskStatus(body('status')), validateInputs, TasksController.editTaskStatus); tasksRouter.post( '/:taskId/edit-assignees', body('assignees').isArray(), nonEmptyString(body('assignees.*')), + validateInputs, TasksController.editTaskAssignees ); -tasksRouter.post('/:taskId/delete', TasksController.deleteTask); +tasksRouter.post( + '/:taskId/edit-wbs-element', + nonEmptyString(body('wbsElementId')), + validateInputs, + TasksController.editTaskWbsElement +); + +tasksRouter.post('/:taskId/delete', validateInputs, TasksController.deleteTask); tasksRouter.get('/overdue-by-team-member/:userId', TasksController.getOverdueTasksByTeamLeadership); +tasksRouter.get('/by-wbs/:wbsNum', TasksController.getTasksByWbsNum); + export default tasksRouter; diff --git a/src/backend/src/services/tasks.services.ts b/src/backend/src/services/tasks.services.ts index 1f1b3b9bf5..4ba5db31d0 100644 --- a/src/backend/src/services/tasks.services.ts +++ b/src/backend/src/services/tasks.services.ts @@ -73,15 +73,25 @@ export default class TasksService { wbsElement: true, workPackages: { include: { wbsElement: true } } } + }, + workPackage: { + include: { + project: { + include: { + teams: getTeamQueryArgs(organization.organizationId) + } + } + } } } }); if (!requestedWbsElement) throw new NotFoundException('WBS Element', wbsPipe(wbsNum)); if (requestedWbsElement.dateDeleted) throw new DeletedException('WBS Element', wbsPipe(wbsNum)); - const { project } = requestedWbsElement; - if (!project) throw new HttpException(400, "This task's wbs element is not linked to a project!"); - const { teams } = project; + if (!requestedWbsElement.project && !requestedWbsElement.workPackage) + throw new HttpException(400, "This task's wbs element is not linked to a project or work package!"); + + const teams = requestedWbsElement.project?.teams ?? requestedWbsElement.workPackage?.project?.teams; if (!teams || teams.length === 0) throw new HttpException(400, 'This project needs to be assigned to a team to create a task!'); @@ -173,13 +183,16 @@ export default class TasksService { /** * Edits the status of a task in the database * @param user the user editing the task - * @param organizationId the organizqtion Id + * @param organizationId the organization Id * @param taskId the id of the task * @param status the new status * @returns the updated task * @throws if the task does not exist, the task is already deleted, or if the user does not have permissions */ static async editTaskStatus(user: User, organizationId: string, taskId: string, status: Task_Status) { + const hasPermission = await userHasPermission(user.userId, organizationId, notGuest); + if (!hasPermission) throw new AccessDeniedException('Guests cannot edit tasks'); + // Get the original task and check if it exists const originalTask = await prisma.task.findUnique({ where: { taskId }, include: { assignees: true, wbsElement: true } }); if (!originalTask) throw new NotFoundException('Task', taskId); @@ -190,9 +203,6 @@ export default class TasksService { throw new HttpException(400, 'A task in progress must have a deadline and assignees!'); } - const hasPermission = await userHasPermission(user.userId, organizationId, notGuest); - if (!hasPermission) throw new AccessDeniedException('Guests cannot edit tasks'); - const updatedTask = await prisma.task.update({ where: { taskId }, data: { status }, @@ -216,6 +226,9 @@ export default class TasksService { assignees: string[], organization: Organization ): Promise { + const hasPermission = await userHasPermission(user.userId, organization.organizationId, notGuest); + if (!hasPermission) throw new AccessDeniedException('Guests cannot edit tasks'); + // Get the original task and check if it exists const originalTask = await prisma.task.findUnique({ where: { taskId }, @@ -230,9 +243,6 @@ export default class TasksService { const originalAssigneeIds = originalTask.assignees.map((assignee) => assignee.userId); const newAssigneeIds = assignees.filter((userId) => !originalAssigneeIds.includes(userId)); - const hasPermission = await userHasPermission(user.userId, organization.organizationId, notGuest); - if (!hasPermission) throw new AccessDeniedException('Guests cannot edit tasks'); - // this throws if any of the users aren't found const assigneeUsers = await getUsers(assignees); @@ -261,6 +271,53 @@ export default class TasksService { return updatedTask; } + /** + * Edits the wbs element of a task in the database + * @param user the user editing the task + * @param taskId the id of the task + * @param wbsElementId the id of the new wbs element + * @param organization the organization that the user is currently in + * @returns the updated task + * @throws if the task does not exist, the task is already deleted, the wbs element doesn't exist, or if the user does not have permissions + */ + static async editTaskWbsElement( + user: User, + taskId: string, + wbsElementId: string, + organization: Organization + ): Promise { + const hasPermission = await userHasPermission(user.userId, organization.organizationId, notGuest); + if (!hasPermission) throw new AccessDeniedException('Guests cannot edit tasks'); + + // get the original task and check if it exists + const originalTask = await prisma.task.findUnique({ + where: { taskId }, + include: { wbsElement: true } + }); + + // throw error if there are issues with the task + if (!originalTask) throw new NotFoundException('Task', taskId); + if (originalTask.dateDeleted) throw new DeletedException('Task', taskId); + if (originalTask.wbsElement.organizationId !== organization.organizationId) + throw new InvalidOrganizationException('Task'); + + const newWbsElement = await prisma.wBS_Element.findUnique({ + where: { wbsElementId } + }); + + // throw error if there are issues with the wbs element + if (!newWbsElement) throw new NotFoundException('WBS Element', wbsElementId); + if (newWbsElement.dateDeleted) throw new DeletedException('WBS Element', wbsElementId); + + const updatedTask = await prisma.task.update({ + where: { taskId }, + data: { wbsElement: { connect: { wbsElementId } } }, + ...getTaskQueryArgs(organization.organizationId) + }); + + return taskTransformer(updatedTask); + } + /** * Delete task in the database * @param taskId the id number of the given task @@ -391,4 +448,60 @@ export default class TasksService { return tasks.map(taskCardPreviewTransformer); } + + /** + * Gets all tasks associated with a wbs element + * If the wbs number is a project (workPackageNumber === 0), returns the project's + * own tasks merged with all of its work packages' tasks + * If the wbs number is a work package, returns just that WP's tasks + * @param wbsNum the wbs number to fetch tasks for + * @param organization the organization that the user is currently in + * @returns array of tasks + */ + static async getTasksByWbsNum(wbsNum: WbsNumber, organization: Organization): Promise { + const wbsElement = await prisma.wBS_Element.findUnique({ + where: { + wbsNumber: { + ...wbsNum, + organizationId: organization.organizationId + } + } + }); + + if (!wbsElement) throw new NotFoundException('WBS Element', wbsPipe(wbsNum)); + if (wbsElement.dateDeleted) throw new DeletedException('WBS Element', wbsPipe(wbsNum)); + + // project case, so return project's own tasks and all its wp's tasks + if (wbsNum.workPackageNumber === 0) { + const project = await prisma.project.findUnique({ + where: { wbsElementId: wbsElement.wbsElementId }, + include: { workPackages: { include: { wbsElement: true } } } + }); + + if (!project) throw new NotFoundException('Project', wbsPipe(wbsNum)); + + const wpWbsElementIds = project.workPackages.map((wp) => wp.wbsElementId); + + const tasks = await prisma.task.findMany({ + where: { + dateDeleted: null, + wbsElementId: { in: [wbsElement.wbsElementId, ...wpWbsElementIds] } + }, + ...getTaskQueryArgs(organization.organizationId) + }); + + return tasks.map(taskTransformer); + } + + // work package case, so return just that wp's tasks + const tasks = await prisma.task.findMany({ + where: { + dateDeleted: null, + wbsElementId: wbsElement.wbsElementId + }, + ...getTaskQueryArgs(organization.organizationId) + }); + + return tasks.map(taskTransformer); + } } diff --git a/src/backend/src/transformers/projects.transformer.ts b/src/backend/src/transformers/projects.transformer.ts index e4ba1e146f..9a32665a29 100644 --- a/src/backend/src/transformers/projects.transformer.ts +++ b/src/backend/src/transformers/projects.transformer.ts @@ -62,7 +62,10 @@ const projectTransformer = (project: Prisma.ProjectGetPayload) startDate: calculateProjectStartDate(project.workPackages), endDate: calculateProjectEndDate(project.workPackages), descriptionBullets: wbsElement.descriptionBullets.map(descBulletConverter), - tasks: wbsElement.tasks.map(taskTransformer), + tasks: [ + ...wbsElement.tasks.map(taskTransformer), // this project's tasks + ...project.workPackages.flatMap((wp) => wp.wbsElement.tasks.map(taskTransformer)) // all of this project's work packages' tasks, flattened to one array + ], workPackages: project.workPackages.map(workPackageTransformer), abbreviation: project.abbreviation ?? undefined }; @@ -91,7 +94,10 @@ export const projectGanttTransformer = (project: Prisma.ProjectGetPayload wp.wbsElement.tasks.map(taskTransformer)) // all of this project's work packages' tasks, flattened to one array + ], workPackages: project.workPackages.map(workPackageTransformer), abbreviation: project.abbreviation ?? undefined }; diff --git a/src/backend/src/transformers/tasks.transformer.ts b/src/backend/src/transformers/tasks.transformer.ts index a68be3f693..aaeb416656 100644 --- a/src/backend/src/transformers/tasks.transformer.ts +++ b/src/backend/src/transformers/tasks.transformer.ts @@ -5,11 +5,12 @@ import { convertTaskPriority, convertTaskStatus } from '../utils/tasks.utils.js' import { userTransformer } from './user.transformer.js'; import { CalendarTaskQueryArgs, TaskQueryArgs, TaskPreviewQueryArgs } from '../prisma-query-args/tasks.query-args.js'; -const taskTransformer = (task: Prisma.TaskGetPayload): Task => { +export const taskTransformer = (task: Prisma.TaskGetPayload): Task => { const wbsNum = wbsNumOf(task.wbsElement); return { taskId: task.taskId, wbsNum, + wbsName: task.wbsElement.name, title: task.title, notes: task.notes, deadline: task.deadline ?? undefined, @@ -45,6 +46,7 @@ export const calendarTaskTransformer = (task: Prisma.TaskGetPayload { const errors = validationResult(req); if (!errors.isEmpty()) { diff --git a/src/backend/tests/unmocked/task.test.ts b/src/backend/tests/unmocked/task.test.ts index e84aac8be5..e11f6725ef 100644 --- a/src/backend/tests/unmocked/task.test.ts +++ b/src/backend/tests/unmocked/task.test.ts @@ -1,5 +1,5 @@ import { financeMember, supermanAdmin, theVisitorGuest } from '../test-data/users.test-data.js'; -import { AccessDeniedException, HttpException } from '../../src/utils/errors.utils.js'; +import { AccessDeniedException, HttpException, NotFoundException, DeletedException } from '../../src/utils/errors.utils.js'; import { createTestOrganization, createTestTask, createTestUser, resetUsers } from '../test-utils.js'; import prisma from '../../src/prisma/prisma.js'; import TasksService from '../../src/services/tasks.services.js'; @@ -14,73 +14,171 @@ describe('Task Test', () => { await resetUsers(); }); - test('Setting status to in progress works when task has deadline and assignees', async () => { - const user = await createTestUser(supermanAdmin, organizationId); - const correctTask = await createTestTask( - user, - 'Test', - '', - [user], - 'HIGH', - 'IN_BACKLOG', - organizationId, - new Date('01/23/2023') - ); - await TasksService.editTaskStatus(user, organizationId, correctTask.taskId, 'IN_PROGRESS'); - const updatedTask = await prisma.task.findUnique({ - where: { - taskId: correctTask.taskId - } + describe('Edit task status', () => { + test('Setting status to in progress works when task has deadline and assignees', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const correctTask = await createTestTask( + user, + 'Test', + '', + [user], + 'HIGH', + 'IN_BACKLOG', + organizationId, + new Date('01/23/2023') + ); + await TasksService.editTaskStatus(user, organizationId, correctTask.taskId, 'IN_PROGRESS'); + const updatedTask = await prisma.task.findUnique({ + where: { + taskId: correctTask.taskId + } + }); + // check that status changed to correct status + expect(updatedTask?.status).toBe('IN_PROGRESS'); }); - // check that status changed to correct status - expect(updatedTask?.status).toBe('IN_PROGRESS'); - }); - test('Setting status to in progress does not work when task does not have a deadline and assignees', async () => { - const user = await createTestUser(supermanAdmin, organizationId); - const badTask = await createTestTask(user, 'Test', '', [], 'HIGH', 'DONE', organizationId); - await expect(async () => - TasksService.editTaskStatus( - await createTestUser(financeMember, organizationId), - organizationId, - badTask.taskId, - 'IN_PROGRESS' - ) - ).rejects.toThrow(new HttpException(400, 'A task in progress must have a deadline and assignees!')); - }); + test('Setting status to in progress does not work when task does not have a deadline and assignees', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const badTask = await createTestTask(user, 'Test', '', [], 'HIGH', 'DONE', organizationId); + await expect(async () => + TasksService.editTaskStatus( + await createTestUser(financeMember, organizationId), + organizationId, + badTask.taskId, + 'IN_PROGRESS' + ) + ).rejects.toThrow(new HttpException(400, 'A task in progress must have a deadline and assignees!')); + }); - test('Setting status to in progress does not work when task does not have a deadline, but does have assignees', async () => { - const user = await createTestUser(supermanAdmin, organizationId); - const badTask = await createTestTask(user, 'Test', '', [user], 'HIGH', 'IN_BACKLOG', organizationId); - await expect(async () => - TasksService.editTaskStatus( - await createTestUser(financeMember, organizationId), - organizationId, - badTask.taskId, - 'IN_PROGRESS' - ) - ).rejects.toThrow(new HttpException(400, 'A task in progress must have a deadline and assignees!')); + test('Setting status to in progress does not work when task does not have a deadline, but does have assignees', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const badTask = await createTestTask(user, 'Test', '', [user], 'HIGH', 'IN_BACKLOG', organizationId); + await expect(async () => + TasksService.editTaskStatus( + await createTestUser(financeMember, organizationId), + organizationId, + badTask.taskId, + 'IN_PROGRESS' + ) + ).rejects.toThrow(new HttpException(400, 'A task in progress must have a deadline and assignees!')); + }); + + test('Setting status to in progress does not work when task does not have assignees, but does have a deadline', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const badTask = await createTestTask(user, 'Test', '', [], 'HIGH', 'DONE', organizationId, new Date()); + await expect(async () => + TasksService.editTaskStatus( + await createTestUser(financeMember, organizationId), + organizationId, + badTask.taskId, + 'IN_PROGRESS' + ) + ).rejects.toThrow(new HttpException(400, 'A task in progress must have a deadline and assignees!')); + }); }); - test('Setting status to in progress does not work when task does not have assignees, but does have a deadline', async () => { - const user = await createTestUser(supermanAdmin, organizationId); - const badTask = await createTestTask(user, 'Test', '', [], 'HIGH', 'DONE', organizationId, new Date()); - await expect(async () => - TasksService.editTaskStatus( - await createTestUser(financeMember, organizationId), - organizationId, - badTask.taskId, - 'IN_PROGRESS' - ) - ).rejects.toThrow(new HttpException(400, 'A task in progress must have a deadline and assignees!')); + describe('Edit task WBS element', () => { + test('Successfully updates the wbs element of a task', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const task = await createTestTask(user, 'Test Task', '', [], 'HIGH', 'IN_BACKLOG', organizationId); + + const newWbsElement = await prisma.wBS_Element.create({ + data: { + name: 'New WBS', + status: 'INACTIVE', + carNumber: 1, + projectNumber: 1, + workPackageNumber: 0, + dateCreated: new Date('01/01/2023'), + leadId: user.userId, + managerId: user.userId, + organizationId + } + }); + + const updatedTask = await TasksService.editTaskWbsElement(user, task.taskId, newWbsElement.wbsElementId, { + organizationId + } as any); + + expect(updatedTask.taskId).toBe(task.taskId); + }); + + test('Throws NotFoundException when task does not exist', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const task = await createTestTask(user, 'Test Task', '', [], 'HIGH', 'IN_BACKLOG', organizationId); + + await expect(async () => + TasksService.editTaskWbsElement(user, 'non-existent-task-id', task.wbsElementId, { organizationId } as any) + ).rejects.toThrow(new NotFoundException('Task', 'non-existent-task-id')); + }); + + test('Throws DeletedException when task is deleted', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const task = await createTestTask(user, 'Test Task', '', [], 'HIGH', 'IN_BACKLOG', organizationId); + + await prisma.task.update({ + where: { taskId: task.taskId }, + data: { dateDeleted: new Date() } + }); + + await expect(async () => + TasksService.editTaskWbsElement(user, task.taskId, task.wbsElementId, { organizationId } as any) + ).rejects.toThrow(new DeletedException('Task', task.taskId)); + }); + + test('Throws NotFoundException when new wbs element does not exist', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const task = await createTestTask(user, 'Test Task', '', [], 'HIGH', 'IN_BACKLOG', organizationId); + + await expect(async () => + TasksService.editTaskWbsElement(user, task.taskId, 'non-existent-wbs-element-id', { organizationId } as any) + ).rejects.toThrow(new NotFoundException('WBS Element', 'non-existent-wbs-element-id')); + }); + + test('Throws DeletedException when new wbs element is deleted', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const task = await createTestTask(user, 'Test Task', '', [], 'HIGH', 'IN_BACKLOG', organizationId); + + // create second wbs element and delete it + const deletedWbsElement = await prisma.wBS_Element.create({ + data: { + name: 'Deleted WBS', + status: 'INACTIVE', + carNumber: 99, + projectNumber: 99, + workPackageNumber: 0, + dateCreated: new Date('01/01/2023'), + leadId: user.userId, + managerId: user.userId, + organizationId, + dateDeleted: new Date() + } + }); + + await expect(async () => + TasksService.editTaskWbsElement(user, task.taskId, deletedWbsElement.wbsElementId, { organizationId } as any) + ).rejects.toThrow(new DeletedException('WBS Element', deletedWbsElement.wbsElementId)); + }); }); - test('Guests cannot edit tasks', async () => { - const guest = await createTestUser(theVisitorGuest, organizationId); - const admin = await createTestUser(supermanAdmin, organizationId); - const task = await createTestTask(admin, 'Test', '', [], 'HIGH', 'DONE', organizationId, new Date()); - await expect(async () => - TasksService.editTask(guest, organizationId, task.taskId, 'Title', 'Notes', 'HIGH', new Date()) - ).rejects.toThrow(new AccessDeniedException('Guests cannot edit tasks')); + describe('Guest editing permissions', () => { + test('Guests cannot edit tasks', async () => { + const guest = await createTestUser(theVisitorGuest, organizationId); + const admin = await createTestUser(supermanAdmin, organizationId); + const task = await createTestTask(admin, 'Test', '', [], 'HIGH', 'DONE', organizationId, new Date()); + await expect(async () => + TasksService.editTask(guest, organizationId, task.taskId, 'Title', 'Notes', 'HIGH', new Date()) + ).rejects.toThrow(new AccessDeniedException('Guests cannot edit tasks')); + }); + + test('Guests cannot edit task wbs element', async () => { + const admin = await createTestUser(supermanAdmin, organizationId); + const guest = await createTestUser(theVisitorGuest, organizationId); + const task = await createTestTask(admin, 'Test Task', '', [], 'HIGH', 'IN_BACKLOG', organizationId); + + await expect(async () => + TasksService.editTaskWbsElement(guest, task.taskId, task.wbsElementId, { organizationId } as any) + ).rejects.toThrow(new AccessDeniedException('Guests cannot edit tasks')); + }); }); }); diff --git a/src/frontend/src/apis/tasks.api.ts b/src/frontend/src/apis/tasks.api.ts index 4fd2c05270..e19c635e25 100644 --- a/src/frontend/src/apis/tasks.api.ts +++ b/src/frontend/src/apis/tasks.api.ts @@ -102,6 +102,24 @@ export const editTaskAssignees = (taskId: string, assignees: string[]) => { ); }; +/** + * Sets the task's wbs element. + * @param taskId the id of the task + * @param wbsElementId the id of the new wbs element + * @returns the edited task + */ +export const editTaskWbsElement = (taskId: string, wbsElementId: string) => { + return axios.post( + apiUrls.editTaskWbsElement(taskId), + { + wbsElementId + }, + { + transformResponse: (data) => taskTransformer(JSON.parse(data)) + } + ); +}; + /** * Sets the task's status. * @param id the id of the task @@ -139,3 +157,16 @@ export const getOverdueTasksByTeamLeader = (userId: string) => { transformResponse: (data) => JSON.parse(data).map(taskTransformer) }); }; + +/** + * Gets all tasks for a given WBS element + * For projects, returns project tasks merged with all project's wp's tasks + * For work packages, returns just that wp's tasks + * @param wbsNum the wbs number to fetch tasks for + * @returns array of tasks + */ +export const getTasksByWbsNum = (wbsNum: WbsNumber) => { + return axios.get(apiUrls.tasksByWbsNum(wbsPipe(wbsNum)), { + transformResponse: (data) => JSON.parse(data).map(taskTransformer) + }); +}; diff --git a/src/frontend/src/hooks/tasks.hooks.ts b/src/frontend/src/hooks/tasks.hooks.ts index 9b752dd6f5..0ebf06e936 100644 --- a/src/frontend/src/hooks/tasks.hooks.ts +++ b/src/frontend/src/hooks/tasks.hooks.ts @@ -11,9 +11,12 @@ import { editSingleTaskStatus, editTask, editTaskAssignees, + editTaskWbsElement, getOverdueTasksByTeamLeader, - getFilterTasks + getFilterTasks, + getTasksByWbsNum } from '../apis/tasks.api'; +import { wbsPipe } from '../utils/pipes'; export interface CreateTaskPayload { wbsNum: WbsNumber; @@ -81,7 +84,7 @@ export interface TaskPayload { /** * Custom React Hook for editing a task - * @returns the edit task mutation' + * @returns the edit task mutation */ export const useEditTask = () => { const queryClient = useQueryClient(); @@ -129,6 +132,27 @@ export const useEditTaskAssignees = () => { ); }; +/** + * custom react hook for editing a task's wbs element + * @returns the edit task wbs element mutation + */ +export const useEditTaskWbsElement = () => { + const queryClient = useQueryClient(); + return useMutation( + ['tasks', 'edit-wbs-element'], + async (editWbsElementTaskPayload: { taskId: string; wbsElementId: string }) => { + const { data } = await editTaskWbsElement(editWbsElementTaskPayload.taskId, editWbsElementTaskPayload.wbsElementId); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['projects']); + queryClient.invalidateQueries(['filter-tasks']); + } + } + ); +}; + /** * custom react hook for editing task status * @returns the edit task status mutation @@ -176,3 +200,17 @@ export const useOverdueTasksByTeamLeader = (userId: string) => { return data; }); }; + +/** + * Custom React Hook to get all tasks for a given wbs element + * For projects, returns project tasks merged with all project's wp's tasks + * For work packages, returns just that wp's tasks + * @param wbsNum the wbs number to fetch tasks for + * @returns the tasks query + */ +export const useTasksByWbsNum = (wbsNum: WbsNumber) => { + return useQuery(['tasks', wbsPipe(wbsNum)], async () => { + const { data } = await getTasksByWbsNum(wbsNum); + return data; + }); +}; diff --git a/src/frontend/src/pages/CalendarPage/TaskClickPopup.tsx b/src/frontend/src/pages/CalendarPage/TaskClickPopup.tsx index 9733689929..d5db088240 100644 --- a/src/frontend/src/pages/CalendarPage/TaskClickPopup.tsx +++ b/src/frontend/src/pages/CalendarPage/TaskClickPopup.tsx @@ -235,7 +235,6 @@ export const TaskClickContent: React.FC = ({ task, onClos {showEditModal && ( setShowEditModal(false)} onSubmit={handleEditSubmit} diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx index e38991c484..7f5b687b17 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx @@ -301,6 +301,7 @@ const ProjectGanttChartPage: FC = () => { teamTypes: [], changes: [], events: [], + tasks: [], deleted: false }; @@ -349,6 +350,7 @@ const ProjectGanttChartPage: FC = () => { const newTask: Task = { taskId, wbsNum: parentProject.wbsNum, + wbsName: parentProject.name, title: taskInfo.title, notes: taskInfo.notes, dateCreated: new Date(), diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx index e4e0eac0cf..62e377d877 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx @@ -2,7 +2,7 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { Autocomplete, FormControl, FormHelperText, FormLabel, Grid, MenuItem, TextField } from '@mui/material'; import { DatePicker } from '@mui/x-date-pickers'; import { Controller, useForm } from 'react-hook-form'; -import { countWords, isGuest, isUnderWordCount, Task, TaskPriority, TeamPreview } from 'shared'; +import { countWords, isGuest, isUnderWordCount, Task, TaskPriority, WorkPackage, WbsNumber } from 'shared'; import { useAllUsers, useCurrentUser } from '../../../../hooks/users.hooks'; import * as yup from 'yup'; import { taskUserToAutocompleteOption } from '../../../../utils/task.utils'; @@ -17,7 +17,8 @@ const schema = yup.object().shape({ priority: yup.mixed().oneOf(Object.values(TaskPriority)).required(), assignees: yup.array().required(), title: yup.string().required(), - taskId: yup.string().required() + taskId: yup.string().required(), + wpWbsNum: yup.mixed().optional() }); export interface EditTaskFormInput { @@ -28,18 +29,28 @@ export interface EditTaskFormInput { startDate?: Date; deadline?: Date; priority: TaskPriority; + wpWbsNum?: WbsNumber; } interface TaskFormModalProps { task?: Task; - teams: TeamPreview[]; modalShow: boolean; onHide: () => void; onSubmit: (data: EditTaskFormInput) => Promise; onReset?: () => void; + workPackages?: WorkPackage[]; + lockedWorkPackage?: WorkPackage; } -const TaskFormModal: React.FC = ({ task, onSubmit, modalShow, onHide, onReset }) => { +const TaskFormModal: React.FC = ({ + task, + onSubmit, + modalShow, + onHide, + onReset, + workPackages, + lockedWorkPackage +}) => { const user = useCurrentUser(); const { data: users, isLoading, isError, error } = useAllUsers(); @@ -58,14 +69,19 @@ const TaskFormModal: React.FC = ({ task, onSubmit, modalShow startDate: task?.startDate ?? undefined, deadline: task?.deadline ?? undefined, priority: task?.priority ?? TaskPriority.Low, - assignees: task?.assignees.map((assignee) => assignee.userId) ?? [] + assignees: task?.assignees.map((assignee) => assignee.userId) ?? [], + wpWbsNum: task?.wbsNum.workPackageNumber !== 0 ? task?.wbsNum : undefined } }); if (isError) return ; if (isLoading || !users) return ; - const options: { label: string; id: string }[] = users.map(taskUserToAutocompleteOption); + const userOptions: { label: string; id: string }[] = users.map(taskUserToAutocompleteOption); + const wpOptions: { label: string; wbsNum: WbsNumber }[] = (workPackages ?? []).map((wp) => ({ + label: wp.name, + wbsNum: wp.wbsNum + })); const unUpperCase = (str: string) => str.charAt(0) + str.slice(1).toLowerCase(); @@ -140,6 +156,37 @@ const TaskFormModal: React.FC = ({ task, onSubmit, modalShow /> + {/* wrap with WP check so dropdown doesn't render if within WP tasks tab*/} + {workPackages && ( + + + Work Package + ( + + option.wbsNum.workPackageNumber === val.wbsNum.workPackageNumber + } + getOptionLabel={(option) => option.label} + onChange={(_, val) => onChange(val?.wbsNum ?? undefined)} + value={wpOptions.find((o) => o.wbsNum.workPackageNumber === value?.workPackageNumber) ?? null} + renderInput={(params) => ( + + )} + /> + )} + /> + + + )} Assignees @@ -152,12 +199,12 @@ const TaskFormModal: React.FC = ({ task, onSubmit, modalShow filterSelectedOptions multiple id="tags-standard" - options={options} + options={userOptions} getOptionLabel={(option) => option.label} onChange={(_, value) => onChange(value.map((v) => v.id))} - value={value.map((v) => options.find((o) => o.id === v)!)} + value={value.map((v) => userOptions.find((o) => o.id === v)!)} renderInput={(params) => ( - + )} /> )} diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx index 25fd18f6b5..b2397e265c 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx @@ -3,7 +3,7 @@ * See the LICENSE file in the repository root folder for details. */ -import { TeamPreview } from 'shared'; +import { WorkPackage } from 'shared'; import { fullNamePipe, datePipe } from '../../../../utils/pipes'; import { Task } from 'shared'; import { Box, Grid, Typography } from '@mui/material'; @@ -13,16 +13,18 @@ import NERModal from '../../../../components/NERModal'; interface TaskModalProps { task: Task; - teams: TeamPreview[]; modalShow: boolean; onHide: () => void; onSubmit: (data: EditTaskFormInput) => Promise; hasEditPermissions: boolean; + workPackages?: WorkPackage[]; } -const TaskModal: React.FC = ({ task, teams, modalShow, onHide, onSubmit, hasEditPermissions }) => { +const TaskModal: React.FC = ({ task, modalShow, onHide, onSubmit, hasEditPermissions, workPackages }) => { const [isEditMode, setIsEditMode] = useState(false); const priorityColor = task.priority === 'HIGH' ? '#ef4345' : task.priority === 'LOW' ? '#00ab41' : '#FFA500'; + const isWpTask = task.wbsNum.workPackageNumber !== 0; + const ViewModal: React.FC = () => { return ( = ({ task, teams, modalShow, onHide, o {task.assignees.map((user) => fullNamePipe(user)).join(', ')} + {isWpTask && ( + + + Work Package: + {task.wbsName} + + + )} Notes: @@ -89,13 +99,13 @@ const TaskModal: React.FC = ({ task, teams, modalShow, onHide, o return isEditMode ? ( { setIsEditMode(false); }} + workPackages={workPackages} /> ) : ( diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx index 7ae82d28c4..2c233769b6 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx @@ -2,31 +2,37 @@ import { Draggable } from '@hello-pangea/dnd'; import { Construction, Delete, Schedule } from '@mui/icons-material'; import { Box, Card, CardContent, Chip, Grid, Typography, IconButton } from '@mui/material'; import { useState } from 'react'; -import { notGuest, Project, Task } from 'shared'; -import { useDeleteTask, useEditTask, useEditTaskAssignees } from '../../../../../hooks/tasks.hooks'; +import { notGuest, Task, WorkPackage } from 'shared'; +import { useDeleteTask, useEditTask, useEditTaskAssignees, useEditTaskWbsElement } from '../../../../../hooks/tasks.hooks'; import { useToast } from '../../../../../hooks/toasts.hooks'; import { useCurrentUser } from '../../../../../hooks/users.hooks'; import { datePipe, fullNamePipe } from '../../../../../utils/pipes'; import { EditTaskFormInput } from '../TaskFormModal'; import TaskModal from '../TaskModal'; import NERModal from '../../../../../components/NERModal'; +import { Link as RouterLink } from 'react-router-dom'; +import { routes } from '../../../../../utils/routes'; +import { wbsPipe } from '../../../../../utils/pipes'; export const TaskCard = ({ task, index, - project, + wbsElementId, + workPackages, onDeleteTask, onEditTask }: { task: Task; index: number; - project: Project; + wbsElementId: string; + workPackages?: WorkPackage[]; onDeleteTask: (taskId: string) => void; onEditTask: (task: Task) => void; }) => { const { mutateAsync: deleteTask } = useDeleteTask(); const { mutateAsync: editTask } = useEditTask(); const { mutateAsync: editTaskAssignees } = useEditTaskAssignees(); + const { mutateAsync: editTaskWbsElement } = useEditTaskWbsElement(); const user = useCurrentUser(); @@ -52,7 +58,16 @@ export const TaskCard = ({ setShowDeleteConfirm(false); }; - const handleEditTask = async ({ taskId, notes, title, deadline, assignees, priority, startDate }: EditTaskFormInput) => { + const handleEditTask = async ({ + taskId, + notes, + title, + deadline, + assignees, + priority, + startDate, + wpWbsNum + }: EditTaskFormInput) => { try { await editTask({ taskId, @@ -62,10 +77,23 @@ export const TaskCard = ({ startDate, priority }); - const newTask = await editTaskAssignees({ + + let newTask = await editTaskAssignees({ taskId, assignees }); + + // check if wp changed first to avoid unnecessary api call + const wpChanged = (wpWbsNum?.workPackageNumber ?? 0) !== task.wbsNum.workPackageNumber; + if (wpChanged) { + const targetWbsElementId = wpWbsNum + ? workPackages?.find((wp) => wp.wbsNum.workPackageNumber === wpWbsNum.workPackageNumber)?.wbsElementId + : wbsElementId; + if (targetWbsElementId) { + newTask = await editTaskWbsElement({ taskId, wbsElementId: targetWbsElementId }); + } + } + onEditTask(newTask); toast.success('Task edited successfully!'); } catch (error: unknown) { @@ -78,16 +106,17 @@ export const TaskCard = ({ const priorityColor = task.priority === 'HIGH' ? '#ef4345' : task.priority === 'LOW' ? '#00ab41' : '#FFA500'; const isOverdue = task.deadline != null && new Date(task.deadline) < new Date() && task.status !== 'DONE'; + const isWpTask = task.wbsNum.workPackageNumber !== 0; return ( <> setShowModal(false)} onSubmit={handleEditTask} hasEditPermissions={notGuest(user.role)} + workPackages={workPackages} /> + {isWpTask && // render iff task does have associated wp + workPackages && ( // and if on project's task page, not wp's + + )} diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskColumn.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskColumn.tsx index 03730800c1..18fa35eda3 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskColumn.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskColumn.tsx @@ -1,7 +1,7 @@ import { Droppable } from '@hello-pangea/dnd'; import { Box, Typography, useTheme } from '@mui/material'; import { useEffect, useRef, useState } from 'react'; -import { Project, Task, TaskStatus, TaskWithIndex } from 'shared'; +import { Task, TaskStatus, TaskWithIndex, WbsNumber, WorkPackage } from 'shared'; import { statusNames, TaskCard } from '.'; import { NERButton } from '../../../../../components/NERButton'; import { useCreateTask } from '../../../../../hooks/tasks.hooks'; @@ -12,7 +12,8 @@ import TaskFormModal, { EditTaskFormInput } from '../TaskFormModal'; export const TaskColumn = ({ status, tasks, - project, + wbsNum, + wbsElementId, equalizedHeight, isDragging, onEditTask, @@ -22,7 +23,8 @@ export const TaskColumn = ({ }: { status: TaskStatus; tasks: TaskWithIndex[]; - project: Project; + wbsNum: WbsNumber; + wbsElementId: string; equalizedHeight: number; isDragging: boolean; onEditTask: (task: Task) => void; @@ -46,10 +48,18 @@ export const TaskColumn = ({ return () => observer.disconnect(); }, [status, onHeightChange]); - const handleCreateTask = async ({ notes, title, deadline, assignees, priority, startDate }: EditTaskFormInput) => { + const handleCreateTask = async ({ + notes, + title, + deadline, + assignees, + priority, + startDate, + wpWbsNum + }: EditTaskFormInput) => { try { const task = await createTask({ - wbsNum: project.wbsNum, + wbsNum: wpWbsNum ?? wbsNum, title, deadline: deadline ? toDateString(deadline) : undefined, startDate: startDate ? toDateString(startDate) : undefined, @@ -74,7 +84,7 @@ export const TaskColumn = ({ onSubmit={handleCreateTask} onHide={() => setShowCreateTaskModal(false)} modalShow={showCreateTaskModal} - teams={project.teams} + workPackages={workPackages} /> ))} {droppableProvided.placeholder} diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskList.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskList.tsx index 5f440961de..34c8dcdcda 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskList.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskList.tsx @@ -6,5 +6,9 @@ import { GuestsTasksList } from '../GuestTasksList'; export const TaskList = ({ project, isGuest }: { project: Project; isGuest: boolean }) => { const isSmall = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm')); - return isSmall || isGuest ? : ; + return isSmall || isGuest ? ( + + ) : ( + + ); }; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskListContent.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskListContent.tsx index 4ca6cbb121..20fd3bc938 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskListContent.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskListContent.tsx @@ -1,20 +1,23 @@ import { DragDropContext, OnDragEndResponder, OnDragStartResponder } from '@hello-pangea/dnd'; import { Box } from '@mui/material'; -import { useCallback, useState } from 'react'; -import { Project, Task, TaskStatus, TaskWithIndex } from 'shared'; +import { useCallback, useState, useEffect } from 'react'; +import { Task, TaskStatus, TaskWithIndex, WbsNumber } from 'shared'; import { getTasksByStatus, statuses, TasksByStatus } from '.'; -import { useSetTaskStatus } from '../../../../../hooks/tasks.hooks'; +import { useSetTaskStatus, useTasksByWbsNum } from '../../../../../hooks/tasks.hooks'; import { useToast } from '../../../../../hooks/toasts.hooks'; import { TaskColumn } from './TaskColumn'; import confetti from 'canvas-confetti'; +import LoadingIndicator from '../../../../../components/LoadingIndicator'; +import ErrorPage from '../../../../ErrorPage'; -interface TaskListProps { - project: Project; +interface TaskListContentProps { + wbsNum: WbsNumber; + wbsElementId: string; } -export const TaskListContent = ({ project }: TaskListProps) => { - const { tasks } = project; - const [tasksByStatus, setTasksByStatus] = useState(getTasksByStatus(tasks)); +export const TaskListContent = ({ wbsNum, wbsElementId }: TaskListContentProps) => { + const { data: tasks, isLoading, isError, error } = useTasksByWbsNum(wbsNum); + const [tasksByStatus, setTasksByStatus] = useState(); // can't use getTasksByStatus since tasks are async const { mutateAsync: setTaskStatus } = useSetTaskStatus(); const toast = useToast(); @@ -23,10 +26,20 @@ export const TaskListContent = ({ project }: TaskListProps) => { const [columnHeights, setColumnHeights] = useState>>({}); const equalizedHeight = Math.max(...(Object.values(columnHeights) as number[])); + // initialize tasksByStatus once tasks load, but only once + useEffect(() => { + if (tasks && !tasksByStatus) { + setTasksByStatus(getTasksByStatus(tasks)); + } + }, [tasks]); + const onHeightChange = useCallback((status: TaskStatus, height: number) => { setColumnHeights((prev) => ({ ...prev, [status]: height })); }, []); + if (isLoading || !tasksByStatus) return ; + if (isError) return ; + const onDeleteTask = (taskId: string) => { setTasksByStatus((prev) => { const newTasksByStatus = { ...prev }; @@ -134,7 +147,8 @@ export const TaskListContent = ({ project }: TaskListProps) => { status={status} tasks={tasksByStatus[status]} key={status} - project={project} + wbsNum={wbsNum} + wbsElementId={wbsElementId} equalizedHeight={equalizedHeight} isDragging={isDragging} /> diff --git a/src/frontend/src/pages/WorkPackageDetailPage/WorkPackageViewContainer/WorkPackageViewContainer.tsx b/src/frontend/src/pages/WorkPackageDetailPage/WorkPackageViewContainer/WorkPackageViewContainer.tsx index 0bc079f640..49f52c3747 100644 --- a/src/frontend/src/pages/WorkPackageDetailPage/WorkPackageViewContainer/WorkPackageViewContainer.tsx +++ b/src/frontend/src/pages/WorkPackageDetailPage/WorkPackageViewContainer/WorkPackageViewContainer.tsx @@ -26,6 +26,7 @@ import ScopeTab from './ScopeTab'; import FullPageTabs from '../../../components/FullPageTabs'; import ChangeRequestTab from '../../../components/ChangeRequestTab'; import ActionsMenu, { ButtonInfo } from '../../../components/ActionsMenu'; +import { TaskListContent } from '../../ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskListContent'; interface WorkPackageViewContainerProps { workPackage: WorkPackage; @@ -52,7 +53,6 @@ const WorkPackageViewContainer: React.FC = ({ const [, setAnchorEl] = useState(null); const { data: dependencies, isError, isLoading, error } = useGetManyWorkPackages(workPackage.blockedBy); const wbsNum = wbsPipe(workPackage.wbsNum); - const [tabValue, setTabValue] = useState(0); if (!dependencies || isLoading) return ; @@ -143,6 +143,7 @@ const WorkPackageViewContainer: React.FC = ({ setTab={setTabValue} tabsLabels={[ { tabUrlValue: 'overview', tabName: 'Overview' }, + { tabUrlValue: 'tasks', tabName: 'Tasks' }, { tabUrlValue: 'scope', tabName: 'Scope' }, { tabUrlValue: 'changes', tabName: 'Changes' }, { tabUrlValue: 'change-requests', tabName: 'Change Requests' } @@ -156,8 +157,12 @@ const WorkPackageViewContainer: React.FC = ({ {tabValue === 0 ? ( ) : tabValue === 1 ? ( - + !allowEdit ? null : ( + + ) ) : tabValue === 2 ? ( + + ) : tabValue === 3 ? ( ) : ( diff --git a/src/frontend/src/tests/test-support/mock-hooks.ts b/src/frontend/src/tests/test-support/mock-hooks.ts index a2c6363e47..14d7c1a519 100644 --- a/src/frontend/src/tests/test-support/mock-hooks.ts +++ b/src/frontend/src/tests/test-support/mock-hooks.ts @@ -64,6 +64,7 @@ export const mockEditProjectReturnValue = mockUseMutationResult( status: TaskStatus.IN_PROGRESS, priority: TaskPriority.Medium, wbsNum: { carNumber: 1, projectNumber: 1, workPackageNumber: 0 }, + wbsName: 'WP', notes: '', dateCreated: new Date(), createdBy: exampleAdminUser, @@ -82,6 +83,7 @@ export const mockCreateTaskReturnValue = mockUseMutationResult( status: TaskStatus.IN_PROGRESS, priority: TaskPriority.Medium, wbsNum: { carNumber: 1, projectNumber: 1, workPackageNumber: 0 }, + wbsName: 'WP', notes: '', dateCreated: new Date(), createdBy: exampleAdminUser, @@ -107,6 +109,7 @@ export const mockEditTaskAssigneesReturnValue = mockUseMutationResult( status: TaskStatus.IN_PROGRESS, priority: TaskPriority.Medium, wbsNum: { carNumber: 1, projectNumber: 1, workPackageNumber: 0 }, + wbsName: 'WP', notes: '', dateCreated: new Date(), createdBy: exampleAdminUser, diff --git a/src/frontend/src/tests/test-support/test-data/tasks.stub.ts b/src/frontend/src/tests/test-support/test-data/tasks.stub.ts index d645c1cccb..c689443415 100644 --- a/src/frontend/src/tests/test-support/test-data/tasks.stub.ts +++ b/src/frontend/src/tests/test-support/test-data/tasks.stub.ts @@ -10,6 +10,7 @@ import { exampleWbsProject1 } from './wbs-numbers.stub'; export const exampleTask1: Task = { taskId: 'i8f-rotwyv', wbsNum: exampleWbsProject1, + wbsName: 'WP', title: 'Sketches', notes: 'drafting the sketches with very straight lines', dateCreated: new Date('2023-03-04T00:00:00-05:00'), @@ -23,6 +24,7 @@ export const exampleTask1: Task = { export const exampleTask1DueSoon: Task = { taskId: 'i8f-rotwyv', wbsNum: exampleWbsProject1, + wbsName: 'WP', title: 'Sketches', notes: 'drafting the sketches with very straight lines', dateCreated: new Date('2023-03-04T00:00:00-05:00'), diff --git a/src/frontend/src/tests/test-support/test-data/work-packages.stub.ts b/src/frontend/src/tests/test-support/test-data/work-packages.stub.ts index 25a104a2be..3a1b1f3ab2 100644 --- a/src/frontend/src/tests/test-support/test-data/work-packages.stub.ts +++ b/src/frontend/src/tests/test-support/test-data/work-packages.stub.ts @@ -55,7 +55,8 @@ export const exampleResearchWorkPackage: WorkPackage = { stage: WorkPackageStage.Research, blocking: [], teamTypes: [], - events: [] + events: [], + tasks: [] }; export const exampleDesignWorkPackage: WorkPackage = { @@ -101,7 +102,8 @@ export const exampleDesignWorkPackage: WorkPackage = { stage: WorkPackageStage.Design, blocking: [], teamTypes: [], - events: [] + events: [], + tasks: [] }; export const exampleManufacturingWorkPackage: WorkPackage = { @@ -137,7 +139,8 @@ export const exampleManufacturingWorkPackage: WorkPackage = { stage: WorkPackageStage.Manufacturing, blocking: [], teamTypes: [], - events: [] + events: [], + tasks: [] }; export const exampleInstallWorkPackage: WorkPackage = { @@ -173,7 +176,8 @@ export const exampleInstallWorkPackage: WorkPackage = { blocking: [], teamTypes: [], events: [], - deleted: false + deleted: false, + tasks: [] }; export const exampleWorkPackage5: WorkPackage = { @@ -208,7 +212,8 @@ export const exampleWorkPackage5: WorkPackage = { projectName: 'project3', blocking: [], teamTypes: [], - events: [] + events: [], + tasks: [] }; export const exampleAllWorkPackages: WorkPackage[] = [ diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 07493fd150..145f779643 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -94,9 +94,11 @@ const tasksCreate = (wbsNum: string) => `${tasks()}/${wbsNum}`; const taskEditStatus = (taskId: string) => `${tasks()}/${taskId}/edit-status`; const editTaskById = (taskId: string) => `${tasks()}/${taskId}/edit`; const editTaskAssignees = (taskId: string) => `${tasks()}/${taskId}/edit-assignees`; +const editTaskWbsElement = (taskId: string) => `${tasks()}/${taskId}/edit-wbs-element`; const deleteTask = (taskId: string) => `${tasks()}/${taskId}/delete`; const tasksFilter = () => `${tasks()}/filter`; const overdueTasksByTeamLeadership = (userId: string) => `${tasks()}/overdue-by-team-member/${userId}`; +const tasksByWbsNum = (wbsNum: string) => `${tasks()}/by-wbs/${wbsNum}`; /**************** Work Packages Endpoints ****************/ const workPackages = (queryParams?: { [field: string]: string }) => { @@ -591,8 +593,10 @@ export const apiUrls = { editTaskById, taskEditStatus, editTaskAssignees, + editTaskWbsElement, deleteTask, overdueTasksByTeamLeadership, + tasksByWbsNum, workPackages, workPackagesByWbsNum, diff --git a/src/shared/src/types/task-types.ts b/src/shared/src/types/task-types.ts index 8f26e6092c..fd5ba4a80d 100644 --- a/src/shared/src/types/task-types.ts +++ b/src/shared/src/types/task-types.ts @@ -21,6 +21,7 @@ export enum TaskStatus { export interface Task { taskId: string; wbsNum: WbsNumber; + wbsName: string; title: string; notes: string; dateDeleted?: Date;