diff --git a/src/backend/src/controllers/tasks.controllers.ts b/src/backend/src/controllers/tasks.controllers.ts index a8fa45e20c..0778256094 100644 --- a/src/backend/src/controllers/tasks.controllers.ts +++ b/src/backend/src/controllers/tasks.controllers.ts @@ -5,7 +5,7 @@ import { validateWBS, WbsNumber } from 'shared'; export default class TasksController { static async createTask(req: Request, res: Response, next: NextFunction) { try { - const { title, deadline, startDate, priority, status, assignees, notes } = req.body; + const { title, deadline, startDate, priority, status, assignees, notes, labelIds } = req.body; const wbsNum: WbsNumber = validateWBS(req.params.wbsNum as string); const task = await TasksService.createTask( @@ -17,6 +17,7 @@ export default class TasksController { status, assignees, req.organization, + labelIds, startDate ? new Date(startDate) : undefined, deadline ? new Date(deadline) : undefined ); @@ -29,7 +30,7 @@ export default class TasksController { static async editTask(req: Request, res: Response, next: NextFunction) { try { - const { title, notes, priority, deadline, startDate, wbsNum } = req.body; + const { title, notes, priority, deadline, startDate, wbsNum, labelIds } = req.body; const { taskId } = req.params as Record; const updateTask = await TasksService.editTask( @@ -39,6 +40,7 @@ export default class TasksController { title, notes, priority, + labelIds, startDate ? new Date(startDate) : undefined, deadline ? new Date(deadline) : undefined, wbsNum @@ -133,4 +135,55 @@ export default class TasksController { next(error); } } + + static async getTasksByWbsNumAndLabels(req: Request, res: Response, next: NextFunction) { + try { + const wbsNum: WbsNumber = validateWBS(req.params.wbsNum as string); + const labelIds = req.query.labelIds ? String(req.query.labelIds).split(',') : []; + const tasks = await TasksService.getTasksByWbsNumAndLabels(wbsNum, labelIds, req.organization); + res.status(200).json(tasks); + } catch (error: unknown) { + next(error); + } + } + + static async getAllTaskLabels(req: Request, res: Response, next: NextFunction) { + try { + const labels = await TasksService.getAllTaskLabels(req.organization); + res.status(200).json(labels); + } catch (error: unknown) { + next(error); + } + } + + static async createTaskLabel(req: Request, res: Response, next: NextFunction) { + try { + const { name, colorHexCode } = req.body; + const label = await TasksService.createTaskLabel(req.currentUser, name, colorHexCode, req.organization); + res.status(200).json(label); + } catch (error: unknown) { + next(error); + } + } + + static async editTaskLabel(req: Request, res: Response, next: NextFunction) { + try { + const { taskLabelId } = req.params as Record; + const { name, colorHexCode } = req.body; + const label = await TasksService.editTaskLabel(req.currentUser, taskLabelId, name, colorHexCode, req.organization); + res.status(200).json(label); + } catch (error: unknown) { + next(error); + } + } + + static async deleteTaskLabel(req: Request, res: Response, next: NextFunction) { + try { + const { taskLabelId } = req.params as Record; + const deletedId = await TasksService.deleteTaskLabel(req.currentUser, taskLabelId, req.organization); + res.status(200).json(deletedId); + } catch (error: unknown) { + next(error); + } + } } 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 84754aeb11..f711bac476 100644 --- a/src/backend/src/prisma-query-args/tasks.query-args.ts +++ b/src/backend/src/prisma-query-args/tasks.query-args.ts @@ -11,7 +11,8 @@ export const getTaskQueryArgs = (organizationId: string) => wbsElement: true, createdBy: getUserQueryArgs(organizationId), deletedBy: getUserQueryArgs(organizationId), - assignees: getUserQueryArgs(organizationId) + assignees: getUserQueryArgs(organizationId), + labels: true } }); @@ -33,7 +34,8 @@ export const getCalendarTaskQueryArgs = (organizationId: string) => }, createdBy: getUserQueryArgs(organizationId), deletedBy: getUserQueryArgs(organizationId), - assignees: getUserQueryArgs(organizationId) + assignees: getUserQueryArgs(organizationId), + labels: true } }); diff --git a/src/backend/src/prisma/migrations/20260528001636_task_labels/migration.sql b/src/backend/src/prisma/migrations/20260528001636_task_labels/migration.sql new file mode 100644 index 0000000000..81e0459acf --- /dev/null +++ b/src/backend/src/prisma/migrations/20260528001636_task_labels/migration.sql @@ -0,0 +1,42 @@ +-- CreateTable +CREATE TABLE "Task_Label" ( + "taskLabelId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "colorHexCode" TEXT NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "dateDeleted" TIMESTAMP(3), + "userCreatedId" TEXT NOT NULL, + "userDeletedId" TEXT, + "organizationId" TEXT NOT NULL, + + CONSTRAINT "Task_Label_pkey" PRIMARY KEY ("taskLabelId") +); + +-- CreateTable +CREATE TABLE "_TaskToTask_Label" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_TaskToTask_Label_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE INDEX "Task_Label_organizationId_idx" ON "Task_Label"("organizationId"); + +-- CreateIndex +CREATE INDEX "_TaskToTask_Label_B_index" ON "_TaskToTask_Label"("B"); + +-- AddForeignKey +ALTER TABLE "Task_Label" ADD CONSTRAINT "Task_Label_userCreatedId_fkey" FOREIGN KEY ("userCreatedId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Task_Label" ADD CONSTRAINT "Task_Label_userDeletedId_fkey" FOREIGN KEY ("userDeletedId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Task_Label" ADD CONSTRAINT "Task_Label_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_TaskToTask_Label" ADD CONSTRAINT "_TaskToTask_Label_A_fkey" FOREIGN KEY ("A") REFERENCES "Task"("taskId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_TaskToTask_Label" ADD CONSTRAINT "_TaskToTask_Label_B_fkey" FOREIGN KEY ("B") REFERENCES "Task_Label"("taskLabelId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index a757bbaa43..bae425c035 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -306,6 +306,8 @@ model User { prospectiveSponsorsContacted Prospective_Sponsor[] @relation(name: "prospectiveSponsorContactor") createdMeetingAttendances Meeting_Attendance[] @relation(name: "meetingAttendanceCreated") attendedMeetingAttendances Meeting_Attendance[] @relation(name: "meetingAttendees") + createdTaskLabels Task_Label[] @relation(name: "taskLabelCreator") + deletedTaskLabels Task_Label[] @relation(name: "taskLabelDeleter") } model Role { @@ -705,10 +707,28 @@ model Task { dateCreated DateTime @default(now()) wbsElement WBS_Element @relation(fields: [wbsElementId], references: [wbsElementId]) wbsElementId String + labels Task_Label[] @@index([wbsElementId]) } +model Task_Label { + taskLabelId String @id @default(uuid()) + name String + colorHexCode String + dateCreated DateTime @default(now()) + dateDeleted DateTime? + userCreated User @relation(fields: [userCreatedId], references: [userId], name: "taskLabelCreator") + userCreatedId String + userDeleted User? @relation(fields: [userDeletedId], references: [userId], name: "taskLabelDeleter") + userDeletedId String? + tasks Task[] + organization Organization @relation(fields: [organizationId], references: [organizationId]) + organizationId String + + @@index([organizationId]) +} + model Reimbursement_Status { reimbursementStatusId String @id @default(uuid()) type Reimbursement_Status_Type @@ -1450,6 +1470,7 @@ model Organization { calendars Calendar[] eventTypes Event_Type[] meetingAttendances Meeting_Attendance[] + taskLabels Task_Label[] } model FrequentlyAskedQuestion { diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 07c95fa1ed..9a4020e365 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -2754,6 +2754,16 @@ const performSeed: () => Promise = async () => { ner ); + /** + * Task Labels + */ + const taskLabelResearch = await TasksService.createTaskLabel(thomasEmrax, 'Research', '#3B82F6', ner); + const taskLabelDesign = await TasksService.createTaskLabel(thomasEmrax, 'Design', '#A855F7', ner); + const taskLabelTesting = await TasksService.createTaskLabel(thomasEmrax, 'Testing', '#EF4444', ner); + const taskLabelAdmin = await TasksService.createTaskLabel(thomasEmrax, 'Admin', '#F97316', ner); + const taskLabelBuild = await TasksService.createTaskLabel(thomasEmrax, 'Build', '#22C55E', ner); + const taskLabelBlocked = await TasksService.createTaskLabel(thomasEmrax, 'Blocked', '#1E3A8A', ner); + /** * Tasks */ @@ -2766,6 +2776,7 @@ const performSeed: () => Promise = async () => { Task_Status.IN_PROGRESS, [joeShmoe.userId], ner, + [taskLabelResearch.taskLabelId], undefined, daysFromNow(10) ); @@ -2779,6 +2790,7 @@ const performSeed: () => Promise = async () => { Task_Status.IN_BACKLOG, [joeShmoe.userId], ner, + [taskLabelDesign.taskLabelId], daysAgo(5), daysFromNow(15) ); @@ -2792,6 +2804,7 @@ const performSeed: () => Promise = async () => { Task_Status.IN_PROGRESS, [joeShmoe.userId, joeBlow.userId], ner, + [taskLabelResearch.taskLabelId, taskLabelBlocked.taskLabelId], undefined, daysFromNow(8) ); @@ -2806,6 +2819,7 @@ const performSeed: () => Promise = async () => { Task_Status.IN_PROGRESS, [joeBlow.userId], ner, + [taskLabelTesting.taskLabelId], undefined, daysFromNow(14) ); @@ -2819,6 +2833,7 @@ const performSeed: () => Promise = async () => { Task_Status.IN_PROGRESS, [thomasEmrax.userId], ner, + [taskLabelAdmin.taskLabelId], daysAgo(14), daysFromNow(7) ); @@ -2832,6 +2847,7 @@ const performSeed: () => Promise = async () => { Task_Status.IN_PROGRESS, [thomasEmrax.userId, joeBlow.userId, joeShmoe.userId], ner, + [taskLabelDesign.taskLabelId], undefined, daysFromNow(9) ); @@ -2845,6 +2861,7 @@ const performSeed: () => Promise = async () => { Task_Status.IN_PROGRESS, [thomasEmrax.userId], ner, + [taskLabelAdmin.taskLabelId], undefined, daysFromNow(6) ); @@ -2858,6 +2875,7 @@ const performSeed: () => Promise = async () => { Task_Status.DONE, [joeShmoe.userId], ner, + [taskLabelBuild.taskLabelId], undefined, daysAgo(30) ); @@ -2879,6 +2897,7 @@ const performSeed: () => Promise = async () => { Task_Status.DONE, [joeShmoe.userId], ner, + [taskLabelBuild.taskLabelId], undefined, daysAgo(90) ); @@ -2892,6 +2911,7 @@ const performSeed: () => Promise = async () => { Task_Status.DONE, [thomasEmrax.userId, joeBlow.userId, joeShmoe.userId], ner, + [taskLabelAdmin.taskLabelId], daysAgo(70), daysAgo(55) ); @@ -2905,6 +2925,7 @@ const performSeed: () => Promise = async () => { Task_Status.IN_BACKLOG, [], ner, + [taskLabelAdmin.taskLabelId], undefined, daysFromNow(12) ); @@ -2918,6 +2939,7 @@ const performSeed: () => Promise = async () => { Task_Status.IN_PROGRESS, [joeShmoe.userId], ner, + [taskLabelTesting.taskLabelId], undefined, daysFromNow(8) ); @@ -2931,6 +2953,7 @@ const performSeed: () => Promise = async () => { Task_Status.IN_PROGRESS, [thomasEmrax.userId, joeShmoe.userId], ner, + [taskLabelAdmin.taskLabelId], undefined, daysFromNow(7) ); @@ -2944,6 +2967,7 @@ const performSeed: () => Promise = async () => { Task_Status.DONE, [thomasEmrax.userId], ner, + [taskLabelDesign.taskLabelId], daysAgo(80), daysAgo(65) ); @@ -2957,6 +2981,7 @@ const performSeed: () => Promise = async () => { Task_Status.IN_BACKLOG, [thomasEmrax, joeShmoe, joeBlow].map((user) => user.userId), ner, + [taskLabelBuild.taskLabelId, taskLabelBlocked.taskLabelId], undefined, daysFromNow(16) ); @@ -2970,6 +2995,7 @@ const performSeed: () => Promise = async () => { Task_Status.IN_PROGRESS, [joeShmoe.userId], ner, + [taskLabelBuild.taskLabelId], undefined, daysFromNow(13) ); @@ -2983,6 +3009,7 @@ const performSeed: () => Promise = async () => { Task_Status.IN_BACKLOG, [joeShmoe.userId], ner, + [taskLabelTesting.taskLabelId], undefined, daysFromNow(18) ); @@ -2996,6 +3023,7 @@ const performSeed: () => Promise = async () => { Task_Status.DONE, [joeBlow.userId], ner, + [], undefined, daysAgo(45) ); @@ -3009,6 +3037,7 @@ const performSeed: () => Promise = async () => { Task_Status.DONE, [joeBlow.userId], ner, + [taskLabelDesign.taskLabelId], undefined, daysAgo(60) ); @@ -3022,6 +3051,7 @@ const performSeed: () => Promise = async () => { Task_Status.IN_PROGRESS, [regina.userId], ner, + [taskLabelResearch.taskLabelId, taskLabelAdmin.taskLabelId], daysAgo(21), daysAgo(10) ); @@ -3035,6 +3065,7 @@ const performSeed: () => Promise = async () => { Task_Status.DONE, [zatanna.userId], ner, + [taskLabelAdmin.taskLabelId], daysAgo(10), daysAgo(9) ); @@ -3048,6 +3079,7 @@ const performSeed: () => Promise = async () => { Task_Status.IN_PROGRESS, [sandy.userId], ner, + [taskLabelResearch.taskLabelId], daysAgo(16), daysAgo(1) ); diff --git a/src/backend/src/routes/tasks.routes.ts b/src/backend/src/routes/tasks.routes.ts index cd11e5c904..1633bede85 100644 --- a/src/backend/src/routes/tasks.routes.ts +++ b/src/backend/src/routes/tasks.routes.ts @@ -35,6 +35,8 @@ tasksRouter.post( isTaskStatus(body('status')), body('assignees').isArray(), nonEmptyString(body('assignees.*')), + body('labelIds').isArray(), + body('labelIds.*').isString(), validateInputs, TasksController.createTask ); @@ -49,6 +51,8 @@ tasksRouter.post( intMinZero(body('wbsNum.carNumber')), intMinZero(body('wbsNum.projectNumber')), intMinZero(body('wbsNum.workPackageNumber')), + body('labelIds').isArray(), + body('labelIds.*').isString(), validateInputs, TasksController.editTask ); @@ -67,6 +71,27 @@ tasksRouter.post('/:taskId/delete', validateInputs, TasksController.deleteTask); tasksRouter.get('/overdue-by-team-member/:userId', TasksController.getOverdueTasksByTeamLeadership); +tasksRouter.get('/by-wbs/:wbsNum/filter-by-labels', TasksController.getTasksByWbsNumAndLabels); tasksRouter.get('/by-wbs/:wbsNum', TasksController.getTasksByWbsNum); +tasksRouter.get('/task-labels', TasksController.getAllTaskLabels); + +tasksRouter.post( + '/task-labels/create', + nonEmptyString(body('name')), + nonEmptyString(body('colorHexCode')), + validateInputs, + TasksController.createTaskLabel +); + +tasksRouter.post( + '/task-labels/:taskLabelId/edit', + nonEmptyString(body('name')), + nonEmptyString(body('colorHexCode')), + validateInputs, + TasksController.editTaskLabel +); + +tasksRouter.post('/task-labels/:taskLabelId/delete', TasksController.deleteTaskLabel); + export default tasksRouter; diff --git a/src/backend/src/services/tasks.services.ts b/src/backend/src/services/tasks.services.ts index fc386d2a97..62b9929794 100644 --- a/src/backend/src/services/tasks.services.ts +++ b/src/backend/src/services/tasks.services.ts @@ -7,12 +7,17 @@ import { notGuest, Task, TaskCardPreview, + TaskLabel, WbsNumber, wbsPipe, User } from 'shared'; import prisma from '../prisma/prisma.js'; -import taskTransformer, { calendarTaskTransformer, taskCardPreviewTransformer } from '../transformers/tasks.transformer.js'; +import taskTransformer, { + calendarTaskTransformer, + taskCardPreviewTransformer, + taskLabelTransformer +} from '../transformers/tasks.transformer.js'; import { NotFoundException, AccessDeniedException, @@ -20,7 +25,7 @@ import { DeletedException, InvalidOrganizationException } from '../utils/errors.utils.js'; -import { sendSlackTaskAssignedNotificationToUsers } from '../utils/tasks.utils.js'; +import { sendSlackTaskAssignedNotificationToUsers, validateTaskLabels } from '../utils/tasks.utils.js'; import { getUsers, userHasPermission } from '../utils/users.utils.js'; import { wbsNumOf } from '../utils/utils.js'; import { getTeamQueryArgs } from '../prisma-query-args/teams.query-args.js'; @@ -42,6 +47,7 @@ export default class TasksService { * @param status the status of the task * @param assignees the assignees ids of the task * @param organizationId the organization that the user is currently in + * @param labelIds the label ids for the task * @param startDate the start date of the task * @param deadline the deadline of the task * @returns the id of the successfully created task @@ -56,6 +62,7 @@ export default class TasksService { status: Task_Status, assignees: string[], organization: Organization, + labelIds: string[], startDate?: Date, deadline?: Date ): Promise { @@ -108,6 +115,8 @@ export default class TasksService { throw new HttpException(400, 'Tasks in progress must have a dealine and assignees'); } + await validateTaskLabels(labelIds, organization.organizationId); + const createdTask = await prisma.task.create({ data: { wbsElement: { @@ -125,7 +134,8 @@ export default class TasksService { priority, status, createdBy: { connect: { userId: createdBy.userId } }, - assignees: { connect: users.map((user) => ({ userId: user.userId })) } + assignees: { connect: users.map((user) => ({ userId: user.userId })) }, + labels: { connect: labelIds.map((id) => ({ taskLabelId: id })) } }, ...getTaskQueryArgs(organization.organizationId) }); @@ -146,6 +156,7 @@ 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 labelIds the new label ids 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 @@ -158,6 +169,7 @@ export default class TasksService { title: string, notes: string, priority: Task_Priority, + labelIds: string[], startDate?: Date, deadline?: Date, wbsNum?: WbsNumber @@ -175,6 +187,8 @@ export default class TasksService { 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'); + await validateTaskLabels(labelIds, organizationId); + // if wbsNum passed, error if there's a problem with the wbs element if (wbsNum) { const newWbsElement = await prisma.wBS_Element.findUnique({ @@ -207,7 +221,8 @@ export default class TasksService { } } } - }) + }), + labels: { set: labelIds.map((id) => ({ taskLabelId: id })) } }, ...getTaskQueryArgs(originalTask.wbsElement.organizationId) }); @@ -491,4 +506,161 @@ export default class TasksService { return tasks.map(taskTransformer); } + + /** + * Gets tasks for a wbs element filtered to only those containing at least one of the given labels + * @param wbsNum the wbs number of the project or work package + * @param labelIds the label ids to filter by + * @param organization the organization that the user is currently in + * @returns array of tasks that have at least one matching label + */ + static async getTasksByWbsNumAndLabels( + wbsNum: WbsNumber, + labelIds: string[], + 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)); + + await validateTaskLabels(labelIds, organization.organizationId); + + const labelFilter = { labels: { some: { taskLabelId: { in: labelIds } } } }; + + 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] }, + ...labelFilter + }, + ...getTaskQueryArgs(organization.organizationId) + }); + + return tasks.map(taskTransformer); + } + + const tasks = await prisma.task.findMany({ + where: { dateDeleted: null, wbsElementId: wbsElement.wbsElementId, ...labelFilter }, + ...getTaskQueryArgs(organization.organizationId) + }); + + return tasks.map(taskTransformer); + } + + /** + * Gets all task labels in the database for a given organization + * @param organization the organization that the user is currently in + * @returns array of task labels + */ + static async getAllTaskLabels(organization: Organization): Promise { + const labels = await prisma.task_Label.findMany({ + where: { organizationId: organization.organizationId, dateDeleted: null } + }); + + return labels.map(taskLabelTransformer); + } + + /** + * Creates a task label in the database + * @param creator the user creating the task label + * @param name the name of the task label + * @param colorHexCode the hex code for the task label color + * @param organization the organization that the user is currently in + * @returns the created task label + * @throws if the user does not have permission + */ + static async createTaskLabel( + creator: User, + name: string, + colorHexCode: string, + organization: Organization + ): Promise { + const hasPermission = await userHasPermission(creator.userId, organization.organizationId, isAdmin); + if (!hasPermission) throw new AccessDeniedException('Non admins cannot create task labels'); + + const label = await prisma.task_Label.create({ + data: { + name, + colorHexCode, + userCreated: { connect: { userId: creator.userId } }, + organization: { connect: { organizationId: organization.organizationId } } + } + }); + + return taskLabelTransformer(label); + } + + /** + * Edits a task label in the database + * @param user the user creating the task label + * @param taskLabelId the id of the task label being edited + * @param name the name of the task label + * @param colorHexCode the hex code for the task label color + * @param organization the organization that the user is currently in + * @returns the edited task label + * @throws if the user does not have permission + */ + static async editTaskLabel( + user: User, + taskLabelId: string, + name: string, + colorHexCode: string, + organization: Organization + ): Promise { + const hasPermission = await userHasPermission(user.userId, organization.organizationId, isAdmin); + if (!hasPermission) throw new AccessDeniedException('Guests cannot edit task labels'); + + const label = await prisma.task_Label.findUnique({ where: { taskLabelId } }); + if (!label) throw new NotFoundException('Task Label', taskLabelId); + if (label.organizationId !== organization.organizationId) throw new InvalidOrganizationException('Task Label'); + if (label.dateDeleted) throw new DeletedException('Task Label', taskLabelId); + + const updatedLabel = await prisma.task_Label.update({ + where: { taskLabelId }, + data: { name, colorHexCode } + }); + + return taskLabelTransformer(updatedLabel); + } + + /** + * Deletes a task label in the database + * @param user the user creating the task label + * @param taskLabelId the id of the task label being deleted + * @param organization the organization that the user is currently in + * @returns the deleted task label + * @throws if the user does not have permission + */ + static async deleteTaskLabel(user: User, taskLabelId: string, organization: Organization): Promise { + const hasPermission = await userHasPermission(user.userId, organization.organizationId, isAdmin); + if (!hasPermission) throw new AccessDeniedException('Only admins can delete task labels'); + + const label = await prisma.task_Label.findUnique({ where: { taskLabelId } }); + if (!label) throw new NotFoundException('Task Label', taskLabelId); + if (label.organizationId !== organization.organizationId) throw new InvalidOrganizationException('Task Label'); + if (label.dateDeleted) throw new DeletedException('Task Label', taskLabelId); + + await prisma.task_Label.update({ + where: { taskLabelId }, + data: { + dateDeleted: new Date(), + userDeleted: { connect: { userId: user.userId } } + } + }); + + return taskLabelId; + } } diff --git a/src/backend/src/transformers/tasks.transformer.ts b/src/backend/src/transformers/tasks.transformer.ts index aaeb416656..0a562c600e 100644 --- a/src/backend/src/transformers/tasks.transformer.ts +++ b/src/backend/src/transformers/tasks.transformer.ts @@ -1,5 +1,5 @@ -import { Prisma } from '@prisma/client'; -import { CalendarTask, Task, TaskCardPreview } from 'shared'; +import { Prisma, Task_Label } from '@prisma/client'; +import { CalendarTask, Task, TaskCardPreview, TaskLabel } from 'shared'; import { wbsNumOf } from '../utils/utils.js'; import { convertTaskPriority, convertTaskStatus } from '../utils/tasks.utils.js'; import { userTransformer } from './user.transformer.js'; @@ -19,6 +19,7 @@ export const taskTransformer = (task: Prisma.TaskGetPayload): Tas status: convertTaskStatus(task.status), createdBy: userTransformer(task.createdBy), assignees: task.assignees.map(userTransformer), + labels: task.labels.map(taskLabelTransformer), dateDeleted: task.dateDeleted ?? undefined, dateCreated: task.dateCreated, deletedBy: task.deletedBy ? userTransformer(task.deletedBy) : undefined @@ -55,6 +56,7 @@ export const calendarTaskTransformer = (task: Prisma.TaskGetPayload ({ + taskLabelId: label.taskLabelId, + name: label.name, + colorHexCode: label.colorHexCode +}); + export default taskTransformer; diff --git a/src/backend/src/utils/errors.utils.ts b/src/backend/src/utils/errors.utils.ts index 327763a4a7..e3c80dbf05 100644 --- a/src/backend/src/utils/errors.utils.ts +++ b/src/backend/src/utils/errors.utils.ts @@ -215,4 +215,5 @@ export type ExceptionObjectNames = | 'ProspectiveSponsor' | 'SponsorTier' | 'Guest Definition' - | 'Meeting Attendance'; + | 'Meeting Attendance' + | 'Task Label'; diff --git a/src/backend/src/utils/tasks.utils.ts b/src/backend/src/utils/tasks.utils.ts index 9771ff0adc..4e27b2b33c 100644 --- a/src/backend/src/utils/tasks.utils.ts +++ b/src/backend/src/utils/tasks.utils.ts @@ -2,6 +2,7 @@ import { Task_Priority, Task_Status } from '@prisma/client'; import { Task, TaskPriority, TaskStatus } from 'shared'; import prisma from '../prisma/prisma.js'; import { sendSlackTaskAssignedNotification } from './slack.utils.js'; +import { DeletedException, InvalidOrganizationException, NotFoundException } from './errors.utils.js'; export const convertTaskPriority = (priority: Task_Priority): TaskPriority => ({ @@ -17,6 +18,28 @@ export const convertTaskStatus = (status: Task_Status): TaskStatus => DONE: TaskStatus.DONE })[status]; +/** + * Validates that all given label IDs exist, are not deleted, and belong to the given organization. + * @throws NotFoundException if any ID doesn't exist + * @throws DeletedException if any label is soft-deleted + * @throws InvalidOrganizationException if any label belongs to a different organization + */ +export const validateTaskLabels = async (labelIds: string[], organizationId: string): Promise => { + if (labelIds.length === 0) return; + + const labels = await prisma.task_Label.findMany({ where: { taskLabelId: { in: labelIds } } }); + + const foundIds = labels.map((l) => l.taskLabelId); + const missingIds = labelIds.filter((id) => !foundIds.includes(id)); + if (missingIds.length > 0) throw new NotFoundException('Task Label', missingIds.join(', ')); + + const deletedLabel = labels.find((l) => l.dateDeleted !== null); + if (deletedLabel) throw new DeletedException('Task Label', deletedLabel.taskLabelId); + + const wrongOrgLabel = labels.find((l) => l.organizationId !== organizationId); + if (wrongOrgLabel) throw new InvalidOrganizationException('Task Label'); +}; + /** * Sends a task assigned notification to the specified users on Slack * @param task the task the users are assigned to diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index 430e4ee640..999230b412 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -133,6 +133,7 @@ export const resetUsers = async () => { await prisma.vendor.deleteMany(); await prisma.account_Code.deleteMany(); await prisma.car.deleteMany(); + await prisma.task_Label.deleteMany(); await prisma.task.deleteMany(); await prisma.stage_Gate_CR.deleteMany(); await prisma.activation_CR.deleteMany(); @@ -794,8 +795,9 @@ export const createTestTaskWithOrganization = async (user: User, organization?: TaskStatus.IN_PROGRESS, [user.userId], organization, - undefined, - new Date() + [], + new Date(), + new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) ); if (!task) throw new Error('Failed to create task'); diff --git a/src/backend/tests/unmocked/task.test.ts b/src/backend/tests/unmocked/task.test.ts index e94c6f5907..397ee61bc6 100644 --- a/src/backend/tests/unmocked/task.test.ts +++ b/src/backend/tests/unmocked/task.test.ts @@ -1,5 +1,11 @@ -import { financeMember, supermanAdmin, theVisitorGuest } from '../test-data/users.test-data.js'; -import { AccessDeniedException, HttpException, NotFoundException, DeletedException } from '../../src/utils/errors.utils.js'; +import { financeMember, flashAdmin, supermanAdmin, theVisitorGuest } from '../test-data/users.test-data.js'; +import { + AccessDeniedException, + HttpException, + InvalidOrganizationException, + NotFoundException, + DeletedException +} from '../../src/utils/errors.utils.js'; import { createTestOrganization, createTestTask, @@ -11,12 +17,15 @@ import { import prisma from '../../src/prisma/prisma.js'; import TasksService from '../../src/services/tasks.services.js'; import { WbsNumber } from 'shared'; +import { Organization } from '@prisma/client'; describe('Task Tests', () => { let organizationId: string; + let organization: Organization; beforeEach(async () => { - ({ organizationId } = await createTestOrganization()); + organization = await createTestOrganization(); + ({ organizationId } = organization); }); afterEach(async () => { @@ -55,6 +64,7 @@ describe('Task Tests', () => { 'Test Task', '', 'HIGH', + [], undefined, undefined, newWbsNum @@ -68,7 +78,7 @@ describe('Task Tests', () => { 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'); + const updatedTask = await TasksService.editTask(user, organizationId, task.taskId, 'Updated Title', '', 'HIGH', []); expect(updatedTask.taskId).toBe(task.taskId); expect(updatedTask.title).toBe('Updated Title'); @@ -88,6 +98,7 @@ describe('Task Tests', () => { 'Test Task', '', 'HIGH', + [], undefined, undefined, nonExistentWbsNum @@ -128,12 +139,63 @@ describe('Task Tests', () => { 'Test Task', '', 'HIGH', + [], undefined, undefined, deletedWbsNum ) ).rejects.toThrow(DeletedException); }); + + it('successfully sets labels on a task', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const task = await createTestTask(user, 'Test Task', '', [], 'HIGH', 'IN_BACKLOG', organizationId); + const label = await TasksService.createTaskLabel(user, 'Test Label', '#3B82F6', organization); + + const updatedTask = await TasksService.editTask(user, organizationId, task.taskId, 'Test Task', '', 'HIGH', [ + label.taskLabelId + ]); + + expect(updatedTask.labels).toHaveLength(1); + expect(updatedTask.labels[0].taskLabelId).toBe(label.taskLabelId); + }); + + it('throws NotFoundException when a label id 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', ['nonexistent-label-id']) + ).rejects.toThrow(NotFoundException); + }); + + it('throws DeletedException when a label is deleted', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const task = await createTestTask(user, 'Test Task', '', [], 'HIGH', 'IN_BACKLOG', organizationId); + const label = await TasksService.createTaskLabel(user, 'Test Label', '#3B82F6', organization); + await prisma.task_Label.update({ where: { taskLabelId: label.taskLabelId }, data: { dateDeleted: new Date() } }); + + await expect(async () => + TasksService.editTask(user, organizationId, task.taskId, 'Test Task', '', 'HIGH', [label.taskLabelId]) + ).rejects.toThrow(DeletedException); + }); + + it('throws InvalidOrganizationException when a label belongs to a different organization', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const task = await createTestTask(user, 'Test Task', '', [], 'HIGH', 'IN_BACKLOG', organizationId); + const otherOrg = await prisma.organization.create({ + data: { + name: 'Other Org', + userCreated: { connect: { userId: user.userId } } + } + }); + const otherUser = await createTestUser(flashAdmin, otherOrg.organizationId); + const label = await TasksService.createTaskLabel(otherUser, 'Test Label', '#3B82F6', otherOrg); + + await expect(async () => + TasksService.editTask(user, organizationId, task.taskId, 'Test Task', '', 'HIGH', [label.taskLabelId]) + ).rejects.toThrow(InvalidOrganizationException); + }); }); describe('Edit task status', () => { @@ -155,7 +217,6 @@ describe('Task Tests', () => { taskId: correctTask.taskId } }); - // check that status changed to correct status expect(updatedTask?.status).toBe('IN_PROGRESS'); }); @@ -205,7 +266,6 @@ describe('Task Tests', () => { 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', @@ -218,7 +278,6 @@ describe('Task Tests', () => { } }); - // create a WP on the project const wp = await prisma.work_Package.create({ data: { wbsElement: { @@ -241,7 +300,6 @@ describe('Task Tests', () => { } }); - // create a task on the WP await prisma.task.create({ data: { title: 'WP Task', @@ -323,8 +381,152 @@ describe('Task Tests', () => { 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()) + TasksService.editTask(guest, organizationId, task.taskId, 'Title', 'Notes', 'HIGH', [], new Date()) ).rejects.toThrow(new AccessDeniedException('Guests cannot edit tasks')); }); }); + + describe('Create task label', () => { + it('successfully creates a task label as admin', async () => { + const admin = await createTestUser(supermanAdmin, organizationId); + const organization = await prisma.organization.findUniqueOrThrow({ where: { organizationId } }); + + const label = await TasksService.createTaskLabel(admin, 'Bug', '#EF4444', organization); + + expect(label.name).toBe('Bug'); + expect(label.colorHexCode).toBe('#EF4444'); + }); + + it('throws AccessDeniedException when non-admin tries to create a task label', async () => { + const member = await createTestUser(financeMember, organizationId); + const organization = await prisma.organization.findUniqueOrThrow({ where: { organizationId } }); + + await expect(async () => TasksService.createTaskLabel(member, 'Bug', '#EF4444', organization)).rejects.toThrow( + new AccessDeniedException('Non admins cannot create task labels') + ); + }); + }); + + describe('Edit task label', () => { + it('successfully edits a task label as admin', async () => { + const admin = await createTestUser(supermanAdmin, organizationId); + const organization = await prisma.organization.findUniqueOrThrow({ where: { organizationId } }); + const label = await TasksService.createTaskLabel(admin, 'Test Label', '#3B82F6', organization); + + const updated = await TasksService.editTaskLabel(admin, label.taskLabelId, 'New Name', '#22C55E', organization); + + expect(updated.name).toBe('New Name'); + expect(updated.colorHexCode).toBe('#22C55E'); + }); + + it('throws AccessDeniedException when non-admin tries to edit a task label', async () => { + const admin = await createTestUser(supermanAdmin, organizationId); + const member = await createTestUser(financeMember, organizationId); + const organization = await prisma.organization.findUniqueOrThrow({ where: { organizationId } }); + const label = await TasksService.createTaskLabel(admin, 'Test Label', '#3B82F6', organization); + + await expect(async () => + TasksService.editTaskLabel(member, label.taskLabelId, 'New Name', '#22C55E', organization) + ).rejects.toThrow(AccessDeniedException); + }); + + it('throws NotFoundException when label does not exist', async () => { + const admin = await createTestUser(supermanAdmin, organizationId); + const organization = await prisma.organization.findUniqueOrThrow({ where: { organizationId } }); + + await expect(async () => + TasksService.editTaskLabel(admin, 'nonexistent-id', 'New Name', '#22C55E', organization) + ).rejects.toThrow(NotFoundException); + }); + + it('throws DeletedException when label is already deleted', async () => { + const admin = await createTestUser(supermanAdmin, organizationId); + const organization = await prisma.organization.findUniqueOrThrow({ where: { organizationId } }); + const label = await TasksService.createTaskLabel(admin, 'Test Label', '#3B82F6', organization); + await prisma.task_Label.update({ where: { taskLabelId: label.taskLabelId }, data: { dateDeleted: new Date() } }); + + await expect(async () => + TasksService.editTaskLabel(admin, label.taskLabelId, 'New Name', '#22C55E', organization) + ).rejects.toThrow(DeletedException); + }); + + it('throws InvalidOrganizationException when label belongs to a different organization', async () => { + const admin = await createTestUser(supermanAdmin, organizationId); + const organization = await prisma.organization.findUniqueOrThrow({ where: { organizationId } }); + const otherOrg = await prisma.organization.create({ + data: { + name: 'Other Org', + userCreated: { connect: { userId: admin.userId } } + } + }); + const otherUser = await createTestUser(flashAdmin, otherOrg.organizationId); + const label = await TasksService.createTaskLabel(otherUser, 'Test Label', '#3B82F6', otherOrg); + + await expect(async () => + TasksService.editTaskLabel(admin, label.taskLabelId, 'New Name', '#22C55E', organization) + ).rejects.toThrow(InvalidOrganizationException); + }); + }); + + describe('Delete task label', () => { + it('successfully deletes a task label as admin', async () => { + const admin = await createTestUser(supermanAdmin, organizationId); + const organization = await prisma.organization.findUniqueOrThrow({ where: { organizationId } }); + const label = await TasksService.createTaskLabel(admin, 'Test Label', '#3B82F6', organization); + + const deletedId = await TasksService.deleteTaskLabel(admin, label.taskLabelId, organization); + + expect(deletedId).toBe(label.taskLabelId); + const inDb = await prisma.task_Label.findUnique({ where: { taskLabelId: label.taskLabelId } }); + expect(inDb?.dateDeleted).not.toBeNull(); + }); + + it('throws AccessDeniedException when non-admin tries to delete a task label', async () => { + const admin = await createTestUser(supermanAdmin, organizationId); + const member = await createTestUser(financeMember, organizationId); + const organization = await prisma.organization.findUniqueOrThrow({ where: { organizationId } }); + const label = await TasksService.createTaskLabel(admin, 'Test Label', '#3B82F6', organization); + + await expect(async () => TasksService.deleteTaskLabel(member, label.taskLabelId, organization)).rejects.toThrow( + AccessDeniedException + ); + }); + + it('throws NotFoundException when label does not exist', async () => { + const admin = await createTestUser(supermanAdmin, organizationId); + const organization = await prisma.organization.findUniqueOrThrow({ where: { organizationId } }); + + await expect(async () => TasksService.deleteTaskLabel(admin, 'nonexistent-id', organization)).rejects.toThrow( + NotFoundException + ); + }); + + it('throws DeletedException when label is already deleted', async () => { + const admin = await createTestUser(supermanAdmin, organizationId); + const organization = await prisma.organization.findUniqueOrThrow({ where: { organizationId } }); + const label = await TasksService.createTaskLabel(admin, 'Test Label', '#3B82F6', organization); + await prisma.task_Label.update({ where: { taskLabelId: label.taskLabelId }, data: { dateDeleted: new Date() } }); + + await expect(async () => TasksService.deleteTaskLabel(admin, label.taskLabelId, organization)).rejects.toThrow( + DeletedException + ); + }); + + it('throws InvalidOrganizationException when label belongs to a different organization', async () => { + const admin = await createTestUser(supermanAdmin, organizationId); + const organization = await prisma.organization.findUniqueOrThrow({ where: { organizationId } }); + const otherOrg = await prisma.organization.create({ + data: { + name: 'Other Org', + userCreated: { connect: { userId: admin.userId } } + } + }); + const otherUser = await createTestUser(flashAdmin, otherOrg.organizationId); + const label = await TasksService.createTaskLabel(otherUser, 'Test Label', '#3B82F6', otherOrg); + + await expect(async () => TasksService.deleteTaskLabel(admin, label.taskLabelId, organization)).rejects.toThrow( + InvalidOrganizationException + ); + }); + }); }); diff --git a/src/frontend/src/apis/tasks.api.ts b/src/frontend/src/apis/tasks.api.ts index 79257cd9fa..6af0233ae8 100644 --- a/src/frontend/src/apis/tasks.api.ts +++ b/src/frontend/src/apis/tasks.api.ts @@ -9,6 +9,7 @@ import { FilterTaskArgs, Task, TaskCardPreview, + TaskLabel, TaskPriority, TaskStatus, WbsNumber, @@ -16,7 +17,7 @@ import { } from 'shared'; import axios from '../utils/axios'; import { apiUrls } from '../utils/urls'; -import { taskTransformer } from './transformers/tasks.transformers'; +import { taskLabelTransformer, taskTransformer } from './transformers/tasks.transformers'; /** * Api call to create a task. @@ -26,6 +27,7 @@ import { taskTransformer } from './transformers/tasks.transformers'; * @param status the status of the task * @param assignees the ids of the users assigned to the task * @param notes the notes for the task + * @param labelIds the ids of the labels for the task * @param deadline the datestring deadline of the task * @param startDate the datestring start date of the task * @returns @@ -37,6 +39,7 @@ export const createSingleTask = ( status: TaskStatus, assignees: string[], notes: string, + labelIds: string[], deadline?: string, startDate?: string ) => { @@ -49,7 +52,8 @@ export const createSingleTask = ( priority, status, assignees, - notes + notes, + labelIds }, { transformResponse: (data) => taskTransformer(JSON.parse(data)) @@ -63,6 +67,7 @@ export const createSingleTask = ( * @param title the new title * @param notes the new notes * @param priority the new priority + * @param labelIds the new label ids * @param deadline the new deadline * @param startDate the new start date * @param wbsNum the new wbs element @@ -73,6 +78,7 @@ export const editTask = ( title: string, notes: string, priority: TaskPriority, + labelIds: string[], deadline?: Date, startDate?: Date, wbsNum?: WbsNumber @@ -81,6 +87,7 @@ export const editTask = ( title, notes, priority, + labelIds, deadline: deadline ? dateToMidnightUTC(deadline) : undefined, startDate: startDate ? dateToMidnightUTC(startDate) : undefined, wbsNum @@ -155,3 +162,67 @@ export const getTasksByWbsNum = (wbsNum: WbsNumber) => { transformResponse: (data) => JSON.parse(data).map(taskTransformer) }); }; + +/** + * Gets tasks for a wbs element filtered by label ids (tasks matching any of the given labels) + * @param wbsNum the wbs number of the project or work package + * @param labelIds the label ids to filter by + * @returns filtered array of tasks + */ +export const getTasksByWbsNumFilteredByLabels = (wbsNum: WbsNumber, labelIds: string[]) => { + return axios.get(apiUrls.tasksByWbsNumFilteredByLabels(wbsPipe(wbsNum), labelIds), { + transformResponse: (data) => JSON.parse(data).map(taskTransformer) + }); +}; + +/** + * Gets all tasks labels for a given organization + * @returns array of task labels + */ +export const getAllTaskLabels = () => { + return axios.get(apiUrls.taskLabels(), { + transformResponse: (data) => JSON.parse(data).map(taskLabelTransformer) + }); +}; + +/** + * Api call to create a task label. + * @param name the name of the task label + * @param colorHexCode the hex code for the task label color + * @returns the created task label + */ +export const createTaskLabel = (name: string, colorHexCode: string) => { + return axios.post( + apiUrls.taskLabelCreate(), + { name, colorHexCode }, + { + transformResponse: (data) => taskLabelTransformer(JSON.parse(data)) + } + ); +}; + +/** + * Edits the task label. + * @param id the id of the task label + * @param name the name of the task label + * @param colorHexCode the hex code for the task label color + * @returns the edited task label + */ +export const editTaskLabel = (taskLabelId: string, name: string, colorHexCode: string) => { + return axios.post( + apiUrls.taskLabelEdit(taskLabelId), + { name, colorHexCode }, + { + transformResponse: (data) => taskLabelTransformer(JSON.parse(data)) + } + ); +}; + +/** + * Soft deletes a task label. + * @param taskLabelId + * @returns the deleted taskLabelId + */ +export const deleteTaskLabel = (taskLabelId: string) => { + return axios.post(apiUrls.taskLabelDelete(taskLabelId), {}); +}; diff --git a/src/frontend/src/apis/transformers/tasks.transformers.ts b/src/frontend/src/apis/transformers/tasks.transformers.ts index 6ffd8cf5e0..c0a8d7e854 100644 --- a/src/frontend/src/apis/transformers/tasks.transformers.ts +++ b/src/frontend/src/apis/transformers/tasks.transformers.ts @@ -1,4 +1,4 @@ -import { dbDateToLocalDate, Task } from 'shared'; +import { dbDateToLocalDate, Task, TaskLabel } from 'shared'; /** * Transforms a task to ensure deep field transformation of date objects. @@ -12,6 +12,15 @@ export const taskTransformer = (task: Task): Task => { dateCreated: new Date(task.dateCreated), dateDeleted: task.dateDeleted ? new Date(task.dateDeleted) : undefined, deadline: task.deadline ? dbDateToLocalDate(new Date(task.deadline)) : undefined, - startDate: task.startDate ? dbDateToLocalDate(new Date(task.startDate)) : undefined + startDate: task.startDate ? dbDateToLocalDate(new Date(task.startDate)) : undefined, + labels: task.labels.map(taskLabelTransformer) }; }; + +/** + * Transforms a task label to ensure deep field transformation of date objects. + * + * @param label Incoming task label object supplied by the HTTP response. + * @returns Properly transformed task label object. + */ +export const taskLabelTransformer = (label: TaskLabel): TaskLabel => ({ ...label }); diff --git a/src/frontend/src/hooks/tasks.hooks.ts b/src/frontend/src/hooks/tasks.hooks.ts index 04b48c08d5..bb7ca7cf52 100644 --- a/src/frontend/src/hooks/tasks.hooks.ts +++ b/src/frontend/src/hooks/tasks.hooks.ts @@ -4,7 +4,7 @@ */ import { useMutation, useQuery, useQueryClient } from 'react-query'; -import { CalendarTask, FilterTaskArgs, WbsNumber, TaskPriority, TaskStatus, Task, TaskCardPreview } from 'shared'; +import { CalendarTask, FilterTaskArgs, WbsNumber, TaskPriority, TaskStatus, Task, TaskCardPreview, TaskLabel } from 'shared'; import { createSingleTask, deleteSingleTask, @@ -13,7 +13,12 @@ import { editTaskAssignees, getOverdueTasksByTeamLeader, getFilterTasks, - getTasksByWbsNum + getTasksByWbsNum, + getTasksByWbsNumFilteredByLabels, + getAllTaskLabels, + createTaskLabel, + editTaskLabel, + deleteTaskLabel } from '../apis/tasks.api'; import { wbsPipe } from '../utils/pipes'; @@ -26,6 +31,7 @@ export interface CreateTaskPayload { status: TaskStatus; notes?: string; assignees: string[]; + labelIds: string[]; } /** @@ -58,6 +64,7 @@ export const useCreateTask = () => { createTaskPayload.status, createTaskPayload.assignees, createTaskPayload.notes ?? '', + createTaskPayload.labelIds, createTaskPayload.deadline, createTaskPayload.startDate ); @@ -68,6 +75,7 @@ export const useCreateTask = () => { queryClient.invalidateQueries(['projects']); queryClient.invalidateQueries(['filter-tasks']); queryClient.invalidateQueries(['tasks']); + queryClient.invalidateQueries(['task-labels']); } } ); @@ -81,6 +89,7 @@ export interface TaskPayload { deadline?: Date; priority: TaskPriority; wbsNum?: WbsNumber; + labelIds: string[]; } /** @@ -97,6 +106,7 @@ export const useEditTask = () => { taskPayload.title, taskPayload.notes ?? '', taskPayload.priority, + taskPayload.labelIds, taskPayload.deadline, taskPayload.startDate, taskPayload.wbsNum @@ -109,6 +119,7 @@ export const useEditTask = () => { queryClient.invalidateQueries(['projects']); queryClient.invalidateQueries(['tasks']); queryClient.invalidateQueries(['filter-tasks']); + queryClient.invalidateQueries(['task-labels']); } } ); @@ -198,3 +209,92 @@ export const useTasksByWbsNum = (wbsNum: WbsNumber) => { return data; }); }; + +/** + * Custom React Hook to get tasks for a wbs element filtered by label ids + * Only fires when labelIds is non-empty; returns all tasks when empty + * @param wbsNum the wbs number to fetch tasks for + * @param labelIds the label ids to filter by + * @returns the filtered tasks query + */ +export const useTasksByWbsNumFilteredByLabels = (wbsNum: WbsNumber, labelIds: string[]) => { + return useQuery( + ['tasks', wbsPipe(wbsNum), 'filtered-by-labels', labelIds], + async () => { + const { data } = await getTasksByWbsNumFilteredByLabels(wbsNum, labelIds); + return data; + }, + { enabled: labelIds.length > 0 } + ); +}; + +/** + * Custom React Hook to get all task labels for a given organization + * @returns the task labels query + */ +export const useAllTaskLabels = () => { + return useQuery(['task-labels'], async () => { + const { data } = await getAllTaskLabels(); + return data; + }); +}; + +/** + * Custom React Hook to create a task label + * @returns the create task label mutation + */ +export const useCreateTaskLabel = () => { + const queryClient = useQueryClient(); + return useMutation( + ['task-labels', 'create'], + async ({ name, colorHexCode }) => { + const { data } = await createTaskLabel(name, colorHexCode); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['task-labels']); + } + } + ); +}; + +/** + * Custom React Hook to edit a task label + * @returns the edit task label mutation + */ +export const useEditTaskLabel = () => { + const queryClient = useQueryClient(); + return useMutation( + ['task-labels', 'edit'], + async ({ taskLabelId, name, colorHexCode }) => { + const { data } = await editTaskLabel(taskLabelId, name, colorHexCode); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['task-labels']); + } + } + ); +}; + +/** + * Custom React Hook to delete a task label + * @returns the delete task label id + */ +export const useDeleteTaskLabel = () => { + const queryClient = useQueryClient(); + return useMutation( + ['task-labels', 'delete'], + async ({ taskLabelId }) => { + const { data } = await deleteTaskLabel(taskLabelId); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['task-labels']); + } + } + ); +}; diff --git a/src/frontend/src/pages/AdminToolsPage/AdminToolsProjectsConfig.tsx b/src/frontend/src/pages/AdminToolsPage/AdminToolsProjectsConfig.tsx index 2c77332948..82e5120070 100644 --- a/src/frontend/src/pages/AdminToolsPage/AdminToolsProjectsConfig.tsx +++ b/src/frontend/src/pages/AdminToolsPage/AdminToolsProjectsConfig.tsx @@ -12,6 +12,7 @@ import ConfluenceLink from './ProjectsConfig/ConfluenceLink'; import PartReviewSampleImage from './ProjectsConfig/PartReviewSampleImage'; import CommonMistakesTable from './ProjectsConfig/CommonMistakesTable'; import PartTagsTable from './ProjectsConfig/PartTagsTable'; +import TaskLabelsTable from './ProjectsConfig/TaskLabelsTable'; const AdminToolsProjectsConfig: React.FC = () => { return ( @@ -51,6 +52,10 @@ const AdminToolsProjectsConfig: React.FC = () => { Project Templates + + Task Labels + + ); }; diff --git a/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/TaskLabelDeleteModal.tsx b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/TaskLabelDeleteModal.tsx new file mode 100644 index 0000000000..740e7bd813 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/TaskLabelDeleteModal.tsx @@ -0,0 +1,44 @@ +import { FormControl, Typography } from '@mui/material'; +import NERModal from '../../../components/NERModal'; +import { Box } from '@mui/system'; + +interface TaskLabelDeleteModalProps { + name: string; + colorHexCode: string; + onDelete: () => void; + onHide: () => void; +} + +const TaskLabelDeleteModal: React.FC = ({ name, colorHexCode, onDelete, onHide }) => { + return ( + + Are you sure you want to delete this task label? + + + {name} + + + + ); +}; + +export default TaskLabelDeleteModal; diff --git a/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/TaskLabelFormModal.tsx b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/TaskLabelFormModal.tsx new file mode 100644 index 0000000000..30779e8c61 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/TaskLabelFormModal.tsx @@ -0,0 +1,156 @@ +import React, { useCallback } from 'react'; +import { Box, FormControl, FormHelperText, FormLabel, Stack } from '@mui/material'; +import ReactHookTextField from '../../../components/ReactHookTextField'; +import ErrorPage from '../../ErrorPage'; +import { useForm } from 'react-hook-form'; +import { useToast } from '../../../hooks/toasts.hooks'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import { useCreateTaskLabel, useEditTaskLabel } from '../../../hooks/tasks.hooks'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { TaskLabel } from 'shared'; +import NERFormModal from '../../../components/NERFormModal'; + +const COLOR_OPTIONS: { label: string; value: string }[] = [ + { label: 'Red', value: '#EF4444' }, + { label: 'Orange', value: '#F97316' }, + { label: 'Yellow', value: '#EAB308' }, + { label: 'Green', value: '#22C55E' }, + { label: 'Blue', value: '#3B82F6' }, + { label: 'Purple', value: '#A855F7' }, + { label: 'Pink', value: '#EC4899' }, + { label: 'Navy', value: '#1E3A8A' } +]; + +const schema = yup.object().shape({ + name: yup.string().required('Name is required'), + colorHexCode: yup.string().required('Color is required') +}); + +const emptyValues = { name: '', colorHexCode: '' }; + +interface TaskLabelFormModalProps { + showModal: boolean; + handleClose: () => void; + defaultValues?: TaskLabel; +} + +const TaskLabelFormModal: React.FC = ({ showModal, handleClose, defaultValues }) => { + const toast = useToast(); + const { + isLoading: createLoading, + isError: createIsError, + error: createError, + mutateAsync: createMutateAsync + } = useCreateTaskLabel(); + const { + isLoading: editLoading, + isError: editIsError, + error: editError, + mutateAsync: editMutateAsync + } = useEditTaskLabel(); + + const isEditing = !!defaultValues; + + const { + handleSubmit, + control, + reset, + watch, + setValue, + formState: { errors } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: defaultValues + ? { + name: defaultValues.name, + colorHexCode: defaultValues.colorHexCode + } + : emptyValues + }); + + const handleReset = useCallback(() => { + reset({ + name: defaultValues?.name ?? '', + colorHexCode: defaultValues?.colorHexCode ?? '' + }); + }, [reset, defaultValues]); + + const selectedColor = watch('colorHexCode'); + + const handleColorClick = (value: string) => { + setValue('colorHexCode', value, { shouldValidate: true }); + }; + + const onSubmit = async (data: { name: string; colorHexCode: string }) => { + try { + if (isEditing) { + await editMutateAsync({ taskLabelId: defaultValues.taskLabelId, ...data }); + } else { + await createMutateAsync(data); + } + } catch (error: unknown) { + if (error instanceof Error) toast.error(error.message); + } + handleClose(); + }; + + if (createIsError) return ; + if (editIsError) return ; + if (createLoading || editLoading) return ; + + return ( + { + handleReset(); + handleClose(); + }} + title={isEditing ? 'Edit Task Label' : 'New Task Label'} + reset={handleReset} + handleUseFormSubmit={handleSubmit} + onFormSubmit={onSubmit} + formId="task-label-form" + showCloseButton + > + + + Label Name + + {errors.name?.message} + + + + Color + + {COLOR_OPTIONS.map((c) => { + const isSelected = c.value === selectedColor; + return ( + handleColorClick(c.value)} + sx={{ + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + px: 1.5, + height: 28, + borderRadius: '999px', + backgroundColor: c.value, + border: isSelected ? '2px solid #ef4345' : '2px solid transparent', + boxSizing: 'border-box', + minWidth: 32 + }} + /> + ); + })} + + {errors.colorHexCode?.message} + + + + ); +}; + +export default TaskLabelFormModal; diff --git a/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/TaskLabelsTable.tsx b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/TaskLabelsTable.tsx new file mode 100644 index 0000000000..1b8ebecd16 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/TaskLabelsTable.tsx @@ -0,0 +1,109 @@ +import { Box, Chip, IconButton, TableCell, TableRow } from '@mui/material'; +import { NERButton } from '../../../components/NERButton'; +import NERTable from '../../../components/NERTable'; +import { useState } from 'react'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import { useAllTaskLabels, useDeleteTaskLabel } from '../../../hooks/tasks.hooks'; +import TaskLabelFormModal from './TaskLabelFormModal'; +import TaskLabelDeleteModal from './TaskLabelDeleteModal'; +import { useToast } from '../../../hooks/toasts.hooks'; +import { Delete, Edit } from '@mui/icons-material'; +import { TaskLabel } from 'shared'; + +interface TaskLabelActionButtonsProps { + label: TaskLabel; + onDelete: (label: TaskLabel) => void; + onEdit: (label: TaskLabel) => void; +} + +const TaskLabelActionButtons: React.FC = ({ label, onDelete, onEdit }) => { + const [showDeleteModal, setShowDeleteModal] = useState(false); + + return ( + <> + onEdit(label)} sx={{ mx: 0.5 }}> + + + setShowDeleteModal(true)} sx={{ mx: 0.5 }}> + + + {showDeleteModal && ( + { + onDelete(label); + setShowDeleteModal(false); + }} + onHide={() => setShowDeleteModal(false)} + /> + )} + + ); +}; + +const TaskLabelsTable: React.FC = () => { + const { data: taskLabels, isLoading, isError, error } = useAllTaskLabels(); + const toast = useToast(); + const { mutateAsync: deleteMutateAsync } = useDeleteTaskLabel(); + const [openCreateModal, setOpenCreateModal] = useState(false); + const [editingLabel, setEditingLabel] = useState(undefined); + + if (isError) return ; + if (!taskLabels || isLoading) return ; + + const handleDelete = async (label: TaskLabel) => { + try { + await deleteMutateAsync({ taskLabelId: label.taskLabelId }); + toast.success(`Task Label "${label.name}" deleted successfully!`); + } catch (e: unknown) { + if (e instanceof Error) toast.error(e.message); + } + }; + + const rows = taskLabels.map((label, index) => ( + + + + + + setEditingLabel(l)} /> + + + )); + + return ( + + setOpenCreateModal(false)} /> + {editingLabel && ( + setEditingLabel(undefined)} defaultValues={editingLabel} /> + )} + + + setOpenCreateModal(true)}> + New Label + + + + ); +}; + +export default TaskLabelsTable; diff --git a/src/frontend/src/pages/CalendarPage/Components/CalendarCreateTaskModal.tsx b/src/frontend/src/pages/CalendarPage/Components/CalendarCreateTaskModal.tsx index e5ec410c2c..b37702c109 100644 --- a/src/frontend/src/pages/CalendarPage/Components/CalendarCreateTaskModal.tsx +++ b/src/frontend/src/pages/CalendarPage/Components/CalendarCreateTaskModal.tsx @@ -1,12 +1,21 @@ import React from 'react'; import { yupResolver } from '@hookform/resolvers/yup'; -import { Autocomplete, FormControl, FormHelperText, FormLabel, Grid, MenuItem, TextField } from '@mui/material'; +import { Autocomplete, Box, Chip, 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, dateToMidnightUTC, isUnderWordCount, ProjectPreview, TaskPriority, TaskStatus, wbsPipe } from 'shared'; +import { + countWords, + dateToMidnightUTC, + isUnderWordCount, + ProjectPreview, + TaskLabel, + TaskPriority, + TaskStatus, + wbsPipe +} from 'shared'; import { useAllMembers } from '../../../hooks/users.hooks'; import { useAllProjects } from '../../../hooks/projects.hooks'; -import { useCreateTask } from '../../../hooks/tasks.hooks'; +import { useAllTaskLabels, useCreateTask } from '../../../hooks/tasks.hooks'; import { useToast } from '../../../hooks/toasts.hooks'; import { taskUserToAutocompleteOption } from '../../../utils/task.utils'; import * as yup from 'yup'; @@ -20,6 +29,7 @@ const schema = yup.object().shape({ priority: yup.mixed().oneOf(Object.values(TaskPriority)).required(), status: yup.mixed().oneOf(Object.values(TaskStatus)).required(), assignees: yup.array().of(yup.string().required()).required(), + labels: yup.array().of(yup.mixed().required()).required(), startDate: yup.date().optional(), deadline: yup.date().optional(), notes: yup.string().optional() @@ -31,6 +41,7 @@ interface CreateTaskFormInput { priority: TaskPriority; status: TaskStatus; assignees: string[]; + labels: TaskLabel[]; startDate?: Date; deadline?: Date; notes?: string; @@ -47,6 +58,7 @@ const CalendarCreateTaskModal: React.FC = ({ open, const { mutateAsync: createTask, isLoading } = useCreateTask(); const { data: users, isLoading: usersLoading, isError: usersError, error: usersErr } = useAllMembers(); const { data: projects, isLoading: projectsLoading, isError: projectsError, error: projectsErr } = useAllProjects(); + const { data: taskLabels, isLoading: labelsIsLoading, isError: labelsIsError, error: labelsError } = useAllTaskLabels(); const { handleSubmit, @@ -61,6 +73,7 @@ const CalendarCreateTaskModal: React.FC = ({ open, priority: TaskPriority.Medium, status: TaskStatus.IN_BACKLOG, assignees: [], + labels: [], startDate: undefined, deadline: defaultDeadline, notes: '' @@ -69,7 +82,8 @@ const CalendarCreateTaskModal: React.FC = ({ open, if (usersError) return ; if (projectsError) return ; - if (usersLoading || !users || projectsLoading || !projects) return ; + if (labelsIsError) return ; + if (usersLoading || !users || projectsLoading || !projects || labelsIsLoading || !taskLabels) return ; const userOptions = users.map(taskUserToAutocompleteOption); @@ -85,6 +99,7 @@ const CalendarCreateTaskModal: React.FC = ({ open, status: data.status, assignees: data.assignees, notes: data.notes, + labelIds: data.labels.map((l) => l.taskLabelId), deadline: data.deadline ? dateToMidnightUTC(data.deadline).toISOString() : undefined, startDate: data.startDate ? dateToMidnightUTC(data.startDate).toISOString() : undefined }); @@ -221,6 +236,60 @@ const CalendarCreateTaskModal: React.FC = ({ open, /> + + + Labels + ( + option.name} + isOptionEqualToValue={(option, val) => option.taskLabelId === val.taskLabelId} + onChange={(_, selected) => onChange(selected)} + value={value} + renderOption={(props, option) => ( +
  • + + {option.name} + +
  • + )} + renderTags={(selected, getTagProps) => + selected.map((label, index) => ( + + )) + } + renderInput={(params) => } + /> + )} + /> +
    +
    Start Date diff --git a/src/frontend/src/pages/CalendarPage/TaskClickPopup.tsx b/src/frontend/src/pages/CalendarPage/TaskClickPopup.tsx index 2481e94e19..097605c6ca 100644 --- a/src/frontend/src/pages/CalendarPage/TaskClickPopup.tsx +++ b/src/frontend/src/pages/CalendarPage/TaskClickPopup.tsx @@ -63,6 +63,7 @@ export const TaskClickContent: React.FC = ({ task, onClos title: data.title, notes: data.notes, priority: data.priority, + labelIds: task.labels.map((l) => l.taskLabelId), startDate: data.startDate, deadline: data.deadline }); diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/AddGanttTaskModal.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/AddGanttTaskModal.tsx index f8225a564a..04d6cfc93c 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/AddGanttTaskModal.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/AddGanttTaskModal.tsx @@ -1,11 +1,12 @@ import React from 'react'; import { yupResolver } from '@hookform/resolvers/yup'; -import { FormControl, FormHelperText, FormLabel, MenuItem, TextField, Autocomplete, Grid } from '@mui/material'; +import { Autocomplete, Box, Chip, FormControl, FormHelperText, FormLabel, Grid, MenuItem, TextField } from '@mui/material'; import { Controller, useForm } from 'react-hook-form'; -import { countWords, isUnderWordCount, TaskPriority, TaskStatus, WorkPackage, WbsNumber } from 'shared'; +import { countWords, isUnderWordCount, TaskLabel, TaskPriority, TaskStatus, WorkPackage, WbsNumber } from 'shared'; import * as yup from 'yup'; import NERFormModal from '../../../components/NERFormModal'; import { useAllMembers } from '../../../hooks/users.hooks'; +import { useAllTaskLabels } from '../../../hooks/tasks.hooks'; import { DatePicker } from '@mui/x-date-pickers/DatePicker'; import LoadingIndicator from '../../../components/LoadingIndicator'; import ErrorPage from '../../ErrorPage'; @@ -16,6 +17,7 @@ const schema = yup.object().shape({ priority: yup.string().required('Priority is required'), status: yup.string().required('Status is required'), assignees: yup.array().of(yup.string()).min(0, 'At least 0 assignees are required'), + labels: yup.array().of(yup.mixed().required()).required(), notes: yup.string(), startDate: yup.date().nullable(), deadline: yup.date().nullable(), @@ -27,6 +29,7 @@ interface CreateTaskFormData { priority: TaskPriority; status: TaskStatus; assignees: string[]; + labels: TaskLabel[]; notes: string; startDate: Date | null; deadline: Date | null; @@ -42,6 +45,7 @@ interface AddGanttTaskModalProps { const AddGanttTaskModal: React.FC = ({ showModal, handleClose, addTask, workPackages }) => { const { isLoading: usersIsLoading, isError: usersIsError, data: users, error: usersError } = useAllMembers(); + const { data: taskLabels, isLoading: labelsIsLoading, isError: labelsIsError, error: labelsError } = useAllTaskLabels(); const unUpperCase = (str: string) => str.charAt(0) + str.slice(1).toLowerCase(); @@ -57,6 +61,7 @@ const AddGanttTaskModal: React.FC = ({ showModal, handle priority: TaskPriority.Medium, status: TaskStatus.IN_BACKLOG, assignees: [], + labels: [], notes: '', startDate: null, deadline: null, @@ -65,7 +70,8 @@ const AddGanttTaskModal: React.FC = ({ showModal, handle }); if (usersIsError) return ; - if (!users || usersIsLoading) return ; + if (labelsIsError) return ; + if (!users || usersIsLoading || labelsIsLoading || !taskLabels) return ; const options: { label: string; id: string }[] = users.map(taskUserToAutocompleteOption); const wpOptions: { label: string; wbsNum: WbsNumber }[] = workPackages.map((wp) => ({ @@ -83,9 +89,6 @@ const AddGanttTaskModal: React.FC = ({ showModal, handle handleClose(); }; - if (usersIsError) return ; - if (usersIsLoading) return ; - return ( = ({ showModal, handle /> + + + Labels + ( + option.name} + isOptionEqualToValue={(option, val) => option.taskLabelId === val.taskLabelId} + onChange={(_, selected) => onChange(selected)} + value={value} + renderOption={(props, option) => ( +
  • + + {option.name} + +
  • + )} + renderTags={(selected, getTagProps) => + selected.map((label, index) => ( + + )) + } + renderInput={(params) => } + /> + )} + /> +
    +
    Start Date (MM-DD-YYYY) diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttProjectCreateModal.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttProjectCreateModal.tsx index 5c41e9b71a..43546f33c8 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttProjectCreateModal.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttProjectCreateModal.tsx @@ -73,6 +73,7 @@ export const GanttProjectCreateModal = ({ change, handleClose, open }: GanttProj status: task.status, assignees: task.assignees.map((user) => user.userId), notes: task.notes || '', + labelIds: task.labels.map((l) => l.taskLabelId), deadline: task.deadline ? dateToMidnightUTC(task.deadline).toISOString() : undefined, startDate: task.startDate ? dateToMidnightUTC(task.startDate).toISOString() : undefined }); diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx index 021054f570..1d0e47005e 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChangeModals/GanttTimeLineChangeModal.tsx @@ -199,6 +199,7 @@ export const GanttTimeLineChangeModal = ({ change, handleClose, open }: GanttTim status: task.status, assignees: task.assignees?.map((user) => user.userId) || [], notes: task.notes || '', + labelIds: task.labels.map((l) => l.taskLabelId), deadline: task.deadline ? dateToMidnightUTC(task.deadline).toISOString() : undefined, startDate: task.startDate ? dateToMidnightUTC(task.startDate).toISOString() : undefined }; @@ -221,6 +222,7 @@ export const GanttTimeLineChangeModal = ({ change, handleClose, open }: GanttTim title: task.title, priority: task.priority, notes: task.notes || '', + labelIds: task.labels.map((l) => l.taskLabelId), deadline: task.deadline, startDate: task.startDate }; diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx index ddbaa6afe1..ff1db6e896 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx @@ -32,6 +32,7 @@ import GanttChart from '../GanttChart/GanttChart'; import { ProjectGantt, Task, + TaskLabel, TaskPriority, TaskStatus, TeamPreview, @@ -323,6 +324,7 @@ const ProjectGanttChartPage: FC = () => { priority: TaskPriority; status: TaskStatus; assignees: string[]; + labels: TaskLabel[]; notes: string; startDate: Date | null; deadline: Date | null; @@ -354,6 +356,7 @@ const ProjectGanttChartPage: FC = () => { role: user.role }, assignees: [], + labels: taskInfo.labels, deadline, startDate, priority: taskInfo.priority, diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx index a7b48617cf..951bbfbfcd 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx @@ -1,8 +1,8 @@ import { yupResolver } from '@hookform/resolvers/yup'; -import { Autocomplete, FormControl, FormHelperText, FormLabel, Grid, MenuItem, TextField } from '@mui/material'; +import { Autocomplete, Box, Chip, 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, WbsNumber } from 'shared'; +import { countWords, isGuest, isUnderWordCount, Task, TaskLabel, TaskPriority, TaskStatus, WbsNumber } from 'shared'; import { useAllMembers, useCurrentUser } from '../../../../hooks/users.hooks'; import * as yup from 'yup'; import { taskUserToAutocompleteOption } from '../../../../utils/task.utils'; @@ -10,12 +10,14 @@ import NERFormModal from '../../../../components/NERFormModal'; import LoadingIndicator from '../../../../components/LoadingIndicator'; import ErrorPage from '../../../ErrorPage'; import { useWorkPackagesByProject } from '../../../../hooks/work-packages.hooks'; +import { useAllTaskLabels } from '../../../../hooks/tasks.hooks'; export interface EditTaskFormInput { taskId: string; title: string; notes?: string; assignees: string[]; + labels: TaskLabel[]; startDate?: Date; deadline?: Date; priority: TaskPriority; @@ -59,6 +61,7 @@ const TaskFormModal: React.FC = ({ deadline: yup.date().required('Deadline is required for In Progress tasks'), priority: yup.mixed().oneOf(Object.values(TaskPriority)).required(), assignees: yup.array().required().min(1, 'At least one assignee is required for In Progress tasks'), + labels: yup.array().of(yup.mixed().required()).required(), title: yup.string().required(), taskId: yup.string().required(), wpWbsNum: yup.mixed().optional() @@ -77,6 +80,7 @@ const TaskFormModal: React.FC = ({ deadline: yup.date().optional(), priority: yup.mixed().oneOf(Object.values(TaskPriority)).required(), assignees: yup.array().required(), + labels: yup.array().of(yup.mixed().required()).required(), title: yup.string().required(), taskId: yup.string().required(), wpWbsNum: yup.mixed().nullable().optional() @@ -86,6 +90,7 @@ const TaskFormModal: React.FC = ({ const user = useCurrentUser(); const { data: users, isLoading: usersLoading, isError, error } = useAllMembers(); + const { data: taskLabels, isLoading: labelsIsLoading, isError: labelsIsError, error: labelsError } = useAllTaskLabels(); const projectWbsNum = { ...wbsNum, workPackageNumber: 0 }; const { data: workPackages } = useWorkPackagesByProject(projectWbsNum); @@ -107,12 +112,14 @@ const TaskFormModal: React.FC = ({ deadline: task?.deadline ?? undefined, priority: task?.priority ?? TaskPriority.Low, assignees: task?.assignees.map((assignee) => assignee.userId) ?? [], + labels: task?.labels ?? [], wpWbsNum: task?.wbsNum.workPackageNumber !== 0 ? task?.wbsNum : undefined } }); if (isError) return ; - if (usersLoading || !users) return ; + if (labelsIsError) return ; + if (usersLoading || !users || labelsIsLoading || !taskLabels) return ; const userOptions: { label: string; id: string }[] = users.map(taskUserToAutocompleteOption); const wpOptions: { label: string; wbsNum: WbsNumber }[] = (workPackages ?? []).map((wp) => ({ @@ -244,6 +251,60 @@ const TaskFormModal: React.FC = ({ {errors.assignees?.message} + + + Labels + ( + option.name} + isOptionEqualToValue={(option, val) => option.taskLabelId === val.taskLabelId} + onChange={(_, selected) => onChange(selected)} + value={value} + renderOption={(props, option) => ( +
  • + + {option.name} + +
  • + )} + renderTags={(selected, getTagProps) => + selected.map((label, index) => ( + + )) + } + renderInput={(params) => } + /> + )} + /> +
    +
    Start Date (MM-DD-YYYY) diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx index ee71b6e733..9e97f2ae20 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx @@ -5,7 +5,7 @@ import { fullNamePipe, datePipe } from '../../../../utils/pipes'; import { Task, WbsNumber } from 'shared'; -import { Box, Grid, Typography } from '@mui/material'; +import { Box, Chip, Grid, Typography } from '@mui/material'; import { useState } from 'react'; import TaskFormModal, { EditTaskFormInput } from './TaskFormModal'; import NERModal from '../../../../components/NERModal'; @@ -81,6 +81,19 @@ const TaskModal: React.FC = ({ task, modalShow, onHide, onSubmit )} + + Label(s): + + {task.labels.map((label) => ( + + ))} + + Notes: 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 6e7b10d68d..b936229a01 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx @@ -72,6 +72,7 @@ export const TaskCard = ({ title, deadline, assignees, + labels, priority, startDate, wpWbsNum @@ -88,6 +89,7 @@ export const TaskCard = ({ deadline, startDate, priority, + labelIds: labels.map((l) => l.taskLabelId), wbsNum: targetWbsNum }); @@ -192,6 +194,20 @@ export const TaskCard = ({ }} /> )} + {task.labels.map((label) => ( + + ))} 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 b726e2d5d3..67a3aa001d 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskColumn.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskColumn.tsx @@ -51,6 +51,7 @@ export const TaskColumn = ({ title, deadline, assignees, + labels, priority, startDate, wpWbsNum @@ -64,7 +65,8 @@ export const TaskColumn = ({ priority, status: status as TaskStatus, assignees, - notes + notes, + labelIds: labels.map((l) => l.taskLabelId) }); onAddTask(task); toast.success('Task Successfully Created!'); 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 89a74da15a..681650fc25 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskListContent.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskListContent.tsx @@ -1,9 +1,15 @@ import { DragDropContext, OnDragEndResponder, OnDragStartResponder } from '@hello-pangea/dnd'; -import { Box } from '@mui/material'; +import { Autocomplete, Box, Button, Chip, TextField, Typography } from '@mui/material'; +import FilterListIcon from '@mui/icons-material/FilterList'; import { useCallback, useState, useEffect } from 'react'; -import { Task, TaskStatus, TaskWithIndex, WbsNumber } from 'shared'; +import { Task, TaskLabel, TaskStatus, TaskWithIndex, WbsNumber } from 'shared'; import { getTasksByStatus, statuses, TasksByStatus } from '.'; -import { useSetTaskStatus, useTasksByWbsNum } from '../../../../../hooks/tasks.hooks'; +import { + useAllTaskLabels, + useSetTaskStatus, + useTasksByWbsNum, + useTasksByWbsNumFilteredByLabels +} from '../../../../../hooks/tasks.hooks'; import { useToast } from '../../../../../hooks/toasts.hooks'; import { TaskColumn } from './TaskColumn'; import confetti from 'canvas-confetti'; @@ -15,7 +21,29 @@ interface TaskListContentProps { } export const TaskListContent = ({ wbsNum }: TaskListContentProps) => { - const { data: tasks, isLoading, isError, error } = useTasksByWbsNum(wbsNum); + const [showFilters, setShowFilters] = useState(false); + const [selectedLabelIds, setSelectedLabelIds] = useState([]); + const isFiltering = selectedLabelIds.length > 0; + + const { + data: allTasks, + isLoading: allTasksLoading, + isError: allTasksIsError, + error: allTasksError + } = useTasksByWbsNum(wbsNum); + const { + data: filteredTasks, + isLoading: filteredTasksLoading, + isError: filteredTasksIsError, + error: filteredTasksError + } = useTasksByWbsNumFilteredByLabels(wbsNum, selectedLabelIds); + const { data: taskLabels } = useAllTaskLabels(); + + const tasks = isFiltering ? filteredTasks : allTasks; + const isLoading = isFiltering ? filteredTasksLoading : allTasksLoading; + const isError = isFiltering ? filteredTasksIsError : allTasksIsError; + const error = isFiltering ? filteredTasksError : allTasksError; + const [tasksByStatus, setTasksByStatus] = useState(undefined); // can't use getTasksByStatus since tasks are async const { mutateAsync: setTaskStatus } = useSetTaskStatus(); @@ -141,6 +169,65 @@ export const TaskListContent = ({ wbsNum }: TaskListContentProps) => { return ( + + + + {showFilters && ( + + option.name} + isOptionEqualToValue={(option, val) => option.taskLabelId === val.taskLabelId} + value={(taskLabels ?? []).filter((l) => selectedLabelIds.includes(l.taskLabelId))} + onChange={(_, selected) => setSelectedLabelIds(selected.map((l) => l.taskLabelId))} + renderOption={(props, option) => ( +
  • + + {option.name} + +
  • + )} + renderTags={(selected, getTagProps) => + selected.map((label, index) => ( + + )) + } + renderInput={(params) => ( + + )} + sx={{ width: '20%', minWidth: 200 }} + /> +
    + )} {statuses.map((status) => ( ( notes: '', dateCreated: new Date(), createdBy: exampleAdminUser, - assignees: [] + assignees: [], + labels: [] }, new Error() ); @@ -87,7 +88,8 @@ export const mockCreateTaskReturnValue = mockUseMutationResult( notes: '', dateCreated: new Date(), createdBy: exampleAdminUser, - assignees: [] + assignees: [], + labels: [] }, new Error() ) as UseMutationResult; @@ -113,7 +115,8 @@ export const mockEditTaskAssigneesReturnValue = mockUseMutationResult( notes: '', dateCreated: new Date(), createdBy: exampleAdminUser, - assignees: [] + assignees: [], + labels: [] }, new Error() ) as UseMutationResult; 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 c689443415..bee159d49c 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 @@ -16,6 +16,7 @@ export const exampleTask1: Task = { dateCreated: new Date('2023-03-04T00:00:00-05:00'), createdBy: exampleLeadershipUser, assignees: [exampleMemberUser], + labels: [], deadline: new Date('2024-03-01T00:00:00-05:00'), priority: TaskPriority.Medium, status: TaskStatus.IN_PROGRESS @@ -30,6 +31,7 @@ export const exampleTask1DueSoon: Task = { dateCreated: new Date('2023-03-04T00:00:00-05:00'), createdBy: exampleLeadershipUser, assignees: [exampleMemberUser], + labels: [], deadline: new Date('2023-11-01T00:00:00-05:00'), priority: TaskPriority.Medium, status: TaskStatus.IN_PROGRESS diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index ee39409179..3f4002f165 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -98,6 +98,12 @@ 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}`; +const tasksByWbsNumFilteredByLabels = (wbsNum: string, labelIds: string[]) => + `${tasks()}/by-wbs/${wbsNum}/filter-by-labels?labelIds=${labelIds.join(',')}`; +const taskLabels = () => `${tasks()}/task-labels`; +const taskLabelCreate = () => `${taskLabels()}/create`; +const taskLabelEdit = (taskLabelId: string) => `${taskLabels()}/${taskLabelId}/edit`; +const taskLabelDelete = (taskLabelId: string) => `${taskLabels()}/${taskLabelId}/delete`; /**************** Work Packages Endpoints ****************/ const workPackages = (queryParams?: { [field: string]: string }) => { @@ -595,6 +601,11 @@ export const apiUrls = { deleteTask, overdueTasksByTeamLeadership, tasksByWbsNum, + tasksByWbsNumFilteredByLabels, + taskLabels, + taskLabelCreate, + taskLabelEdit, + taskLabelDelete, workPackages, workPackagesByWbsNum, diff --git a/src/shared/src/types/task-types.ts b/src/shared/src/types/task-types.ts index fd5ba4a80d..f6c29a120b 100644 --- a/src/shared/src/types/task-types.ts +++ b/src/shared/src/types/task-types.ts @@ -29,6 +29,7 @@ export interface Task { createdBy: User; deletedBy?: User; assignees: User[]; + labels: TaskLabel[]; startDate?: Date; deadline?: Date; priority: TaskPriority; @@ -58,3 +59,9 @@ export interface CalendarTask extends Task { projectLeadId?: string; projectManagerId?: string; } + +export interface TaskLabel { + taskLabelId: string; + name: string; + colorHexCode: string; +}