diff --git a/src/backend/src/controllers/tasks.controllers.ts b/src/backend/src/controllers/tasks.controllers.ts index 5ba914ff20..a8fa45e20c 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, wbsNum } = 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, + wbsNum ); 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 1d7224e7bc..4f626ea5a3 100644 --- a/src/backend/src/controllers/work-packages.controllers.ts +++ b/src/backend/src/controllers/work-packages.controllers.ts @@ -62,6 +62,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/seed.ts b/src/backend/src/prisma/seed.ts index 6734f879af..522976f9da 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -2862,7 +2862,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..1b0e64a8bb 100644 --- a/src/backend/src/routes/tasks.routes.ts +++ b/src/backend/src/routes/tasks.routes.ts @@ -6,7 +6,8 @@ import { isTaskPriority, isTaskStatus, validateInputs, - isOptionalDateOnly + isOptionalDateOnly, + intMinZero } from '../utils/validation.utils.js'; import { isDate } from '../utils/validation.utils.js'; @@ -45,20 +46,26 @@ tasksRouter.post( isOptionalDateOnly(body('deadline')), isOptionalDateOnly(body('startDate')), isTaskPriority(body('priority')), + intMinZero(body('wbsNum.carNumber')), + intMinZero(body('wbsNum.projectNumber')), + intMinZero(body('wbsNum.workPackageNumber')), 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 b3aa8ac529..5baef04a29 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..fc386d2a97 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 wbsNum the new wbs element for the task * @returns the sucessfully edited task */ static async editTask( @@ -147,24 +159,56 @@ export default class TasksService { notes: string, priority: Task_Priority, startDate?: Date, - deadline?: Date + deadline?: Date, + wbsNum?: WbsNumber ) { 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 wbsNum passed, error if there's a problem with the wbs element + if (wbsNum) { + const newWbsElement = await prisma.wBS_Element.findUnique({ + where: { + wbsNumber: { + ...wbsNum, + organizationId + } + } + }); + if (!newWbsElement) throw new NotFoundException('WBS Element', wbsPipe(wbsNum)); + if (newWbsElement.dateDeleted) throw new DeletedException('WBS Element', wbsPipe(wbsNum)); + } + const updatedTask = await prisma.task.update({ where: { taskId }, - data: { title, notes, priority, startDate, deadline }, + data: { + title, + notes, + priority, + startDate, + deadline, + // if wbsNum passed, update prisma relation to connect task with wbs element + ...(wbsNum && { + wbsElement: { + connect: { + wbsNumber: { + ...wbsNum, + organizationId + } + } + } + }) + }, ...getTaskQueryArgs(originalTask.wbsElement.organizationId) }); return taskTransformer(updatedTask); @@ -173,13 +217,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 +237,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 +260,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 +277,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 +435,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 83feb938d8..63c4a1a095 100644 --- a/src/backend/src/services/work-packages.services.ts +++ b/src/backend/src/services/work-packages.services.ts @@ -183,6 +183,39 @@ export default class WorkPackagesService { return workPackages.map(workPackageTransformer); } + /** + * 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..e94c6f5907 100644 --- a/src/backend/tests/unmocked/task.test.ts +++ b/src/backend/tests/unmocked/task.test.ts @@ -1,11 +1,20 @@ import { financeMember, supermanAdmin, theVisitorGuest } from '../test-data/users.test-data.js'; -import { AccessDeniedException, HttpException } from '../../src/utils/errors.utils.js'; -import { createTestOrganization, createTestTask, createTestUser, resetUsers } from '../test-utils.js'; +import { AccessDeniedException, HttpException, NotFoundException, DeletedException } from '../../src/utils/errors.utils.js'; +import { + createTestOrganization, + createTestTask, + createTestUser, + resetUsers, + createTestCar, + createTestProject +} from '../test-utils.js'; import prisma from '../../src/prisma/prisma.js'; import TasksService from '../../src/services/tasks.services.js'; +import { WbsNumber } from 'shared'; -describe('Task Test', () => { +describe('Task Tests', () => { let organizationId: string; + beforeEach(async () => { ({ organizationId } = await createTestOrganization()); }); @@ -14,73 +23,308 @@ 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', () => { + it('successfully updates wbs element when wbsNum is provided', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const task = await createTestTask(user, 'Test Task', '', [], 'HIGH', 'IN_BACKLOG', 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 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 newWbsNum: WbsNumber = { + carNumber: newWbsElement.carNumber, + projectNumber: newWbsElement.projectNumber, + workPackageNumber: newWbsElement.workPackageNumber + }; + + 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, + newWbsNum + ); + + expect(updatedTask.taskId).toBe(task.taskId); + expect(updatedTask.wbsNum).toBeDefined(); + }); + + it('does not update wbs element when wbsNum 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'); + }); + + it('throws NotFoundException when wbsNum does not exist', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const task = await createTestTask(user, 'Test Task', '', [], 'HIGH', 'IN_BACKLOG', organizationId); + + const nonExistentWbsNum: WbsNumber = { carNumber: 99, projectNumber: 99, workPackageNumber: 99 }; + + await expect(async () => + TasksService.editTask( + user, + organizationId, + task.taskId, + 'Test Task', + '', + 'HIGH', + undefined, + undefined, + nonExistentWbsNum + ) + ).rejects.toThrow(NotFoundException); + }); + + it('throws DeletedException when wbsNum 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() + } + }); + + const deletedWbsNum: WbsNumber = { + carNumber: deletedWbsElement.carNumber, + projectNumber: deletedWbsElement.projectNumber, + workPackageNumber: deletedWbsElement.workPackageNumber + }; + + await expect(async () => + TasksService.editTask( + user, + organizationId, + task.taskId, + 'Test Task', + '', + 'HIGH', + undefined, + undefined, + deletedWbsNum + ) + ).rejects.toThrow(DeletedException); + }); }); - 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', () => { + it('successfully sets status to in progress 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'); + }); + + it('fails to set status to in progress 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!')); + }); + + it('fails to set status to in progress 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!')); + }); + + it('fails to set status to in progress 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', () => { + it('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'); + }); + + it('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'); + }); + + it('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', () => { + it('does not let guests 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/unit/work-packages.test.ts b/src/backend/tests/unmocked/work-packages.test.ts similarity index 56% rename from src/backend/tests/unit/work-packages.test.ts rename to src/backend/tests/unmocked/work-packages.test.ts index 542ef26eef..38ab7ffb5c 100644 --- a/src/backend/tests/unit/work-packages.test.ts +++ b/src/backend/tests/unmocked/work-packages.test.ts @@ -1,5 +1,6 @@ import { Organization, User } from '@prisma/client'; import prisma from '../../src/prisma/prisma.js'; +import { NotFoundException } from '../../src/utils/errors.utils.js'; import { createTestCar, createTestOrganization, @@ -11,7 +12,7 @@ import { import { supermanAdmin } from '../test-data/users.test-data.js'; import WorkPackagesService from '../../src/services/work-packages.services.js'; -describe('WorkPackagesService', () => { +describe('Work Package Tests', () => { let organization: Organization; let orgId: string; let user: User; @@ -54,7 +55,6 @@ describe('WorkPackagesService', () => { const wpA = await createTestWorkPackage(user, orgId, proj1.projectId, 1, 1, 1); const wpB = await createTestWorkPackage(user, orgId, proj1.projectId, 1, 1, 2); - // wpA blocks wpB await prisma.work_Package.update({ where: { workPackageId: wpB.workPackageId }, data: { blockedBy: { connect: { wbsElementId: wpA.wbsElement.wbsElementId } } } @@ -69,4 +69,60 @@ describe('WorkPackagesService', () => { expect(result[0].wbsNum).toEqual({ carNumber: 1, projectNumber: 1, workPackageNumber: 2 }); }); }); + + describe('getWorkPackagesByProject', () => { + it('successfully returns work packages for a project', async () => { + const car = await createTestCar(orgId, user.userId); + const project = await createTestProject(user, orgId, 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: orgId + } + }, + project: { connect: { projectId: project.projectId } }, + orderInProject: 1, + startDate: new Date(), + duration: 2 + } + }); + + const workPackages = await WorkPackagesService.getWorkPackagesByProject( + { carNumber: 0, projectNumber: 1, workPackageNumber: 0 }, + { organizationId: orgId } as any + ); + + expect(workPackages.length).toBe(1); + }); + + it('returns empty array when project has no work packages', async () => { + const car = await createTestCar(orgId, user.userId); + await createTestProject(user, orgId, undefined, car.carId); + + const workPackages = await WorkPackagesService.getWorkPackagesByProject( + { carNumber: 0, projectNumber: 1, workPackageNumber: 0 }, + { organizationId: orgId } as any + ); + + expect(workPackages.length).toBe(0); + }); + + it('throws NotFoundException when project does not exist', async () => { + await expect(async () => + WorkPackagesService.getWorkPackagesByProject({ carNumber: 99, projectNumber: 99, workPackageNumber: 0 }, { + organizationId: orgId + } 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..79257cd9fa 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 wbsNum 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, + wbsNum?: WbsNumber ) => { 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, + wbsNum }); }; @@ -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..42e9127648 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; @@ -65,6 +67,7 @@ export const useCreateTask = () => { onSuccess: () => { queryClient.invalidateQueries(['projects']); queryClient.invalidateQueries(['filter-tasks']); + queryClient.invalidateQueries(['tasks']); } } ); @@ -77,11 +80,12 @@ export interface TaskPayload { startDate?: Date; deadline?: Date; priority: TaskPriority; + wbsNum?: WbsNumber; } /** * 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 +98,8 @@ export const useEditTask = () => { taskPayload.notes ?? '', taskPayload.priority, taskPayload.deadline, - taskPayload.startDate + taskPayload.startDate, + taskPayload.wbsNum ); return data; @@ -102,6 +107,7 @@ export const useEditTask = () => { { onSuccess: () => { queryClient.invalidateQueries(['projects']); + queryClient.invalidateQueries(['tasks']); queryClient.invalidateQueries(['filter-tasks']); } } @@ -176,3 +182,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 4c34ebc0b2..f3cb30cbc4 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, @@ -61,6 +63,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..2481e94e19 100644 --- a/src/frontend/src/pages/CalendarPage/TaskClickPopup.tsx +++ b/src/frontend/src/pages/CalendarPage/TaskClickPopup.tsx @@ -235,10 +235,10 @@ export const TaskClickContent: React.FC = ({ task, onClos {showEditModal && ( setShowEditModal(false)} onSubmit={handleEditSubmit} + wbsNum={task.wbsNum} /> )} 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..021054f570 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, @@ -216,6 +216,7 @@ export const GanttTimeLineChangeModal = ({ change, handleClose, open }: GanttTim if (editedTasks.length > 0) { for (const task of editedTasks) { const taskPayload: TaskPayload = { + wbsNum: task.wbsNum, taskId: task.taskId, title: task.title, priority: task.priority, diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx index 2457c0736c..ddbaa6afe1 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 { useAllTeamTypes } from '../../../hooks/team-types.hooks'; @@ -325,6 +326,7 @@ const ProjectGanttChartPage: FC = () => { notes: string; startDate: Date | null; deadline: Date | null; + wpWbsNum?: WbsNumber; }, parentProject: ProjectGantt ) => { @@ -336,7 +338,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(), @@ -363,6 +369,7 @@ const ProjectGanttChartPage: FC = () => { }); setSelectedProject(undefined); }; + const handleAddProjectInfo = async ( projectInfo: { name: string; carNumber: number }, selectedTeam: { teamId: string; teamName: string } @@ -482,6 +489,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 762123f7db..37d29c5d61 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx @@ -2,13 +2,14 @@ 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, TaskStatus, TeamPreview } from 'shared'; +import { countWords, isGuest, isUnderWordCount, Task, TaskPriority, TaskStatus, WbsNumber } from 'shared'; import { useAllMembers, useCurrentUser } from '../../../../hooks/users.hooks'; import * as yup from 'yup'; import { taskUserToAutocompleteOption } from '../../../../utils/task.utils'; import NERFormModal from '../../../../components/NERFormModal'; import LoadingIndicator from '../../../../components/LoadingIndicator'; import ErrorPage from '../../../ErrorPage'; +import { useWorkPackagesByProject } from '../../../../hooks/work-packages.hooks'; export interface EditTaskFormInput { taskId: string; @@ -18,19 +19,20 @@ export interface EditTaskFormInput { startDate?: Date; deadline?: Date; priority: TaskPriority; + wpWbsNum?: WbsNumber | null; } interface TaskFormModalProps { task?: Task; status?: Task['status']; - teams: TeamPreview[]; modalShow: boolean; onHide: () => void; onSubmit: (data: EditTaskFormInput) => Promise; onReset?: () => void; + wbsNum: WbsNumber; } -const TaskFormModal: React.FC = ({ task, status, onSubmit, modalShow, onHide, onReset }) => { +const TaskFormModal: React.FC = ({ task, status, onSubmit, modalShow, onHide, onReset, wbsNum }) => { let schema; if (status === TaskStatus.IN_PROGRESS) { @@ -48,7 +50,8 @@ const TaskFormModal: React.FC = ({ task, status, onSubmit, m priority: yup.mixed().oneOf(Object.values(TaskPriority)).required(), assignees: yup.array().required().min(1, 'At least one assignee is required for In Progress tasks'), title: yup.string().required(), - taskId: yup.string().required() + taskId: yup.string().required(), + wpWbsNum: yup.mixed().optional() }); } else { schema = yup.object().shape({ @@ -65,7 +68,8 @@ const TaskFormModal: React.FC = ({ task, status, onSubmit, m 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().nullable().optional() }); } @@ -73,6 +77,11 @@ const TaskFormModal: React.FC = ({ task, status, onSubmit, m const { data: users, isLoading, isError, error } = useAllMembers(); + const projectWbsNum = { ...wbsNum, workPackageNumber: 0 }; + const { data: workPackages } = useWorkPackagesByProject(projectWbsNum); + + const isWpContext = wbsNum.workPackageNumber !== 0; + const { handleSubmit, control, @@ -87,14 +96,19 @@ const TaskFormModal: React.FC = ({ task, status, onSubmit, m 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(); @@ -169,6 +183,31 @@ const TaskFormModal: React.FC = ({ task, status, onSubmit, m /> + {!isWpContext && ( + + + Work Package + ( + option.label} + isOptionEqualToValue={(option, val) => + option.wbsNum.workPackageNumber === val.wbsNum.workPackageNumber + } + onChange={(_, val) => onChange(val?.wbsNum ?? null)} + value={wpOptions.find((o) => o.wbsNum.workPackageNumber === value?.workPackageNumber) ?? null} + renderInput={(params) => ( + + )} + /> + )} + /> + + + )} Assignees @@ -181,12 +220,12 @@ const TaskFormModal: React.FC = ({ task, status, onSubmit, m 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..ee71b6e733 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,20 @@ 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 isWpContext = wbsNum.workPackageNumber !== 0; + const ViewModal: React.FC = () => { return ( = ({ task, teams, modalShow, onHide, o {task.assignees.map((user) => fullNamePipe(user)).join(', ')} + {isWpTask && !isWpContext && ( + + + Work Package: + {task.wbsName} + + + )} Notes: @@ -89,13 +100,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..6e7b10d68d 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx @@ -1,8 +1,8 @@ import { Draggable } from '@hello-pangea/dnd'; -import { Construction, Delete, Schedule } from '@mui/icons-material'; +import { Construction, Folder, 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,31 @@ 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'; + +const wpColors = [ + { bg: 'rgba(55,138,221,0.15)', color: '#7dbef4' }, // blue + { bg: 'rgba(127,119,221,0.15)', color: '#AFA9EC' }, // purple + { bg: 'rgba(255,182,193,0.15)', color: '#F4A7B9' }, // rose + { bg: 'rgba(79,172,254,0.15)', color: '#63C5DA' }, // cyan + { bg: 'rgba(100,149,237,0.15)', color: '#93B5E1' }, // greyish blue + { bg: 'rgba(147,112,219,0.15)', color: '#C9B1FF' }, // lavender + { bg: 'rgba(176,196,222,0.15)', color: '#A8C0D6' }, // really greyish blue + { bg: 'rgba(29,158,117,0.15)', color: '#5DCAA5' } // teal +]; 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,20 +66,36 @@ 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 { + // uses the project's wbs element id as fallback if no wp was selected + const targetWbsNum = + wpWbsNum ?? (task.wbsNum.workPackageNumber !== 0 ? { ...wbsNum, workPackageNumber: 0 } : undefined); + await editTask({ taskId, notes, title, deadline, startDate, - priority + priority, + wbsNum: targetWbsNum }); + const newTask = await editTaskAssignees({ taskId, assignees }); + onEditTask(newTask); toast.success('Task edited successfully!'); } catch (error: unknown) { @@ -78,16 +108,19 @@ 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; + const wpColor = wpColors[(task.wbsNum.workPackageNumber - 1) % wpColors.length]; return ( <> setShowModal(false)} onSubmit={handleEditTask} hasEditPermissions={notGuest(user.role)} + wbsNum={wbsNum} /> } label={ task.assignees.length === 0 @@ -141,6 +174,24 @@ export const TaskCard = ({ } size="medium" /> + {isWpTask && // render iff task does have associated wp + isProjectContext && ( // and if on project's task page, not wp's + } + label={task.wbsName} + size="medium" + component={RouterLink} + to={`${routes.PROJECTS}/${wbsPipe(task.wbsNum)}`} + clickable + sx={{ + marginTop: 1, + backgroundColor: wpColor.bg, + color: wpColor.color, + fontWeight: 500, + maxWidth: 275 // truncates wtih ellipses if it gets too long + }} + /> + )} 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 fe60129ad9..05347b2aae 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,7 @@ export const TaskColumn = ({ }: { status: TaskStatus; tasks: TaskWithIndex[]; - project: Project; + wbsNum: WbsNumber; equalizedHeight: number; isDragging: boolean; onEditTask: (task: Task) => void; @@ -46,10 +46,19 @@ 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 projectWbsNum = { ...wbsNum, workPackageNumber: 0 }; const task = await createTask({ - wbsNum: project.wbsNum, + wbsNum: wpWbsNum ?? projectWbsNum, title, deadline: deadline ? toDateString(deadline) : undefined, startDate: startDate ? toDateString(startDate) : undefined, @@ -75,7 +84,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..08cb7c2f7d 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,5 @@ 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..c5a9644584 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,22 @@ 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; } -export const TaskListContent = ({ project }: TaskListProps) => { - const { tasks } = project; - const [tasksByStatus, setTasksByStatus] = useState(getTasksByStatus(tasks)); +export const TaskListContent = ({ wbsNum }: 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 +25,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 +58,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 +72,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 +153,7 @@ export const TaskListContent = ({ project }: TaskListProps) => { status={status} tasks={tasksByStatus[status]} key={status} - project={project} + wbsNum={wbsNum} 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..3553cea89d 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 4ccc856fc2..99339430d3 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`; @@ -576,9 +578,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;