diff --git a/src/backend/src/controllers/tasks.controllers.ts b/src/backend/src/controllers/tasks.controllers.ts index 5ba914ff20..b130e6a41b 100644 --- a/src/backend/src/controllers/tasks.controllers.ts +++ b/src/backend/src/controllers/tasks.controllers.ts @@ -29,7 +29,7 @@ export default class TasksController { static async editTask(req: Request, res: Response, next: NextFunction) { try { - const { title, notes, priority, deadline, startDate } = req.body; + const { title, notes, priority, deadline, startDate, wbsElementId } = req.body; const { taskId } = req.params as Record; const updateTask = await TasksService.editTask( @@ -40,7 +40,8 @@ export default class TasksController { notes, priority, startDate ? new Date(startDate) : undefined, - deadline ? new Date(deadline) : undefined + deadline ? new Date(deadline) : undefined, + wbsElementId ); res.status(200).json(updateTask); @@ -122,4 +123,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/controllers/work-packages.controllers.ts b/src/backend/src/controllers/work-packages.controllers.ts index 2c0b34ddf9..ec5aceb4a1 100644 --- a/src/backend/src/controllers/work-packages.controllers.ts +++ b/src/backend/src/controllers/work-packages.controllers.ts @@ -57,6 +57,17 @@ export default class WorkPackagesController { } } + // fetch all work packages for the given project wbs number + static async getWorkPackagesByProject(req: Request, res: Response, next: NextFunction) { + try { + const projectWbsNum: WbsNumber = validateWBS(req.params.wbsNum as string); + const workPackages = await WorkPackagesService.getWorkPackagesByProject(projectWbsNum, req.organization); + res.status(200).json(workPackages); + } catch (error: unknown) { + next(error); + } + } + // Create a work package with the given details static async createWorkPackage(req: Request, res: Response, next: NextFunction) { try { 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..794cf29f23 100644 --- a/src/backend/src/routes/tasks.routes.ts +++ b/src/backend/src/routes/tasks.routes.ts @@ -45,20 +45,24 @@ tasksRouter.post( isOptionalDateOnly(body('deadline')), isOptionalDateOnly(body('startDate')), isTaskPriority(body('priority')), + nonEmptyString(body('wbsElementId').optional()), 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/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/routes/work-packages.routes.ts b/src/backend/src/routes/work-packages.routes.ts index 328bcad2a5..2d7714e1c3 100644 --- a/src/backend/src/routes/work-packages.routes.ts +++ b/src/backend/src/routes/work-packages.routes.ts @@ -30,6 +30,9 @@ workPackagesRouter.post( WorkPackagesController.getManyWorkPackages ); workPackagesRouter.get('/:wbsNum', WorkPackagesController.getSingleWorkPackage); + +workPackagesRouter.get('/by-project/:wbsNum', WorkPackagesController.getWorkPackagesByProject); + workPackagesRouter.post( '/create', nonEmptyString(body('crId').optional()), diff --git a/src/backend/src/services/tasks.services.ts b/src/backend/src/services/tasks.services.ts index 1f1b3b9bf5..906a74b4c1 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!'); @@ -136,7 +146,9 @@ export default class TasksService { * @param title the new title for the task * @param notes the new notes for the task * @param priority the new priority for the task + * @param startDate the new start date for the task * @param deadline the new deadline for the task + * @param wbsElementId the new wbs element id for the task * @returns the sucessfully edited task */ static async editTask( @@ -147,24 +159,40 @@ export default class TasksService { notes: string, priority: Task_Priority, startDate?: Date, - deadline?: Date + deadline?: Date, + wbsElementId?: string ) { const hasPermission = await userHasPermission(user.userId, organizationId, notGuest); if (!hasPermission) throw new AccessDeniedException('Guests cannot edit tasks'); const originalTask = await prisma.task.findUnique({ where: { taskId }, include: { wbsElement: true } }); + // error if there's a problem with the task if (!originalTask) throw new NotFoundException('Task', taskId); if (originalTask.wbsElement.organizationId !== organizationId) throw new InvalidOrganizationException('Task'); if (originalTask.dateDeleted) throw new DeletedException('Task', taskId); if (!isUnderWordCount(title, 15)) throw new HttpException(400, 'Title must be less than 15 words'); - if (!isUnderWordCount(notes, 250)) throw new HttpException(400, 'Notes must be less than 250 words'); + // if wbsElementId passed, error if there's a problem with the wbs element + if (wbsElementId) { + const newWbsElement = await prisma.wBS_Element.findUnique({ where: { wbsElementId } }); + 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: { title, notes, priority, startDate, deadline }, + data: { + title, + notes, + priority, + startDate, + deadline, + // if wbsElementId passed, update prisma relation to connect task with wbs element + ...(wbsElementId && { wbsElement: { connect: { wbsElementId } } }) + }, ...getTaskQueryArgs(originalTask.wbsElement.organizationId) }); return taskTransformer(updatedTask); @@ -173,13 +201,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 +221,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 +244,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 +261,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); @@ -391,4 +419,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/services/work-packages.services.ts b/src/backend/src/services/work-packages.services.ts index 5db4b1a99f..93b1c4285d 100644 --- a/src/backend/src/services/work-packages.services.ts +++ b/src/backend/src/services/work-packages.services.ts @@ -162,6 +162,39 @@ export default class WorkPackagesService { return resolvedWorkPackages; } +/** + * Retrieve all work packages for a given project + * @param projectWbsNum the wbs number of the project + * @param organization the organization that the user is currently in + * @returns the work packages for the given project + * @throws if the project does not exist or is deleted + */ +static async getWorkPackagesByProject(projectWbsNum: WbsNumber, organization: Organization): Promise { + const project = await prisma.project.findFirst({ + where: { + wbsElement: { + carNumber: projectWbsNum.carNumber, + projectNumber: projectWbsNum.projectNumber, + workPackageNumber: 0, + organizationId: organization.organizationId, + dateDeleted: null + } + } + }); + + if (!project) throw new NotFoundException('Project', wbsPipe(projectWbsNum)); + + const workPackages = await prisma.work_Package.findMany({ + where: { + projectId: project.projectId, + wbsElement: { dateDeleted: null } + }, + ...getWorkPackageQueryArgs(organization.organizationId) + }); + + return workPackages.map(workPackageTransformer); +} + /** * Creates a Work_Package in the database * @param user the user creating the work package 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..247286ea56 100644 --- a/src/backend/tests/unmocked/task.test.ts +++ b/src/backend/tests/unmocked/task.test.ts @@ -14,73 +14,294 @@ 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 - } - }); - // check that status changed to correct status - expect(updatedTask?.status).toBe('IN_PROGRESS'); - }); + describe('Edit task', () => { + test('Successfully updates wbs element when wbsElementId is provided', 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 + } + }); - 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), + const updatedTask = await TasksService.editTask( + user, organizationId, - badTask.taskId, - 'IN_PROGRESS' - ) - ).rejects.toThrow(new HttpException(400, 'A task in progress must have a deadline and assignees!')); + task.taskId, + 'Test Task', + '', + 'HIGH', + undefined, + undefined, + newWbsElement.wbsElementId + ); + + expect(updatedTask.taskId).toBe(task.taskId); + expect(updatedTask.wbsNum).toBeDefined(); + }); + + test('Does not update wbs element when wbsElementId is not provided', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const task = await createTestTask(user, 'Test Task', '', [], 'HIGH', 'IN_BACKLOG', organizationId); + + const updatedTask = await TasksService.editTask(user, organizationId, task.taskId, 'Updated Title', '', 'HIGH'); + + expect(updatedTask.taskId).toBe(task.taskId); + expect(updatedTask.title).toBe('Updated Title'); + }); + + test('Throws NotFoundException when wbsElementId 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.editTask( + user, + organizationId, + task.taskId, + 'Test Task', + '', + 'HIGH', + undefined, + undefined, + 'non-existent-wbs-element-id' + ) + ).rejects.toThrow(new NotFoundException('WBS Element', 'non-existent-wbs-element-id')); + }); + + test('Throws DeletedException when wbsElementId is deleted', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const task = await createTestTask(user, 'Test Task', '', [], 'HIGH', 'IN_BACKLOG', organizationId); + + 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.editTask( + user, + organizationId, + task.taskId, + 'Test Task', + '', + 'HIGH', + undefined, + undefined, + deletedWbsElement.wbsElementId + ) + ).rejects.toThrow(new DeletedException('WBS Element', deletedWbsElement.wbsElementId)); + }); }); - 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), + 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, - badTask.taskId, - 'IN_PROGRESS' - ) - ).rejects.toThrow(new HttpException(400, 'A task in progress must have a deadline and assignees!')); + 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'); + }); + + 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 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('Get tasks by wbs num', () => { + test('Returns project tasks and all WP tasks when given a project wbs number', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const car = await createTestCar(organizationId, user.userId); + const project = await createTestProject(user, organizationId, undefined, car.carId); + + // create a task on the project wbs element + await prisma.task.create({ + data: { + title: 'Project Task', + notes: '', + priority: 'HIGH', + status: 'IN_BACKLOG', + dateCreated: new Date(), + createdBy: { connect: { userId: user.userId } }, + wbsElement: { connect: { wbsElementId: project.wbsElementId } } + } + }); + + // create a WP on the project + const wp = await prisma.work_Package.create({ + data: { + wbsElement: { + create: { + carNumber: 0, + projectNumber: 1, + workPackageNumber: 1, + dateCreated: new Date(), + name: 'WP 1', + status: 'INACTIVE', + leadId: user.userId, + managerId: user.userId, + organizationId + } + }, + project: { connect: { projectId: project.projectId } }, + orderInProject: 1, + startDate: new Date(), + duration: 2 + } + }); + + // create a task on the WP + await prisma.task.create({ + data: { + title: 'WP Task', + notes: '', + priority: 'LOW', + status: 'IN_BACKLOG', + dateCreated: new Date(), + createdBy: { connect: { userId: user.userId } }, + wbsElement: { connect: { wbsElementId: wp.wbsElementId } } + } + }); + + const tasks = await TasksService.getTasksByWbsNum({ carNumber: 0, projectNumber: 1, workPackageNumber: 0 }, { + organizationId + } as any); + + expect(tasks.length).toBe(2); + expect(tasks.map((t) => t.title)).toContain('Project Task'); + expect(tasks.map((t) => t.title)).toContain('WP Task'); + }); + + test('Returns only WP tasks when given a WP wbs number', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const car = await createTestCar(organizationId, user.userId); + const project = await createTestProject(user, organizationId, undefined, car.carId); + + const wp = await prisma.work_Package.create({ + data: { + wbsElement: { + create: { + carNumber: 0, + projectNumber: 1, + workPackageNumber: 1, + dateCreated: new Date(), + name: 'WP 1', + status: 'INACTIVE', + leadId: user.userId, + managerId: user.userId, + organizationId + } + }, + project: { connect: { projectId: project.projectId } }, + orderInProject: 1, + startDate: new Date(), + duration: 2 + } + }); + + await prisma.task.create({ + data: { + title: 'WP Task', + notes: '', + priority: 'HIGH', + status: 'IN_BACKLOG', + dateCreated: new Date(), + createdBy: { connect: { userId: user.userId } }, + wbsElement: { connect: { wbsElementId: wp.wbsElementId } } + } + }); + + const tasks = await TasksService.getTasksByWbsNum({ carNumber: 0, projectNumber: 1, workPackageNumber: 1 }, { + organizationId + } as any); + + expect(tasks.length).toBe(1); + expect(tasks[0].title).toBe('WP Task'); + }); + + test('Throws NotFoundException when wbs element does not exist', async () => { + await expect(async () => + TasksService.getTasksByWbsNum({ carNumber: 99, projectNumber: 99, workPackageNumber: 0 }, { organizationId } as any) + ).rejects.toThrow(NotFoundException); + }); }); - 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')); + }); }); }); diff --git a/src/backend/tests/unmocked/work-packages.test.ts b/src/backend/tests/unmocked/work-packages.test.ts new file mode 100644 index 0000000000..add53272fc --- /dev/null +++ b/src/backend/tests/unmocked/work-packages.test.ts @@ -0,0 +1,75 @@ +import { supermanAdmin } from '../test-data/users.test-data.js'; +import { NotFoundException } from '../../src/utils/errors.utils.js'; +import { createTestOrganization, createTestProject, createTestCar, createTestUser, resetUsers } from '../test-utils.js'; +import prisma from '../../src/prisma/prisma.js'; +import WorkPackagesService from '../../src/services/work-packages.services.js'; + +describe('Work Package Tests', () => { + let organizationId: string; + + beforeEach(async () => { + ({ organizationId } = await createTestOrganization()); + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('Get work packages by project wbs num', () => { + test('Successfully returns work packages for a project', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const car = await createTestCar(organizationId, user.userId); + const project = await createTestProject(user, organizationId, undefined, car.carId); + + await prisma.work_Package.create({ + data: { + wbsElement: { + create: { + carNumber: 0, + projectNumber: 1, + workPackageNumber: 1, + dateCreated: new Date(), + name: 'WP 1', + status: 'INACTIVE', + leadId: user.userId, + managerId: user.userId, + organizationId + } + }, + project: { connect: { projectId: project.projectId } }, + orderInProject: 1, + startDate: new Date(), + duration: 2 + } + }); + + const workPackages = await WorkPackagesService.getWorkPackagesByProjectWbsNum( + { carNumber: 0, projectNumber: 1, workPackageNumber: 0 }, + { organizationId } as any + ); + + expect(workPackages.length).toBe(1); + }); + + test('Returns empty array when project has no work packages', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const car = await createTestCar(organizationId, user.userId); + await createTestProject(user, organizationId, undefined, car.carId); + + const workPackages = await WorkPackagesService.getWorkPackagesByProjectWbsNum( + { carNumber: 0, projectNumber: 1, workPackageNumber: 0 }, + { organizationId } as any + ); + + expect(workPackages.length).toBe(0); + }); + + test('Throws NotFoundException when project does not exist', async () => { + await expect(async () => + WorkPackagesService.getWorkPackagesByProjectWbsNum({ carNumber: 99, projectNumber: 99, workPackageNumber: 0 }, { + organizationId + } as any) + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/src/frontend/src/apis/tasks.api.ts b/src/frontend/src/apis/tasks.api.ts index 4fd2c05270..2d574d7741 100644 --- a/src/frontend/src/apis/tasks.api.ts +++ b/src/frontend/src/apis/tasks.api.ts @@ -65,6 +65,7 @@ export const createSingleTask = ( * @param priority the new priority * @param deadline the new deadline * @param startDate the new start date + * @param wbsElementId the new wbs element * @returns the edited task */ export const editTask = ( @@ -73,14 +74,16 @@ export const editTask = ( notes: string, priority: TaskPriority, deadline?: Date, - startDate?: Date + startDate?: Date, + wbsElementId?: string ) => { return axios.post<{ message: string }>(apiUrls.editTaskById(taskId), { title, notes, priority, deadline: deadline ? dateToMidnightUTC(deadline) : undefined, - startDate: startDate ? dateToMidnightUTC(startDate) : undefined + startDate: startDate ? dateToMidnightUTC(startDate) : undefined, + wbsElementId }); }; @@ -139,3 +142,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/apis/work-packages.api.ts b/src/frontend/src/apis/work-packages.api.ts index edeb00fedc..389e98e416 100644 --- a/src/frontend/src/apis/work-packages.api.ts +++ b/src/frontend/src/apis/work-packages.api.ts @@ -55,6 +55,17 @@ export const getSingleWorkPackage = (wbsNum: WbsNumber) => { }); }; +/** + * Fetch all work packages for a given project + * @param projectWbsNum the wbs number of the project + * @returns the work packages for the given project + */ +export const getWorkPackagesByProject = (projectWbsNum: WbsNumber) => { + return axios.get(apiUrls.workPackagesByProject(wbsPipe(projectWbsNum)), { + transformResponse: (data) => JSON.parse(data).map(workPackageTransformer) + }); +}; + /** * Create a single work package. * diff --git a/src/frontend/src/hooks/tasks.hooks.ts b/src/frontend/src/hooks/tasks.hooks.ts index 9b752dd6f5..e83abec4c5 100644 --- a/src/frontend/src/hooks/tasks.hooks.ts +++ b/src/frontend/src/hooks/tasks.hooks.ts @@ -12,8 +12,10 @@ import { editTask, editTaskAssignees, getOverdueTasksByTeamLeader, - getFilterTasks + getFilterTasks, + getTasksByWbsNum } from '../apis/tasks.api'; +import { wbsPipe } from '../utils/pipes'; export interface CreateTaskPayload { wbsNum: WbsNumber; @@ -77,11 +79,12 @@ export interface TaskPayload { startDate?: Date; deadline?: Date; priority: TaskPriority; + wbsElementId?: string; } /** * Custom React Hook for editing a task - * @returns the edit task mutation' + * @returns the edit task mutation */ export const useEditTask = () => { const queryClient = useQueryClient(); @@ -94,7 +97,8 @@ export const useEditTask = () => { taskPayload.notes ?? '', taskPayload.priority, taskPayload.deadline, - taskPayload.startDate + taskPayload.startDate, + taskPayload.wbsElementId ); return data; @@ -102,6 +106,7 @@ export const useEditTask = () => { { onSuccess: () => { queryClient.invalidateQueries(['projects']); + queryClient.invalidateQueries(['tasks']); queryClient.invalidateQueries(['filter-tasks']); } } @@ -176,3 +181,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/hooks/work-packages.hooks.ts b/src/frontend/src/hooks/work-packages.hooks.ts index acf0fa7d92..88167ee9cd 100644 --- a/src/frontend/src/hooks/work-packages.hooks.ts +++ b/src/frontend/src/hooks/work-packages.hooks.ts @@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from 'react-query'; import { WorkPackage, WorkPackagePreview, WbsNumber, WorkPackageSelection } from 'shared'; +import { wbsPipe } from '../utils/pipes'; import { createSingleWorkPackage, deleteWorkPackage, @@ -14,6 +15,7 @@ import { getAllWorkPackagesPreview, getManyWorkPackages, getSingleWorkPackage, + getWorkPackagesByProject, slackUpcomingDeadlines, WorkPackageCreateArgs, WorkPackageEditArgs, @@ -52,6 +54,17 @@ export const useSingleWorkPackage = (wbsNum: WbsNumber) => { }); }; +/** + * Custom React Hook to get all work packages for a given project + * @param projectWbsNum the wbs number of the project + */ +export const useWorkPackagesByProject = (projectWbsNum: WbsNumber) => { + return useQuery(['work-packages', 'by-project', wbsPipe(projectWbsNum)], async () => { + const { data } = await getWorkPackagesByProject(projectWbsNum); + return data; + }); +}; + /** * Custom React Hook to create a new work package. * 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/AddGanttTaskModal.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/AddGanttTaskModal.tsx index b4c2dc64fb..ebbdd05ef9 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/AddGanttTaskModal.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/AddGanttTaskModal.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { yupResolver } from '@hookform/resolvers/yup'; import { FormControl, FormHelperText, FormLabel, MenuItem, TextField, Autocomplete, Grid } from '@mui/material'; import { Controller, useForm } from 'react-hook-form'; -import { countWords, isUnderWordCount, TaskPriority, TaskStatus } from 'shared'; +import { countWords, isUnderWordCount, TaskPriority, TaskStatus, WorkPackage, WbsNumber } from 'shared'; import * as yup from 'yup'; import NERFormModal from '../../../components/NERFormModal'; import { useAllMembers } from '../../../hooks/users.hooks'; @@ -18,7 +18,8 @@ const schema = yup.object().shape({ assignees: yup.array().of(yup.string()).min(0, 'At least 0 assignees are required'), notes: yup.string(), startDate: yup.date().nullable(), - deadline: yup.date().nullable() + deadline: yup.date().nullable(), + wpWbsNum: yup.mixed().optional() }); interface CreateTaskFormData { @@ -29,15 +30,17 @@ interface CreateTaskFormData { notes: string; startDate: Date | null; deadline: Date | null; + wpWbsNum?: WbsNumber; } interface AddGanttTaskModalProps { showModal: boolean; handleClose: () => void; addTask: (task: CreateTaskFormData) => void; + workPackages: WorkPackage[]; } -const AddGanttTaskModal: React.FC = ({ showModal, handleClose, addTask }) => { +const AddGanttTaskModal: React.FC = ({ showModal, handleClose, addTask, workPackages }) => { const { isLoading: usersIsLoading, isError: usersIsError, data: users, error: usersError } = useAllMembers(); const unUpperCase = (str: string) => str.charAt(0) + str.slice(1).toLowerCase(); @@ -56,7 +59,8 @@ const AddGanttTaskModal: React.FC = ({ showModal, handle assignees: [], notes: '', startDate: null, - deadline: null + deadline: null, + wpWbsNum: undefined } }); @@ -64,6 +68,10 @@ const AddGanttTaskModal: React.FC = ({ showModal, handle if (usersIsError) return ; const options: { label: string; id: string }[] = users.map(taskUserToAutocompleteOption); + const wpOptions: { label: string; wbsNum: WbsNumber }[] = workPackages.map((wp) => ({ + label: wp.name, + wbsNum: wp.wbsNum + })); const onSubmit = async (data: CreateTaskFormData) => { addTask(data); @@ -131,6 +139,25 @@ const AddGanttTaskModal: React.FC = ({ showModal, handle /> + + + 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 diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttProjectCreateModal.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttProjectCreateModal.tsx index 4c0319f1db..5c41e9b71a 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttProjectCreateModal.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttProjectCreateModal.tsx @@ -67,7 +67,7 @@ export const GanttProjectCreateModal = ({ change, handleClose, open }: GanttProj for (const task of project.tasks) { try { await createSingleTask({ - wbsNum: createdProject.wbsNum, + wbsNum: task.wbsNum, title: task.title, priority: task.priority, status: task.status, diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx index 995e8d1f37..df0dfcae5b 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx @@ -193,7 +193,7 @@ export const GanttTimeLineChangeModal = ({ change, handleClose, open }: GanttTim if (createdTasks.length > 0) { for (const task of createdTasks) { const taskPayload: CreateTaskPayload = { - wbsNum: project.wbsNum, + wbsNum: task.wbsNum, title: task.title, priority: task.priority, status: task.status, diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx index e38991c484..866c58ec05 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx @@ -40,7 +40,8 @@ import { WbsElementStatus, wbsPipe, WorkPackage, - WorkPackageStage + WorkPackageStage, + WbsNumber } from 'shared'; import { useAllTeams } from '../../../hooks/teams.hooks'; import { useGetAllCars } from '../../../hooks/cars.hooks'; @@ -337,6 +338,7 @@ const ProjectGanttChartPage: FC = () => { notes: string; startDate: Date | null; deadline: Date | null; + wpWbsNum?: WbsNumber; }, parentProject: ProjectGantt ) => { @@ -348,7 +350,11 @@ const ProjectGanttChartPage: FC = () => { const newTask: Task = { taskId, - wbsNum: parentProject.wbsNum, + wbsNum: taskInfo.wpWbsNum ?? parentProject.wbsNum, + wbsName: taskInfo.wpWbsNum + ? (parentProject.workPackages.find((wp) => wp.wbsNum.workPackageNumber === taskInfo.wpWbsNum?.workPackageNumber) + ?.name ?? parentProject.name) + : parentProject.name, title: taskInfo.title, notes: taskInfo.notes, dateCreated: new Date(), @@ -375,6 +381,7 @@ const ProjectGanttChartPage: FC = () => { }); setSelectedProject(undefined); }; + const handleAddProjectInfo = async ( projectInfo: { name: string; carNumber: number }, selectedTeam: { teamId: string; teamName: string } @@ -498,6 +505,7 @@ const ProjectGanttChartPage: FC = () => { toast.error('No Parent Project Selected'); } }} + workPackages={selectedProject?.workPackages ?? []} /> ); }; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx index e4e0eac0cf..779d743164 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx @@ -2,8 +2,9 @@ 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, WbsNumber } from 'shared'; import { useAllUsers, useCurrentUser } from '../../../../hooks/users.hooks'; +import { useWorkPackagesByProject } from '../../../../hooks/work-packages.hooks'; import * as yup from 'yup'; import { taskUserToAutocompleteOption } from '../../../../utils/task.utils'; import NERFormModal from '../../../../components/NERFormModal'; @@ -17,7 +18,9 @@ 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(), + wpWbsElementId: yup.string().optional(), + wpWbsNum: yup.mixed().optional() }); export interface EditTaskFormInput { @@ -28,27 +31,34 @@ export interface EditTaskFormInput { startDate?: Date; deadline?: Date; priority: TaskPriority; + wpWbsElementId?: string; + wpWbsNum?: WbsNumber; } interface TaskFormModalProps { task?: Task; - teams: TeamPreview[]; modalShow: boolean; onHide: () => void; onSubmit: (data: EditTaskFormInput) => Promise; onReset?: () => void; + wbsNum: WbsNumber; } -const TaskFormModal: React.FC = ({ task, onSubmit, modalShow, onHide, onReset }) => { +const TaskFormModal: React.FC = ({ task, onSubmit, modalShow, onHide, onReset, wbsNum }) => { const user = useCurrentUser(); const { data: users, isLoading, isError, error } = useAllUsers(); + const projectWbsNum = { ...wbsNum, workPackageNumber: 0 }; + const { data: workPackages } = useWorkPackagesByProject(projectWbsNum); + const isWpContext = wbsNum.workPackageNumber !== 0; + const { handleSubmit, control, formState: { errors }, - reset + reset, + setValue } = useForm({ resolver: yupResolver(schema), defaultValues: { @@ -58,14 +68,26 @@ 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) ?? [], + wpWbsElementId: + task?.wbsNum.workPackageNumber !== 0 + ? workPackages?.find((wp) => wp.wbsNum.workPackageNumber === task?.wbsNum.workPackageNumber)?.wbsElementId + : isWpContext + ? workPackages?.find((wp) => wp.wbsNum.workPackageNumber === wbsNum.workPackageNumber)?.wbsElementId + : undefined, + wpWbsNum: task?.wbsNum.workPackageNumber !== 0 ? task?.wbsNum : isWpContext ? 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; wbsElementId: string; wbsNum: WbsNumber }[] = (workPackages ?? []).map((wp) => ({ + label: wp.name, + wbsElementId: wp.wbsElementId, + wbsNum: wp.wbsNum + })); const unUpperCase = (str: string) => str.charAt(0) + str.slice(1).toLowerCase(); @@ -140,6 +162,30 @@ const TaskFormModal: React.FC = ({ task, onSubmit, modalShow /> + + + Work Package + ( + option.wbsElementId === val.wbsElementId} + getOptionLabel={(option) => option.label} + onChange={(_, val) => { + onChange(val?.wbsElementId ?? undefined); + setValue('wpWbsNum', val?.wbsNum ?? undefined); + }} + value={wpOptions.find((o) => o.wbsElementId === value) ?? null} + renderInput={(params) => ( + + )} + /> + )} + /> + + Assignees @@ -152,12 +198,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..f18c88bf0e 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx @@ -3,9 +3,8 @@ * See the LICENSE file in the repository root folder for details. */ -import { TeamPreview } from 'shared'; import { fullNamePipe, datePipe } from '../../../../utils/pipes'; -import { Task } from 'shared'; +import { Task, WbsNumber } from 'shared'; import { Box, Grid, Typography } from '@mui/material'; import { useState } from 'react'; import TaskFormModal, { EditTaskFormInput } from './TaskFormModal'; @@ -13,16 +12,18 @@ import NERModal from '../../../../components/NERModal'; interface TaskModalProps { task: Task; - teams: TeamPreview[]; modalShow: boolean; onHide: () => void; onSubmit: (data: EditTaskFormInput) => Promise; hasEditPermissions: boolean; + wbsNum: WbsNumber; } -const TaskModal: React.FC = ({ task, teams, modalShow, onHide, onSubmit, hasEditPermissions }) => { +const TaskModal: React.FC = ({ task, modalShow, onHide, onSubmit, hasEditPermissions, wbsNum }) => { 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 +98,13 @@ const TaskModal: React.FC = ({ task, teams, modalShow, onHide, o return isEditMode ? ( { setIsEditMode(false); }} + wbsNum={wbsNum} /> ) : ( 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..c6fbe2ae25 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx @@ -2,7 +2,7 @@ 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 { notGuest, Task, WbsNumber } from 'shared'; import { useDeleteTask, useEditTask, useEditTaskAssignees } from '../../../../../hooks/tasks.hooks'; import { useToast } from '../../../../../hooks/toasts.hooks'; import { useCurrentUser } from '../../../../../hooks/users.hooks'; @@ -10,17 +10,20 @@ 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, + wbsNum, onDeleteTask, onEditTask }: { task: Task; index: number; - project: Project; + wbsNum: WbsNumber; onDeleteTask: (taskId: string) => void; onEditTask: (task: Task) => void; }) => { @@ -52,7 +55,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, + wpWbsElementId + }: EditTaskFormInput) => { try { await editTask({ taskId, @@ -60,12 +72,15 @@ export const TaskCard = ({ title, deadline, startDate, - priority + priority, + wbsElementId: wpWbsElementId }); + const newTask = await editTaskAssignees({ taskId, assignees }); + onEditTask(newTask); toast.success('Task edited successfully!'); } catch (error: unknown) { @@ -78,16 +93,18 @@ 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; + const isProjectContext = wbsNum.workPackageNumber === 0; return ( <> setShowModal(false)} onSubmit={handleEditTask} hasEditPermissions={notGuest(user.role)} + wbsNum={wbsNum} /> + {isWpTask && // render iff task does have associated wp + isProjectContext && ( // 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..1452fa0715 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 } from 'shared'; import { statusNames, TaskCard } from '.'; import { NERButton } from '../../../../../components/NERButton'; import { useCreateTask } from '../../../../../hooks/tasks.hooks'; @@ -12,7 +12,7 @@ import TaskFormModal, { EditTaskFormInput } from '../TaskFormModal'; export const TaskColumn = ({ status, tasks, - project, + wbsNum, equalizedHeight, isDragging, onEditTask, @@ -22,7 +22,8 @@ export const TaskColumn = ({ }: { status: TaskStatus; tasks: TaskWithIndex[]; - project: Project; + wbsNum: WbsNumber; + wbsElementId: string; equalizedHeight: number; isDragging: boolean; onEditTask: (task: Task) => void; @@ -46,10 +47,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 +83,7 @@ export const TaskColumn = ({ onSubmit={handleCreateTask} onHide={() => setShowCreateTaskModal(false)} modalShow={showCreateTaskModal} - teams={project.teams} + wbsNum={wbsNum} /> ))} {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..388963bb36 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(undefined); // can't use getTasksByStatus since tasks are async const { mutateAsync: setTaskStatus } = useSetTaskStatus(); const toast = useToast(); @@ -23,12 +26,25 @@ 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)); + } + // disable lint check because adding tasksByStatus to deps would cause infinite loop + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [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) => { + if (!prev) return prev; const newTasksByStatus = { ...prev }; for (const status of statuses) { const index = newTasksByStatus[status].findIndex((task) => task?.taskId === taskId); @@ -43,6 +59,7 @@ export const TaskListContent = ({ project }: TaskListProps) => { const onEditTask = (task: Task) => { setTasksByStatus((prev) => { + if (!prev) return prev; const newTasksByStatus = { ...prev }; for (const status of statuses) { const index = newTasksByStatus[status].findIndex((t) => t?.taskId === task.taskId); @@ -56,10 +73,13 @@ export const TaskListContent = ({ project }: TaskListProps) => { }; const onAddTask = (task: Task) => { - setTasksByStatus((prev) => ({ - ...prev, - [task.status]: [...prev[task.status], { ...task, index: prev[task.status].length }] - })); + setTasksByStatus((prev) => { + if (!prev) return prev; + return { + ...prev, + [task.status]: [...prev[task.status], { ...task, index: prev[task.status].length }] + }; + }); }; const onDragStart: OnDragStartResponder = () => { @@ -134,7 +154,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..bc29d91aea 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/utils/urls.ts b/src/frontend/src/utils/urls.ts index 07493fd150..3b25be31ce 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -97,6 +97,7 @@ const editTaskAssignees = (taskId: string) => `${tasks()}/${taskId}/edit-assigne 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 }) => { @@ -108,6 +109,7 @@ const workPackages = (queryParams?: { [field: string]: string }) => { }; const workPackagesByWbsNum = (wbsNum: string) => `${workPackages()}/${wbsNum}`; +const workPackagesByProject = (wbsNum: string) => `${workPackages()}/by-project/${wbsNum}`; const workPackagesCreate = () => `${workPackages()}/create`; const workPackagesEdit = () => `${workPackages()}/edit`; const workPackagesDelete = (wbsNum: string) => `${workPackagesByWbsNum(wbsNum)}/delete`; @@ -593,9 +595,11 @@ export const apiUrls = { editTaskAssignees, deleteTask, overdueTasksByTeamLeadership, + tasksByWbsNum, workPackages, workPackagesByWbsNum, + workPackagesByProject, workPackagesCreate, workPackagesEdit, workPackagesDelete, 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;