From 3ff416cd9b0d572d9c4829a8da33e289af89522f Mon Sep 17 00:00:00 2001 From: getheobald Date: Mon, 23 Mar 2026 16:53:45 -0400 Subject: [PATCH 01/23] #4048 wp query args and project transformer edits to start fetching wp tasks --- .../prisma-query-args/work-packages.query-args.ts | 4 +++- .../src/transformers/projects.transformer.ts | 15 ++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/backend/src/prisma-query-args/work-packages.query-args.ts b/src/backend/src/prisma-query-args/work-packages.query-args.ts index 36ffe33223..78c0378d50 100644 --- a/src/backend/src/prisma-query-args/work-packages.query-args.ts +++ b/src/backend/src/prisma-query-args/work-packages.query-args.ts @@ -3,6 +3,7 @@ import { getUserPreviewQueryArgs, getUserQueryArgs } from './user.query-args.js' import { getDescriptionBulletQueryArgs } from './description-bullets.query-args.js'; import { getLinkQueryArgs } from './links.query-args.js'; import { getEventQueryArgs } from './event.query-args.js'; +import { getTaskQueryArgs } from './tasks.query-args.js'; export type WorkPackageQueryArgs = ReturnType; export type WorkPackagePreviewQueryArgs = ReturnType; @@ -30,7 +31,8 @@ export const getWorkPackageQueryArgs = (organizationId: string) => orderBy: { dateImplemented: 'asc' } }, blocking: { where: { wbsElement: { dateDeleted: null } }, include: { wbsElement: true } }, - descriptionBullets: { where: { dateDeleted: null }, ...getDescriptionBulletQueryArgs(organizationId) } + descriptionBullets: { where: { dateDeleted: null }, ...getDescriptionBulletQueryArgs(organizationId) }, + tasks: { where: { dateDeleted: null }, ...getTaskQueryArgs(organizationId) } } }, blockedBy: { where: { dateDeleted: null } }, diff --git a/src/backend/src/transformers/projects.transformer.ts b/src/backend/src/transformers/projects.transformer.ts index 85325ae54f..b60bc3162e 100644 --- a/src/backend/src/transformers/projects.transformer.ts +++ b/src/backend/src/transformers/projects.transformer.ts @@ -62,7 +62,10 @@ const projectTransformer = (project: Prisma.ProjectGetPayload) startDate: calculateProjectStartDate(project.workPackages), endDate: calculateProjectEndDate(project.workPackages), descriptionBullets: wbsElement.descriptionBullets.map(descBulletConverter), - tasks: wbsElement.tasks.map(taskTransformer), + tasks: [ + ...wbsElement.tasks.map(taskTransformer), // this project's tasks + ...project.workPackages.flatMap((wp) => wp.wbsElement.tasks.map(taskTransformer)) // all of this project's work packages' tasks, flattened to one array + ], workPackages: project.workPackages.map(workPackageTransformer), abbreviation: project.abbreviation ?? undefined }; @@ -91,7 +94,10 @@ export const projectGanttTransformer = (project: Prisma.ProjectGetPayload wp.wbsElement.tasks.map(taskTransformer)) // all of this project's work packages' tasks, flattened to one array + ], workPackages: project.workPackages.map(workPackageTransformer), abbreviation: project.abbreviation ?? undefined }; @@ -141,7 +147,10 @@ export const projectPreviewTransformer = (project: Prisma.ProjectGetPayload): ProjectOverview => { return { ...projectPreviewTransformer(project), - tasks: project.wbsElement.tasks.map(taskTransformer), + tasks: [ + ...project.wbsElement.tasks.map(taskTransformer), // this project's tasks + ...project.workPackages.flatMap((wp) => wp.wbsElement.tasks.map(taskTransformer)) // all of this project's work packages' tasks, flattened to one array + ], links: project.wbsElement.links }; }; From a230f8e25e4c7c12496d576499e82215e9d21259 Mon Sep 17 00:00:00 2001 From: getheobald Date: Mon, 23 Mar 2026 19:45:14 -0400 Subject: [PATCH 02/23] #4048 finish backend transformer and query arg updates --- .../src/prisma-query-args/projects.query-args.ts | 8 ++++++-- .../prisma-query-args/work-packages.query-args.ts | 14 ++++++++++++++ src/backend/src/transformers/tasks.transformer.ts | 2 ++ .../src/transformers/work-packages.transformer.ts | 2 ++ .../transformers/work-packages.transformers.ts | 4 +++- src/shared/src/types/project-types.ts | 1 + src/shared/src/types/task-types.ts | 1 + 7 files changed, 29 insertions(+), 3 deletions(-) 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 1c430b3c8a..34546b6834 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,11 @@ 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 { + getWorkPackagePreviewQueryArgs, + getWorkPackageQueryArgs, + getWorkPackagePreviewWithTasksQueryArgs +} from './work-packages.query-args.js'; export type ProjectQueryArgs = ReturnType; @@ -94,7 +98,7 @@ export const getProjectPreviewQueryArgs = (organizationId: string) => status: true } }, - workPackages: getWorkPackagePreviewQueryArgs(), + workPackages: getWorkPackagePreviewWithTasksQueryArgs(organizationId), projectId: true, budget: true, abbreviation: true, diff --git a/src/backend/src/prisma-query-args/work-packages.query-args.ts b/src/backend/src/prisma-query-args/work-packages.query-args.ts index 78c0378d50..a8fa05b678 100644 --- a/src/backend/src/prisma-query-args/work-packages.query-args.ts +++ b/src/backend/src/prisma-query-args/work-packages.query-args.ts @@ -7,6 +7,7 @@ import { getTaskQueryArgs } from './tasks.query-args.js'; export type WorkPackageQueryArgs = ReturnType; export type WorkPackagePreviewQueryArgs = ReturnType; +export type WorkPackagePreviewWithTasksQueryArgs = ReturnType; export const getWorkPackageQueryArgs = (organizationId: string) => Prisma.validator()({ @@ -75,3 +76,16 @@ export const getWorkPackagePreviewQueryArgs = () => stage: true } }); + +export const getWorkPackagePreviewWithTasksQueryArgs = (organizationId: string) => + Prisma.validator()({ + select: { + ...getWorkPackagePreviewQueryArgs().select, + wbsElement: { + select: { + ...getWorkPackagePreviewQueryArgs().select.wbsElement.select, + tasks: { where: { dateDeleted: null }, ...getTaskQueryArgs(organizationId) } + } + } + } + }); diff --git a/src/backend/src/transformers/tasks.transformer.ts b/src/backend/src/transformers/tasks.transformer.ts index a68be3f693..b182646fba 100644 --- a/src/backend/src/transformers/tasks.transformer.ts +++ b/src/backend/src/transformers/tasks.transformer.ts @@ -10,6 +10,7 @@ const taskTransformer = (task: Prisma.TaskGetPayload): Task => { 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): WorkPackage => { const wbsNum = wbsNumOf(wpInput.wbsElement); @@ -42,6 +43,7 @@ const workPackageTransformer = (wpInput: Prisma.Work_PackageGetPayload eventPreviewTransformer(event, `${wpInput.project.wbsElement.name} - ${wpInput.wbsElement.name}`) ), + tasks: wpInput.wbsElement.tasks.map(taskTransformer), deleted: wpInput.wbsElement.dateDeleted !== null }; }; diff --git a/src/frontend/src/apis/transformers/work-packages.transformers.ts b/src/frontend/src/apis/transformers/work-packages.transformers.ts index 6c2a9fc04d..4a47a1a88c 100644 --- a/src/frontend/src/apis/transformers/work-packages.transformers.ts +++ b/src/frontend/src/apis/transformers/work-packages.transformers.ts @@ -7,6 +7,7 @@ import { dbDateToLocalDate, RetrospectiveWorkPackage, WorkPackage, WorkPackagePr import { implementedChangeTransformer } from './change-requests.transformers'; import { descriptionBulletTransformer } from './projects.transformers'; import { eventPreviewTransformer } from './calendar.transformer'; +import { taskTransformer } from './tasks.transformers'; /** * Transforms a work package to ensure deep field transformation of date objects. @@ -22,7 +23,8 @@ export const workPackageTransformer = (workPackage: WorkPackage): WorkPackage => endDate: dbDateToLocalDate(new Date(workPackage.endDate)), descriptionBullets: workPackage.descriptionBullets.map(descriptionBulletTransformer), changes: workPackage.changes.map(implementedChangeTransformer), - events: workPackage.events.map(eventPreviewTransformer) + events: workPackage.events.map(eventPreviewTransformer), + tasks: workPackage.tasks.map(taskTransformer) }; }; diff --git a/src/shared/src/types/project-types.ts b/src/shared/src/types/project-types.ts index 3b43e1129c..12011ad220 100644 --- a/src/shared/src/types/project-types.ts +++ b/src/shared/src/types/project-types.ts @@ -106,6 +106,7 @@ export interface WorkPackage extends WbsElement { teamTypes: TeamType[]; projectId: string; events: EventPreview[]; + tasks: Task[]; } export interface WorkPackagePreview extends WbsElementPreview { 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; From 48f639963f86cba54e6493cf2085db4bab2ac31c Mon Sep 17 00:00:00 2001 From: getheobald Date: Mon, 23 Mar 2026 22:07:58 -0400 Subject: [PATCH 03/23] #4048 task card, column, and taskformmodal updates - still getting wbs element error but bedtime --- .../prisma-query-args/projects.query-args.ts | 2 +- .../src/transformers/tasks.transformer.ts | 2 +- .../TaskList/TaskFormModal.tsx | 57 ++++++++++++++++--- .../TaskList/v2/TaskCard.tsx | 17 +++++- .../TaskList/v2/TaskColumn.tsx | 13 ++++- 5 files changed, 79 insertions(+), 12 deletions(-) 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 34546b6834..e4be4436b4 100644 --- a/src/backend/src/prisma-query-args/projects.query-args.ts +++ b/src/backend/src/prisma-query-args/projects.query-args.ts @@ -130,7 +130,7 @@ export const getProjectOverviewQueryArgs = (organizationId: string) => tasks: getTaskQueryArgs(organizationId) } }, - workPackages: getWorkPackagePreviewQueryArgs(), + workPackages: getWorkPackagePreviewWithTasksQueryArgs(), projectId: true, budget: true, abbreviation: true, diff --git a/src/backend/src/transformers/tasks.transformer.ts b/src/backend/src/transformers/tasks.transformer.ts index b182646fba..aaeb416656 100644 --- a/src/backend/src/transformers/tasks.transformer.ts +++ b/src/backend/src/transformers/tasks.transformer.ts @@ -5,7 +5,7 @@ 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, diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx index e4e0eac0cf..e2c0f74988 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx @@ -17,7 +17,8 @@ const schema = yup.object().shape({ priority: yup.mixed().oneOf(Object.values(TaskPriority)).required(), assignees: yup.array().required(), title: yup.string().required(), - taskId: yup.string().required() + taskId: yup.string().required(), + wpWbsNum: yup.object().optional() }); export interface EditTaskFormInput { @@ -28,6 +29,7 @@ export interface EditTaskFormInput { startDate?: Date; deadline?: Date; priority: TaskPriority; + wpWbsNum?: WbsNumber; } interface TaskFormModalProps { @@ -37,9 +39,19 @@ interface TaskFormModalProps { onHide: () => void; onSubmit: (data: EditTaskFormInput) => Promise; onReset?: () => void; + workPackages?: WorkPackage[]; + lockedWorkPackage?: WorkPackage; } -const TaskFormModal: React.FC = ({ task, onSubmit, modalShow, onHide, onReset }) => { +const TaskFormModal: React.FC = ({ + task, + onSubmit, + modalShow, + onHide, + onReset, + workPackages, + lockedWorkPackage +}) => { const user = useCurrentUser(); const { data: users, isLoading, isError, error } = useAllUsers(); @@ -58,14 +70,19 @@ const TaskFormModal: React.FC = ({ task, onSubmit, modalShow startDate: task?.startDate ?? undefined, deadline: task?.deadline ?? undefined, priority: task?.priority ?? TaskPriority.Low, - assignees: task?.assignees.map((assignee) => assignee.userId) ?? [] + assignees: task?.assignees.map((assignee) => assignee.userId) ?? [], + wpWbsNum: lockedWorkPackage?.wbsNum ?? undefined } }); if (isError) return ; if (isLoading || !users) return ; - const options: { label: string; id: string }[] = users.map(taskUserToAutocompleteOption); + const userOptions: { label: string; id: string }[] = users.map(taskUserToAutocompleteOption); + const wpOptions: { label: string; wbsNum: WbsNumber }[] = (workPackages ?? []).map((wp) => ({ + label: wp.name, + wbsNum: wp.wbsNum + })); const unUpperCase = (str: string) => str.charAt(0) + str.slice(1).toLowerCase(); @@ -140,6 +157,32 @@ const TaskFormModal: React.FC = ({ task, onSubmit, modalShow /> + + + Work Package + ( + option.wbsNum.workPackageNumber === val.wbsNum.workPackageNumber} + getOptionLabel={(option) => option.label} + onChange={(_, val) => onChange(val?.wbsNum ?? undefined)} + value={wpOptions.find((o) => o.wbsNum.workPackageNumber === value?.workPackageNumber) ?? null} + renderInput={(params) => ( + + )} + /> + )} + /> + + Assignees @@ -152,12 +195,12 @@ const TaskFormModal: React.FC = ({ task, onSubmit, modalShow filterSelectedOptions multiple id="tags-standard" - options={options} + options={userOptions} getOptionLabel={(option) => option.label} onChange={(_, value) => onChange(value.map((v) => v.id))} - value={value.map((v) => options.find((o) => o.id === v)!)} + value={value.map((v) => userOptions.find((o) => o.id === v)!)} renderInput={(params) => ( - + )} /> )} diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx index 7ae82d28c4..9f1907e956 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx @@ -16,13 +16,15 @@ export const TaskCard = ({ index, project, onDeleteTask, - onEditTask + onEditTask, + showWpChip = true }: { task: Task; index: number; project: Project; onDeleteTask: (taskId: string) => void; onEditTask: (task: Task) => void; + showWpChip?: boolean; }) => { const { mutateAsync: deleteTask } = useDeleteTask(); const { mutateAsync: editTask } = useEditTask(); @@ -78,6 +80,7 @@ export const TaskCard = ({ const priorityColor = task.priority === 'HIGH' ? '#ef4345' : task.priority === 'LOW' ? '#00ab41' : '#FFA500'; const isOverdue = task.deadline != null && new Date(task.deadline) < new Date() && task.status !== 'DONE'; + const isWpTask = task.wbsNum.workPackageNumber !== 0; return ( <> @@ -141,6 +144,18 @@ export const TaskCard = ({ } size="medium" /> + {isWpTask && showWpChip && ( + + )} diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskColumn.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskColumn.tsx index 03730800c1..a5a1f60f86 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskColumn.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskColumn.tsx @@ -46,10 +46,18 @@ export const TaskColumn = ({ return () => observer.disconnect(); }, [status, onHeightChange]); - const handleCreateTask = async ({ notes, title, deadline, assignees, priority, startDate }: EditTaskFormInput) => { + const handleCreateTask = async ({ + notes, + title, + deadline, + assignees, + priority, + startDate, + wpWbsNum + }: EditTaskFormInput) => { try { const task = await createTask({ - wbsNum: project.wbsNum, + wbsNum: wpWbsNum ?? project.wbsNum, title, deadline: deadline ? toDateString(deadline) : undefined, startDate: startDate ? toDateString(startDate) : undefined, @@ -75,6 +83,7 @@ export const TaskColumn = ({ onHide={() => setShowCreateTaskModal(false)} modalShow={showCreateTaskModal} teams={project.teams} + workPackages={project.workPackages} /> Date: Wed, 25 Mar 2026 16:50:15 -0400 Subject: [PATCH 04/23] #4048 fixed create task wbs check, chip now showing, updated task modal but editing still broken --- .../prisma-query-args/projects.query-args.ts | 2 +- src/backend/src/services/tasks.services.ts | 16 +++++++++++--- .../TaskList/TaskModal.tsx | 22 ++++++++++++++++++- .../TaskList/v2/TaskCard.tsx | 2 ++ 4 files changed, 37 insertions(+), 5 deletions(-) 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 e4be4436b4..69971f6ef1 100644 --- a/src/backend/src/prisma-query-args/projects.query-args.ts +++ b/src/backend/src/prisma-query-args/projects.query-args.ts @@ -130,7 +130,7 @@ export const getProjectOverviewQueryArgs = (organizationId: string) => tasks: getTaskQueryArgs(organizationId) } }, - workPackages: getWorkPackagePreviewWithTasksQueryArgs(), + workPackages: getWorkPackagePreviewWithTasksQueryArgs(organizationId), projectId: true, budget: true, abbreviation: true, diff --git a/src/backend/src/services/tasks.services.ts b/src/backend/src/services/tasks.services.ts index 1f1b3b9bf5..025eb794bc 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!'); diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx index 25fd18f6b5..e4e7680bd9 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx @@ -18,11 +18,22 @@ interface TaskModalProps { onHide: () => void; onSubmit: (data: EditTaskFormInput) => Promise; hasEditPermissions: boolean; + workPackages?: WorkPackage[]; } -const TaskModal: React.FC = ({ task, teams, modalShow, onHide, onSubmit, hasEditPermissions }) => { +const TaskModal: React.FC = ({ + task, + teams, + modalShow, + onHide, + onSubmit, + hasEditPermissions, + workPackages +}) => { const [isEditMode, setIsEditMode] = useState(false); const priorityColor = task.priority === 'HIGH' ? '#ef4345' : task.priority === 'LOW' ? '#00ab41' : '#FFA500'; + const isWpTask = task.wbsNum.workPackageNumber !== 0; + const ViewModal: React.FC = () => { return ( = ({ task, teams, modalShow, onHide, o {task.assignees.map((user) => fullNamePipe(user)).join(', ')} + {isWpTask && ( + + + Work Package: + {task.wbsName} + + + )} Notes: @@ -96,6 +115,7 @@ const TaskModal: React.FC = ({ task, teams, modalShow, onHide, o onReset={() => { setIsEditMode(false); }} + workPackages={workPackages} /> ) : ( diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx index 9f1907e956..ed0fbc98aa 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx @@ -91,6 +91,7 @@ export const TaskCard = ({ onHide={() => setShowModal(false)} onSubmit={handleEditTask} hasEditPermissions={notGuest(user.role)} + workPackages={project.workPackages} /> Date: Wed, 25 Mar 2026 19:14:21 -0400 Subject: [PATCH 05/23] #4048 revert overview and preview task include --- .../src/prisma-query-args/projects.query-args.ts | 10 +++------- .../prisma-query-args/work-packages.query-args.ts | 14 -------------- src/backend/src/prisma/seed.ts | 2 +- .../src/transformers/projects.transformer.ts | 4 ---- 4 files changed, 4 insertions(+), 26 deletions(-) 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 b2fd923aa3..7b40a69dd6 100644 --- a/src/backend/src/prisma-query-args/projects.query-args.ts +++ b/src/backend/src/prisma-query-args/projects.query-args.ts @@ -4,11 +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, - getWorkPackagePreviewWithTasksQueryArgs -} from './work-packages.query-args.js'; +import { getWorkPackageQueryArgs } from './work-packages.query-args.js'; export type ProjectQueryArgs = ReturnType; @@ -98,7 +94,7 @@ export const getProjectPreviewQueryArgs = (organizationId: string) => status: true } }, - workPackages: getWorkPackagePreviewWithTasksQueryArgs(organizationId), + workPackages: getWorkPackagePreviewQueryArgs(), projectId: true, budget: true, abbreviation: true, @@ -142,7 +138,7 @@ export const getProjectOverviewQueryArgs = (organizationId: string) => } } }, - workPackages: getWorkPackagePreviewWithTasksQueryArgs(organizationId), + workPackages: getWorkPackagePreviewQueryArgs(), projectId: true, budget: true, abbreviation: true, diff --git a/src/backend/src/prisma-query-args/work-packages.query-args.ts b/src/backend/src/prisma-query-args/work-packages.query-args.ts index a8fa05b678..78c0378d50 100644 --- a/src/backend/src/prisma-query-args/work-packages.query-args.ts +++ b/src/backend/src/prisma-query-args/work-packages.query-args.ts @@ -7,7 +7,6 @@ import { getTaskQueryArgs } from './tasks.query-args.js'; export type WorkPackageQueryArgs = ReturnType; export type WorkPackagePreviewQueryArgs = ReturnType; -export type WorkPackagePreviewWithTasksQueryArgs = ReturnType; export const getWorkPackageQueryArgs = (organizationId: string) => Prisma.validator()({ @@ -76,16 +75,3 @@ export const getWorkPackagePreviewQueryArgs = () => stage: true } }); - -export const getWorkPackagePreviewWithTasksQueryArgs = (organizationId: string) => - Prisma.validator()({ - select: { - ...getWorkPackagePreviewQueryArgs().select, - wbsElement: { - select: { - ...getWorkPackagePreviewQueryArgs().select.wbsElement.select, - tasks: { where: { dateDeleted: null }, ...getTaskQueryArgs(organizationId) } - } - } - } - }); diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 8161ea418e..4b7b4c9345 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -1677,7 +1677,7 @@ const performSeed: () => Promise = async () => { "of the wheel and put pedal to the metal. Accelerating down straightaways and taking corners with finesse, it's " + 'easy to forget McCauley, in his blue racing jacket and jet black helmet, is racing laps around the roof of ' + "Columbus Parking Garage on Northeastern's Boston campus. But that's the reality of Northeastern Electric " + - 'Racing, a student club that has made due and found massive success in the world of electric racing despite its ' + + 'Racing, a student club that has made do and found massive success in the world of electric racing despite its ' + "relative rookie status. McCauley, NER's chief electrical engineer, has seen the club's car, Cinnamon, go from " + 'a 5-foot drive test to hitting 60 miles per hour in competitions. "It\'s a go-kart that has 110 kilowatts of ' + 'power, 109 kilowatts of power," says McCauley, a fourth-year electrical and computer engineering student. ' + diff --git a/src/backend/src/transformers/projects.transformer.ts b/src/backend/src/transformers/projects.transformer.ts index bb37324706..9a32665a29 100644 --- a/src/backend/src/transformers/projects.transformer.ts +++ b/src/backend/src/transformers/projects.transformer.ts @@ -160,10 +160,6 @@ export const projectPreviewTransformer = (project: Prisma.ProjectGetPayload): ProjectOverview => { return { ...projectPreviewTransformer(project), - tasks: [ - ...project.wbsElement.tasks.map(taskTransformer), // this project's tasks - ...project.workPackages.flatMap((wp) => wp.wbsElement.tasks.map(taskTransformer)) // all of this project's work packages' tasks, flattened to one array - ], tasksRemaining: project.wbsElement._count.tasks, links: project.wbsElement.links }; From 35ea413eee56617eef39eb9fb7f25a4010e399dd Mon Sep 17 00:00:00 2001 From: getheobald Date: Wed, 25 Mar 2026 22:34:56 -0400 Subject: [PATCH 06/23] #4048 added editTaskWbsElement across stack to handle edits and now it works! --- .../src/controllers/tasks.controllers.ts | 13 ++++ .../prisma-query-args/projects.query-args.ts | 2 +- src/backend/src/routes/tasks.routes.ts | 12 +++- src/backend/src/services/tasks.services.ts | 61 ++++++++++++++++--- src/backend/src/utils/validation.utils.ts | 1 + src/frontend/src/apis/tasks.api.ts | 18 ++++++ src/frontend/src/hooks/tasks.hooks.ts | 24 +++++++- .../TaskList/TaskFormModal.tsx | 2 +- .../TaskList/v2/TaskCard.tsx | 29 ++++++++- src/frontend/src/utils/urls.ts | 2 + 10 files changed, 149 insertions(+), 15 deletions(-) diff --git a/src/backend/src/controllers/tasks.controllers.ts b/src/backend/src/controllers/tasks.controllers.ts index 5ba914ff20..f0ed554119 100644 --- a/src/backend/src/controllers/tasks.controllers.ts +++ b/src/backend/src/controllers/tasks.controllers.ts @@ -80,6 +80,19 @@ export default class TasksController { } } + static async editTaskWbsElement(req: Request, res: Response, next: NextFunction) { + try { + const { wbsElementId } = req.body; + const { taskId } = req.params as Record; + + const updatedTask = await TasksService.editTaskWbsElement(req.currentUser, taskId, wbsElementId, req.organization); + + res.status(200).json(updatedTask); + } catch (error: unknown) { + next(error); + } + } + static async deleteTask(req: Request, res: Response, next: NextFunction) { try { const { taskId } = req.params as Record; 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 7b40a69dd6..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 { 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/routes/tasks.routes.ts b/src/backend/src/routes/tasks.routes.ts index c6f6819a06..1075b9b04b 100644 --- a/src/backend/src/routes/tasks.routes.ts +++ b/src/backend/src/routes/tasks.routes.ts @@ -48,16 +48,24 @@ tasksRouter.post( TasksController.editTask ); -tasksRouter.post('/:taskId/edit-status', isTaskStatus(body('status')), TasksController.editTaskStatus); +tasksRouter.post('/:taskId/edit-status', isTaskStatus(body('status')), validateInputs, TasksController.editTaskStatus); tasksRouter.post( '/:taskId/edit-assignees', body('assignees').isArray(), nonEmptyString(body('assignees.*')), + validateInputs, TasksController.editTaskAssignees ); -tasksRouter.post('/:taskId/delete', TasksController.deleteTask); +tasksRouter.post( + '/:taskId/edit-wbs-element', + nonEmptyString(body('wbsElementId')), + validateInputs, + TasksController.editTaskWbsElement +); + +tasksRouter.post('/:taskId/delete', validateInputs, TasksController.deleteTask); tasksRouter.get('/overdue-by-team-member/:userId', TasksController.getOverdueTasksByTeamLeadership); diff --git a/src/backend/src/services/tasks.services.ts b/src/backend/src/services/tasks.services.ts index 025eb794bc..8ffdc802da 100644 --- a/src/backend/src/services/tasks.services.ts +++ b/src/backend/src/services/tasks.services.ts @@ -183,13 +183,16 @@ export default class TasksService { /** * Edits the status of a task in the database * @param user the user editing the task - * @param organizationId the organizqtion Id + * @param organizationId the organization Id * @param taskId the id of the task * @param status the new status * @returns the updated task * @throws if the task does not exist, the task is already deleted, or if the user does not have permissions */ static async editTaskStatus(user: User, organizationId: string, taskId: string, status: Task_Status) { + const hasPermission = await userHasPermission(user.userId, organizationId, notGuest); + if (!hasPermission) throw new AccessDeniedException('Guests cannot edit tasks'); + // Get the original task and check if it exists const originalTask = await prisma.task.findUnique({ where: { taskId }, include: { assignees: true, wbsElement: true } }); if (!originalTask) throw new NotFoundException('Task', taskId); @@ -200,9 +203,6 @@ export default class TasksService { throw new HttpException(400, 'A task in progress must have a deadline and assignees!'); } - const hasPermission = await userHasPermission(user.userId, organizationId, notGuest); - if (!hasPermission) throw new AccessDeniedException('Guests cannot edit tasks'); - const updatedTask = await prisma.task.update({ where: { taskId }, data: { status }, @@ -226,6 +226,9 @@ export default class TasksService { assignees: string[], organization: Organization ): Promise { + const hasPermission = await userHasPermission(user.userId, organization.organizationId, notGuest); + if (!hasPermission) throw new AccessDeniedException('Guests cannot edit tasks'); + // Get the original task and check if it exists const originalTask = await prisma.task.findUnique({ where: { taskId }, @@ -240,9 +243,6 @@ export default class TasksService { const originalAssigneeIds = originalTask.assignees.map((assignee) => assignee.userId); const newAssigneeIds = assignees.filter((userId) => !originalAssigneeIds.includes(userId)); - const hasPermission = await userHasPermission(user.userId, organization.organizationId, notGuest); - if (!hasPermission) throw new AccessDeniedException('Guests cannot edit tasks'); - // this throws if any of the users aren't found const assigneeUsers = await getUsers(assignees); @@ -271,6 +271,53 @@ export default class TasksService { return updatedTask; } + /** + * Edits the wbs element of a task in the database + * @param user the user editing the task + * @param taskId the id of the task + * @param wbsElementId the id of the new wbs element + * @param organization the organization that the user is currently in + * @returns the updated task + * @throws if the task does not exist, the task is already deleted, the wbs element doesn't exist, or if the user does not have permissions + */ + static async editTaskWbsElement( + user: User, + taskId: string, + wbsElementId: string, + organization: Organization + ): Promise { + const hasPermission = await userHasPermission(user.userId, organization.organizationId, notGuest); + if (!hasPermission) throw new AccessDeniedException('Guests cannot edit tasks'); + + // get the original task and check if it exists + const originalTask = await prisma.task.findUnique({ + where: { taskId }, + include: { wbsElement: true } + }); + + // throw error if there are issues with the task + if (!originalTask) throw new NotFoundException('Task', taskId); + if (originalTask.dateDeleted) throw new DeletedException('Task', taskId); + if (originalTask.wbsElement.organizationId !== organization.organizationId) + throw new InvalidOrganizationException('Task'); + + const newWbsElement = await prisma.wBS_Element.findUnique({ + where: { wbsElementId } + }); + + // throw error if there are issues with the wbs element + if (!newWbsElement) throw new NotFoundException('WBS Element', wbsElementId); + if (newWbsElement.dateDeleted) throw new DeletedException('WBS Element', wbsElementId); + + const updatedTask = await prisma.task.update({ + where: { taskId }, + data: { wbsElement: { connect: { wbsElementId } } }, + ...getTaskQueryArgs(organization.organizationId) + }); + + return taskTransformer(updatedTask); + } + /** * Delete task in the database * @param taskId the id number of the given task diff --git a/src/backend/src/utils/validation.utils.ts b/src/backend/src/utils/validation.utils.ts index 153b32a0bf..92c1e523b7 100644 --- a/src/backend/src/utils/validation.utils.ts +++ b/src/backend/src/utils/validation.utils.ts @@ -288,6 +288,7 @@ export const materialValidators = [ body('linkUrl').optional().isString(), body('notes').isString().optional() ]; + export const validateInputs = (req: Request, res: Response, next: Function): void => { const errors = validationResult(req); if (!errors.isEmpty()) { diff --git a/src/frontend/src/apis/tasks.api.ts b/src/frontend/src/apis/tasks.api.ts index 4fd2c05270..65e8a8a5f9 100644 --- a/src/frontend/src/apis/tasks.api.ts +++ b/src/frontend/src/apis/tasks.api.ts @@ -102,6 +102,24 @@ export const editTaskAssignees = (taskId: string, assignees: string[]) => { ); }; +/** + * Sets the task's wbs element. + * @param taskId the id of the task + * @param wbsElementId the id of the new wbs element + * @returns the edited task + */ +export const editTaskWbsElement = (taskId: string, wbsElementId: string) => { + return axios.post( + apiUrls.editTaskWbsElement(taskId), + { + wbsElementId + }, + { + transformResponse: (data) => taskTransformer(JSON.parse(data)) + } + ); +}; + /** * Sets the task's status. * @param id the id of the task diff --git a/src/frontend/src/hooks/tasks.hooks.ts b/src/frontend/src/hooks/tasks.hooks.ts index 9b752dd6f5..846c9a27a4 100644 --- a/src/frontend/src/hooks/tasks.hooks.ts +++ b/src/frontend/src/hooks/tasks.hooks.ts @@ -11,6 +11,7 @@ import { editSingleTaskStatus, editTask, editTaskAssignees, + editTaskWbsElement, getOverdueTasksByTeamLeader, getFilterTasks } from '../apis/tasks.api'; @@ -81,7 +82,7 @@ export interface TaskPayload { /** * Custom React Hook for editing a task - * @returns the edit task mutation' + * @returns the edit task mutation */ export const useEditTask = () => { const queryClient = useQueryClient(); @@ -129,6 +130,27 @@ export const useEditTaskAssignees = () => { ); }; +/** + * custom react hook for editing a task's wbs element + * @returns the edit task wbs element mutation + */ +export const useEditTaskWbsElement = () => { + const queryClient = useQueryClient(); + return useMutation( + ['tasks', 'edit-wbs-element'], + async (editWbsElementTaskPayload: { taskId: string; wbsElementId: string }) => { + const { data } = await editTaskWbsElement(editWbsElementTaskPayload.taskId, editWbsElementTaskPayload.wbsElementId); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['projects']); + queryClient.invalidateQueries(['filter-tasks']); + } + } + ); +}; + /** * custom react hook for editing task status * @returns the edit task status mutation diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx index e2c0f74988..7f88f54010 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx @@ -71,7 +71,7 @@ const TaskFormModal: React.FC = ({ deadline: task?.deadline ?? undefined, priority: task?.priority ?? TaskPriority.Low, assignees: task?.assignees.map((assignee) => assignee.userId) ?? [], - wpWbsNum: lockedWorkPackage?.wbsNum ?? undefined + wpWbsNum: task?.wbsNum.workPackageNumber !== 0 ? task?.wbsNum : undefined } }); 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 ed0fbc98aa..1fa722ae72 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx @@ -3,7 +3,7 @@ import { Construction, Delete, Schedule } from '@mui/icons-material'; import { Box, Card, CardContent, Chip, Grid, Typography, IconButton } from '@mui/material'; import { useState } from 'react'; import { notGuest, Project, Task } from 'shared'; -import { useDeleteTask, useEditTask, useEditTaskAssignees } from '../../../../../hooks/tasks.hooks'; +import { useDeleteTask, useEditTask, useEditTaskAssignees, useEditTaskWbsElement } from '../../../../../hooks/tasks.hooks'; import { useToast } from '../../../../../hooks/toasts.hooks'; import { useCurrentUser } from '../../../../../hooks/users.hooks'; import { datePipe, fullNamePipe } from '../../../../../utils/pipes'; @@ -29,6 +29,7 @@ export const TaskCard = ({ const { mutateAsync: deleteTask } = useDeleteTask(); const { mutateAsync: editTask } = useEditTask(); const { mutateAsync: editTaskAssignees } = useEditTaskAssignees(); + const { mutateAsync: editTaskWbsElement } = useEditTaskWbsElement(); const user = useCurrentUser(); @@ -54,7 +55,16 @@ export const TaskCard = ({ setShowDeleteConfirm(false); }; - const handleEditTask = async ({ taskId, notes, title, deadline, assignees, priority, startDate }: EditTaskFormInput) => { + const handleEditTask = async ({ + taskId, + notes, + title, + deadline, + assignees, + priority, + startDate, + wpWbsNum + }: EditTaskFormInput) => { try { await editTask({ taskId, @@ -64,10 +74,23 @@ export const TaskCard = ({ startDate, priority }); - const newTask = await editTaskAssignees({ + + let newTask = await editTaskAssignees({ taskId, assignees }); + + // check if wp changed first to avoid unnecessary api call + const wpChanged = (wpWbsNum?.workPackageNumber ?? 0) !== task.wbsNum.workPackageNumber; + if (wpChanged) { + const targetWbsElementId = wpWbsNum + ? project.workPackages.find((wp) => wp.wbsNum.workPackageNumber === wpWbsNum.workPackageNumber)?.wbsElementId + : project.wbsElementId; + if (targetWbsElementId) { + newTask = await editTaskWbsElement({ taskId, wbsElementId: targetWbsElementId }); + } + } + onEditTask(newTask); toast.success('Task edited successfully!'); } catch (error: unknown) { diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 07493fd150..6344796cd0 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -94,6 +94,7 @@ const tasksCreate = (wbsNum: string) => `${tasks()}/${wbsNum}`; const taskEditStatus = (taskId: string) => `${tasks()}/${taskId}/edit-status`; const editTaskById = (taskId: string) => `${tasks()}/${taskId}/edit`; const editTaskAssignees = (taskId: string) => `${tasks()}/${taskId}/edit-assignees`; +const editTaskWbsElement = (taskId: string) => `${tasks()}/${taskId}/edit-wbs-element`; const deleteTask = (taskId: string) => `${tasks()}/${taskId}/delete`; const tasksFilter = () => `${tasks()}/filter`; const overdueTasksByTeamLeadership = (userId: string) => `${tasks()}/overdue-by-team-member/${userId}`; @@ -591,6 +592,7 @@ export const apiUrls = { editTaskById, taskEditStatus, editTaskAssignees, + editTaskWbsElement, deleteTask, overdueTasksByTeamLeadership, From 99f3d44964be5763f2340cd71f32911a4c7dd841 Mon Sep 17 00:00:00 2001 From: getheobald Date: Thu, 26 Mar 2026 11:49:56 -0400 Subject: [PATCH 07/23] #4048 wp tasks page with one-line filter, pre-refactor --- .../TaskList/TaskFormModal.tsx | 4 ++-- .../ProjectViewContainer/TaskList/TaskModal.tsx | 2 +- .../WorkPackageViewContainer.tsx | 17 ++++++++++++++++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx index 7f88f54010..e2c2cc7324 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx @@ -2,7 +2,7 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { Autocomplete, FormControl, FormHelperText, FormLabel, Grid, MenuItem, TextField } from '@mui/material'; import { DatePicker } from '@mui/x-date-pickers'; import { Controller, useForm } from 'react-hook-form'; -import { countWords, isGuest, isUnderWordCount, Task, TaskPriority, TeamPreview } from 'shared'; +import { countWords, isGuest, isUnderWordCount, Task, TaskPriority, TeamPreview, WorkPackage, WbsNumber } from 'shared'; import { useAllUsers, useCurrentUser } from '../../../../hooks/users.hooks'; import * as yup from 'yup'; import { taskUserToAutocompleteOption } from '../../../../utils/task.utils'; @@ -18,7 +18,7 @@ const schema = yup.object().shape({ assignees: yup.array().required(), title: yup.string().required(), taskId: yup.string().required(), - wpWbsNum: yup.object().optional() + wpWbsNum: yup.mixed().optional() }); export interface EditTaskFormInput { diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx index e4e7680bd9..c488d42707 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx @@ -3,7 +3,7 @@ * See the LICENSE file in the repository root folder for details. */ -import { TeamPreview } from 'shared'; +import { TeamPreview, WorkPackage } from 'shared'; import { fullNamePipe, datePipe } from '../../../../utils/pipes'; import { Task } from 'shared'; import { Box, Grid, Typography } from '@mui/material'; diff --git a/src/frontend/src/pages/WorkPackageDetailPage/WorkPackageViewContainer/WorkPackageViewContainer.tsx b/src/frontend/src/pages/WorkPackageDetailPage/WorkPackageViewContainer/WorkPackageViewContainer.tsx index 0bc079f640..181a139d8d 100644 --- a/src/frontend/src/pages/WorkPackageDetailPage/WorkPackageViewContainer/WorkPackageViewContainer.tsx +++ b/src/frontend/src/pages/WorkPackageDetailPage/WorkPackageViewContainer/WorkPackageViewContainer.tsx @@ -26,6 +26,8 @@ import ScopeTab from './ScopeTab'; import FullPageTabs from '../../../components/FullPageTabs'; import ChangeRequestTab from '../../../components/ChangeRequestTab'; import ActionsMenu, { ButtonInfo } from '../../../components/ActionsMenu'; +import { TaskList } from '../../ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskList'; +import { useSingleProject } from '../../../hooks/projects.hooks'; interface WorkPackageViewContainerProps { workPackage: WorkPackage; @@ -52,6 +54,8 @@ const WorkPackageViewContainer: React.FC = ({ const [, setAnchorEl] = useState(null); const { data: dependencies, isError, isLoading, error } = useGetManyWorkPackages(workPackage.blockedBy); const wbsNum = wbsPipe(workPackage.wbsNum); + const projectWbsNum = { ...workPackage.wbsNum, workPackageNumber: 0 }; + const { data: project } = useSingleProject(projectWbsNum); const [tabValue, setTabValue] = useState(0); @@ -143,6 +147,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 +161,18 @@ const WorkPackageViewContainer: React.FC = ({ {tabValue === 0 ? ( ) : tabValue === 1 ? ( - + project && ( + t.wbsNum.workPackageNumber === workPackage.wbsNum.workPackageNumber) + }} + isGuest={!allowEdit} + /> + ) ) : tabValue === 2 ? ( + + ) : tabValue === 3 ? ( ) : ( From b8e54afa00346a8412d8771218f4bd5f3fb7180c Mon Sep 17 00:00:00 2001 From: getheobald Date: Thu, 26 Mar 2026 12:30:03 -0400 Subject: [PATCH 08/23] #4048 refactor task frontend to accept piecemeal props instead of project --- .../TaskList/v2/TaskCard.tsx | 16 +++++++++------- .../TaskList/v2/TaskColumn.tsx | 19 ++++++++++++------- .../TaskList/v2/TaskList.tsx | 11 ++++++++++- .../TaskList/v2/TaskListContent.tsx | 16 ++++++++++------ 4 files changed, 41 insertions(+), 21 deletions(-) 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 1fa722ae72..dc96e2ba93 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx @@ -2,7 +2,7 @@ import { Draggable } from '@hello-pangea/dnd'; import { Construction, Delete, Schedule } from '@mui/icons-material'; import { Box, Card, CardContent, Chip, Grid, Typography, IconButton } from '@mui/material'; import { useState } from 'react'; -import { notGuest, Project, Task } from 'shared'; +import { notGuest, Task, WorkPackage } from 'shared'; import { useDeleteTask, useEditTask, useEditTaskAssignees, useEditTaskWbsElement } from '../../../../../hooks/tasks.hooks'; import { useToast } from '../../../../../hooks/toasts.hooks'; import { useCurrentUser } from '../../../../../hooks/users.hooks'; @@ -14,14 +14,16 @@ import NERModal from '../../../../../components/NERModal'; export const TaskCard = ({ task, index, - project, + wbsElementId, + workPackages, onDeleteTask, onEditTask, showWpChip = true }: { task: Task; index: number; - project: Project; + wbsElementId: string; + workPackages?: WorkPackage[]; onDeleteTask: (taskId: string) => void; onEditTask: (task: Task) => void; showWpChip?: boolean; @@ -84,8 +86,8 @@ export const TaskCard = ({ const wpChanged = (wpWbsNum?.workPackageNumber ?? 0) !== task.wbsNum.workPackageNumber; if (wpChanged) { const targetWbsElementId = wpWbsNum - ? project.workPackages.find((wp) => wp.wbsNum.workPackageNumber === wpWbsNum.workPackageNumber)?.wbsElementId - : project.wbsElementId; + ? workPackages?.find((wp) => wp.wbsNum.workPackageNumber === wpWbsNum.workPackageNumber)?.wbsElementId + : wbsElementId; if (targetWbsElementId) { newTask = await editTaskWbsElement({ taskId, wbsElementId: targetWbsElementId }); } @@ -110,11 +112,11 @@ export const TaskCard = ({ setShowModal(false)} onSubmit={handleEditTask} hasEditPermissions={notGuest(user.role)} - workPackages={project.workPackages} + workPackages={workPackages} /> void; @@ -57,7 +61,7 @@ export const TaskColumn = ({ }: EditTaskFormInput) => { try { const task = await createTask({ - wbsNum: wpWbsNum ?? project.wbsNum, + wbsNum: wpWbsNum ?? wbsNum, title, deadline: deadline ? toDateString(deadline) : undefined, startDate: startDate ? toDateString(startDate) : undefined, @@ -82,8 +86,8 @@ export const TaskColumn = ({ onSubmit={handleCreateTask} onHide={() => setShowCreateTaskModal(false)} modalShow={showCreateTaskModal} - teams={project.teams} - workPackages={project.workPackages} + teams={[]} + workPackages={workPackages} /> ))} {droppableProvided.placeholder} diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskList.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskList.tsx index 5f440961de..6562dd3870 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,14 @@ 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..8364df734f 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskListContent.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskListContent.tsx @@ -1,19 +1,21 @@ 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 { Task, TaskStatus, TaskWithIndex, WbsNumber, WorkPackage } from 'shared'; import { getTasksByStatus, statuses, TasksByStatus } from '.'; import { useSetTaskStatus } from '../../../../../hooks/tasks.hooks'; import { useToast } from '../../../../../hooks/toasts.hooks'; import { TaskColumn } from './TaskColumn'; import confetti from 'canvas-confetti'; -interface TaskListProps { - project: Project; +interface TaskListContentProps { + tasks: Task[]; + wbsNum: WbsNumber; + wbsElementId: string; + workPackages?: WorkPackage[]; } -export const TaskListContent = ({ project }: TaskListProps) => { - const { tasks } = project; +export const TaskListContent = ({ tasks, wbsNum, wbsElementId, workPackages }: TaskListContentProps) => { const [tasksByStatus, setTasksByStatus] = useState(getTasksByStatus(tasks)); const { mutateAsync: setTaskStatus } = useSetTaskStatus(); @@ -134,7 +136,9 @@ export const TaskListContent = ({ project }: TaskListProps) => { status={status} tasks={tasksByStatus[status]} key={status} - project={project} + wbsNum={wbsNum} + wbsElementId={wbsElementId} + workPackages={workPackages} equalizedHeight={equalizedHeight} isDragging={isDragging} /> From 524b3a0561c333b19c6a3252693b8cbe549271b5 Mon Sep 17 00:00:00 2001 From: getheobald Date: Thu, 26 Mar 2026 13:20:22 -0400 Subject: [PATCH 09/23] #4048 revert filter change and call TLC directly from WPVC --- .../WorkPackageViewContainer.tsx | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/frontend/src/pages/WorkPackageDetailPage/WorkPackageViewContainer/WorkPackageViewContainer.tsx b/src/frontend/src/pages/WorkPackageDetailPage/WorkPackageViewContainer/WorkPackageViewContainer.tsx index 181a139d8d..49f52c3747 100644 --- a/src/frontend/src/pages/WorkPackageDetailPage/WorkPackageViewContainer/WorkPackageViewContainer.tsx +++ b/src/frontend/src/pages/WorkPackageDetailPage/WorkPackageViewContainer/WorkPackageViewContainer.tsx @@ -26,8 +26,7 @@ import ScopeTab from './ScopeTab'; import FullPageTabs from '../../../components/FullPageTabs'; import ChangeRequestTab from '../../../components/ChangeRequestTab'; import ActionsMenu, { ButtonInfo } from '../../../components/ActionsMenu'; -import { TaskList } from '../../ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskList'; -import { useSingleProject } from '../../../hooks/projects.hooks'; +import { TaskListContent } from '../../ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskListContent'; interface WorkPackageViewContainerProps { workPackage: WorkPackage; @@ -54,9 +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 projectWbsNum = { ...workPackage.wbsNum, workPackageNumber: 0 }; - const { data: project } = useSingleProject(projectWbsNum); - const [tabValue, setTabValue] = useState(0); if (!dependencies || isLoading) return ; @@ -161,14 +157,8 @@ const WorkPackageViewContainer: React.FC = ({ {tabValue === 0 ? ( ) : tabValue === 1 ? ( - project && ( - t.wbsNum.workPackageNumber === workPackage.wbsNum.workPackageNumber) - }} - isGuest={!allowEdit} - /> + !allowEdit ? null : ( + ) ) : tabValue === 2 ? ( From 5047191ec110ba5779acf40e18eb1ca05c13c7bc Mon Sep 17 00:00:00 2001 From: getheobald Date: Thu, 26 Mar 2026 13:55:26 -0400 Subject: [PATCH 10/23] #4048 conditionally render wp dropdown, truncate chip, omit chip on wp tasks tab --- .../TaskList/TaskFormModal.tsx | 57 ++++++++++--------- .../TaskList/v2/TaskCard.tsx | 5 +- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx index e2c2cc7324..edb10b8c5b 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx @@ -157,32 +157,37 @@ const TaskFormModal: React.FC = ({ /> - - - 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) => ( - - )} - /> - )} - /> - - + {/* wrap with WP check so dropdown doesn't render if within WP tasks tab*/} + {workPackages && ( + + + Work Package + ( + + option.wbsNum.workPackageNumber === val.wbsNum.workPackageNumber + } + getOptionLabel={(option) => option.label} + onChange={(_, val) => onChange(val?.wbsNum ?? undefined)} + value={wpOptions.find((o) => o.wbsNum.workPackageNumber === value?.workPackageNumber) ?? null} + renderInput={(params) => ( + + )} + /> + )} + /> + + + )} Assignees 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 dc96e2ba93..491f902124 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx @@ -170,7 +170,7 @@ export const TaskCard = ({ } size="medium" /> - {isWpTask && showWpChip && ( + {isWpTask && workPackages && ( )} From 5cc45121977bc8513d2e82b791ecf6d84deaeae8 Mon Sep 17 00:00:00 2001 From: getheobald Date: Thu, 26 Mar 2026 13:59:27 -0400 Subject: [PATCH 11/23] #4048 remove dead teams code from task frontend --- .../ProjectViewContainer/TaskList/TaskFormModal.tsx | 1 - .../ProjectViewContainer/TaskList/TaskModal.tsx | 12 +----------- .../ProjectViewContainer/TaskList/v2/TaskCard.tsx | 5 +---- .../ProjectViewContainer/TaskList/v2/TaskColumn.tsx | 1 - 4 files changed, 2 insertions(+), 17 deletions(-) diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx index edb10b8c5b..968677e131 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx @@ -34,7 +34,6 @@ export interface EditTaskFormInput { interface TaskFormModalProps { task?: Task; - teams: TeamPreview[]; modalShow: boolean; onHide: () => void; onSubmit: (data: EditTaskFormInput) => Promise; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx index c488d42707..ed4b059532 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx @@ -13,7 +13,6 @@ import NERModal from '../../../../components/NERModal'; interface TaskModalProps { task: Task; - teams: TeamPreview[]; modalShow: boolean; onHide: () => void; onSubmit: (data: EditTaskFormInput) => Promise; @@ -21,15 +20,7 @@ interface TaskModalProps { workPackages?: WorkPackage[]; } -const TaskModal: React.FC = ({ - task, - teams, - modalShow, - onHide, - onSubmit, - hasEditPermissions, - workPackages -}) => { +const TaskModal: React.FC = ({ task, modalShow, onHide, onSubmit, hasEditPermissions, workPackages }) => { const [isEditMode, setIsEditMode] = useState(false); const priorityColor = task.priority === 'HIGH' ? '#ef4345' : task.priority === 'LOW' ? '#00ab41' : '#FFA500'; const isWpTask = task.wbsNum.workPackageNumber !== 0; @@ -108,7 +99,6 @@ const TaskModal: React.FC = ({ return isEditMode ? ( void; onEditTask: (task: Task) => void; - showWpChip?: boolean; }) => { const { mutateAsync: deleteTask } = useDeleteTask(); const { mutateAsync: editTask } = useEditTask(); @@ -112,7 +110,6 @@ export const TaskCard = ({ setShowModal(false)} onSubmit={handleEditTask} hasEditPermissions={notGuest(user.role)} 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 716d32aa9e..93848cef05 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskColumn.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskColumn.tsx @@ -86,7 +86,6 @@ export const TaskColumn = ({ onSubmit={handleCreateTask} onHide={() => setShowCreateTaskModal(false)} modalShow={showCreateTaskModal} - teams={[]} workPackages={workPackages} /> Date: Thu, 26 Mar 2026 14:18:48 -0400 Subject: [PATCH 12/23] #4048 clickable chip --- .../TaskList/v2/TaskCard.tsx | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) 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 95a7e261f2..2c233769b6 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskCard.tsx @@ -10,6 +10,9 @@ import { datePipe, fullNamePipe } from '../../../../../utils/pipes'; import { EditTaskFormInput } from '../TaskFormModal'; import TaskModal from '../TaskModal'; import NERModal from '../../../../../components/NERModal'; +import { Link as RouterLink } from 'react-router-dom'; +import { routes } from '../../../../../utils/routes'; +import { wbsPipe } from '../../../../../utils/pipes'; export const TaskCard = ({ task, @@ -167,20 +170,24 @@ export const TaskCard = ({ } size="medium" /> - {isWpTask && workPackages && ( - - )} + {isWpTask && // render iff task does have associated wp + workPackages && ( // and if on project's task page, not wp's + + )} From a324ab9979399f0c241e931f727a754d1476202c Mon Sep 17 00:00:00 2001 From: getheobald Date: Thu, 26 Mar 2026 14:54:34 -0400 Subject: [PATCH 13/23] #4048 extraneous imports --- .../ProjectViewContainer/TaskList/TaskFormModal.tsx | 2 +- .../ProjectViewContainer/TaskList/TaskModal.tsx | 2 +- .../ProjectViewContainer/TaskList/v2/TaskColumn.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx index 968677e131..62e377d877 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx @@ -2,7 +2,7 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { Autocomplete, FormControl, FormHelperText, FormLabel, Grid, MenuItem, TextField } from '@mui/material'; import { DatePicker } from '@mui/x-date-pickers'; import { Controller, useForm } from 'react-hook-form'; -import { countWords, isGuest, isUnderWordCount, Task, TaskPriority, TeamPreview, WorkPackage, WbsNumber } from 'shared'; +import { countWords, isGuest, isUnderWordCount, Task, TaskPriority, WorkPackage, WbsNumber } from 'shared'; import { useAllUsers, useCurrentUser } from '../../../../hooks/users.hooks'; import * as yup from 'yup'; import { taskUserToAutocompleteOption } from '../../../../utils/task.utils'; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx index ed4b059532..b2397e265c 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskModal.tsx @@ -3,7 +3,7 @@ * See the LICENSE file in the repository root folder for details. */ -import { TeamPreview, WorkPackage } from 'shared'; +import { WorkPackage } from 'shared'; import { fullNamePipe, datePipe } from '../../../../utils/pipes'; import { Task } from 'shared'; import { Box, Grid, Typography } from '@mui/material'; 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 93848cef05..d934d9d23c 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, WbsNumber, WorkPackage } from 'shared'; +import { Task, TaskStatus, TaskWithIndex, WbsNumber, WorkPackage } from 'shared'; import { statusNames, TaskCard } from '.'; import { NERButton } from '../../../../../components/NERButton'; import { useCreateTask } from '../../../../../hooks/tasks.hooks'; From b4832e740dbd9982c3bbfe354b818e2275e86a60 Mon Sep 17 00:00:00 2001 From: getheobald Date: Thu, 26 Mar 2026 18:14:46 -0400 Subject: [PATCH 14/23] #4048 unit tests --- src/backend/tests/unmocked/task.test.ts | 219 +++++++++++++++++------- 1 file changed, 157 insertions(+), 62 deletions(-) diff --git a/src/backend/tests/unmocked/task.test.ts b/src/backend/tests/unmocked/task.test.ts index e84aac8be5..645f9569ee 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 } from '../../src/utils/errors.utils.js'; +import { + AccessDeniedException, + HttpException, + NotFoundException, + DeletedException, + InvalidOrganizationException +} from '../../src/utils/errors.utils.js'; import { createTestOrganization, createTestTask, createTestUser, resetUsers } from '../test-utils.js'; import prisma from '../../src/prisma/prisma.js'; import TasksService from '../../src/services/tasks.services.js'; @@ -14,73 +20,162 @@ describe('Task Test', () => { await resetUsers(); }); - test('Setting status to in progress works when task has deadline and assignees', async () => { - const user = await createTestUser(supermanAdmin, organizationId); - const correctTask = await createTestTask( - user, - 'Test', - '', - [user], - 'HIGH', - 'IN_BACKLOG', - organizationId, - new Date('01/23/2023') - ); - await TasksService.editTaskStatus(user, organizationId, correctTask.taskId, 'IN_PROGRESS'); - const updatedTask = await prisma.task.findUnique({ - where: { - taskId: correctTask.taskId - } + describe('Edit task status', () => { + test('Setting status to in progress works when task has deadline and assignees', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const correctTask = await createTestTask( + user, + 'Test', + '', + [user], + 'HIGH', + 'IN_BACKLOG', + organizationId, + new Date('01/23/2023') + ); + await TasksService.editTaskStatus(user, organizationId, correctTask.taskId, 'IN_PROGRESS'); + const updatedTask = await prisma.task.findUnique({ + where: { + taskId: correctTask.taskId + } + }); + // check that status changed to correct status + expect(updatedTask?.status).toBe('IN_PROGRESS'); }); - // check that status changed to correct status - expect(updatedTask?.status).toBe('IN_PROGRESS'); - }); - test('Setting status to in progress does not work when task does not have a deadline and assignees', async () => { - const user = await createTestUser(supermanAdmin, organizationId); - const badTask = await createTestTask(user, 'Test', '', [], 'HIGH', 'DONE', organizationId); - await expect(async () => - TasksService.editTaskStatus( - await createTestUser(financeMember, organizationId), - organizationId, - badTask.taskId, - 'IN_PROGRESS' - ) - ).rejects.toThrow(new HttpException(400, 'A task in progress must have a deadline and assignees!')); - }); + test('Setting status to in progress does not work when task does not have a deadline and assignees', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const badTask = await createTestTask(user, 'Test', '', [], 'HIGH', 'DONE', organizationId); + await expect(async () => + TasksService.editTaskStatus( + await createTestUser(financeMember, organizationId), + organizationId, + badTask.taskId, + 'IN_PROGRESS' + ) + ).rejects.toThrow(new HttpException(400, 'A task in progress must have a deadline and assignees!')); + }); - test('Setting status to in progress does not work when task does not have a deadline, but does have assignees', async () => { - const user = await createTestUser(supermanAdmin, organizationId); - const badTask = await createTestTask(user, 'Test', '', [user], 'HIGH', 'IN_BACKLOG', organizationId); - await expect(async () => - TasksService.editTaskStatus( - await createTestUser(financeMember, organizationId), - organizationId, - badTask.taskId, - 'IN_PROGRESS' - ) - ).rejects.toThrow(new HttpException(400, 'A task in progress must have a deadline and assignees!')); + test('Setting status to in progress does not work when task does not have a deadline, but does have assignees', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const badTask = await createTestTask(user, 'Test', '', [user], 'HIGH', 'IN_BACKLOG', organizationId); + await expect(async () => + TasksService.editTaskStatus( + await createTestUser(financeMember, organizationId), + organizationId, + badTask.taskId, + 'IN_PROGRESS' + ) + ).rejects.toThrow(new HttpException(400, 'A task in progress must have a deadline and assignees!')); + }); + + test('Setting status to in progress does not work when task does not have assignees, but does have a deadline', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const badTask = await createTestTask(user, 'Test', '', [], 'HIGH', 'DONE', organizationId, new Date()); + await expect(async () => + TasksService.editTaskStatus( + await createTestUser(financeMember, organizationId), + organizationId, + badTask.taskId, + 'IN_PROGRESS' + ) + ).rejects.toThrow(new HttpException(400, 'A task in progress must have a deadline and assignees!')); + }); }); - test('Setting status to in progress does not work when task does not have assignees, but does have a deadline', async () => { - const user = await createTestUser(supermanAdmin, organizationId); - const badTask = await createTestTask(user, 'Test', '', [], 'HIGH', 'DONE', organizationId, new Date()); - await expect(async () => - TasksService.editTaskStatus( - await createTestUser(financeMember, organizationId), - organizationId, - badTask.taskId, - 'IN_PROGRESS' - ) - ).rejects.toThrow(new HttpException(400, 'A task in progress must have a deadline and assignees!')); + describe('Edit task WBS element', () => { + test('Successfully updates the wbs element of a task', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const task = await createTestTask(user, 'Test Task', '', [], 'HIGH', 'IN_BACKLOG', organizationId); + + // create a second wbs element to move the task to + const newWbsElement = await prisma.wBS_Element.findUnique({ + where: { wbsElementId: task.wbsElementId } + }); + // reassign to the same wbs element to make sure it updates without error + const updatedTask = await TasksService.editTaskWbsElement(user, task.taskId, task.wbsElementId, { + organizationId + } as any); + + expect(updatedTask.taskId).toBe(task.taskId); + }); + + test('Throws NotFoundException when task does not exist', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const task = await createTestTask(user, 'Test Task', '', [], 'HIGH', 'IN_BACKLOG', organizationId); + + await expect(async () => + TasksService.editTaskWbsElement(user, 'non-existent-task-id', task.wbsElementId, { organizationId } as any) + ).rejects.toThrow(new NotFoundException('Task', 'non-existent-task-id')); + }); + + test('Throws DeletedException when task is deleted', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const task = await createTestTask(user, 'Test Task', '', [], 'HIGH', 'IN_BACKLOG', organizationId); + + await prisma.task.update({ + where: { taskId: task.taskId }, + data: { dateDeleted: new Date() } + }); + + await expect(async () => + TasksService.editTaskWbsElement(user, task.taskId, task.wbsElementId, { organizationId } as any) + ).rejects.toThrow(new DeletedException('Task', task.taskId)); + }); + + test('Throws NotFoundException when new wbs element does not exist', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const task = await createTestTask(user, 'Test Task', '', [], 'HIGH', 'IN_BACKLOG', organizationId); + + await expect(async () => + TasksService.editTaskWbsElement(user, task.taskId, 'non-existent-wbs-element-id', { organizationId } as any) + ).rejects.toThrow(new NotFoundException('WBS Element', 'non-existent-wbs-element-id')); + }); + + test('Throws DeletedException when new wbs element is deleted', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const task = await createTestTask(user, 'Test Task', '', [], 'HIGH', 'IN_BACKLOG', organizationId); + + // create second wbs element and delete it + const deletedWbsElement = await prisma.wBS_Element.create({ + data: { + name: 'Deleted WBS', + status: 'INACTIVE', + carNumber: 99, + projectNumber: 99, + workPackageNumber: 0, + dateCreated: new Date('01/01/2023'), + leadId: user.userId, + managerId: user.userId, + organizationId, + dateDeleted: new Date() + } + }); + + await expect(async () => + TasksService.editTaskWbsElement(user, task.taskId, deletedWbsElement.wbsElementId, { organizationId } as any) + ).rejects.toThrow(new DeletedException('WBS Element', deletedWbsElement.wbsElementId)); + }); }); - test('Guests cannot edit tasks', async () => { - const guest = await createTestUser(theVisitorGuest, organizationId); - const admin = await createTestUser(supermanAdmin, organizationId); - const task = await createTestTask(admin, 'Test', '', [], 'HIGH', 'DONE', organizationId, new Date()); - await expect(async () => - TasksService.editTask(guest, organizationId, task.taskId, 'Title', 'Notes', 'HIGH', new Date()) - ).rejects.toThrow(new AccessDeniedException('Guests cannot edit tasks')); + describe('Guest editing permissions', () => { + test('Guests cannot edit tasks', async () => { + const guest = await createTestUser(theVisitorGuest, organizationId); + const admin = await createTestUser(supermanAdmin, organizationId); + const task = await createTestTask(admin, 'Test', '', [], 'HIGH', 'DONE', organizationId, new Date()); + await expect(async () => + TasksService.editTask(guest, organizationId, task.taskId, 'Title', 'Notes', 'HIGH', new Date()) + ).rejects.toThrow(new AccessDeniedException('Guests cannot edit tasks')); + }); + + test('Guests cannot edit task wbs element', async () => { + const admin = await createTestUser(supermanAdmin, organizationId); + const guest = await createTestUser(theVisitorGuest, organizationId); + const task = await createTestTask(admin, 'Test Task', '', [], 'HIGH', 'IN_BACKLOG', organizationId); + + await expect(async () => + TasksService.editTaskWbsElement(guest, task.taskId, task.wbsElementId, { organizationId } as any) + ).rejects.toThrow(new AccessDeniedException('Guests cannot edit tasks')); + }); }); }); From 359e27dd62ec34b5582fb952a5634cecb65da614 Mon Sep 17 00:00:00 2001 From: getheobald Date: Thu, 26 Mar 2026 22:35:04 -0400 Subject: [PATCH 15/23] #4048 lint --- .../src/prisma-query-args/tasks.query-args.ts | 3 ++- src/backend/tests/unmocked/task.test.ts | 20 +++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) 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/tests/unmocked/task.test.ts b/src/backend/tests/unmocked/task.test.ts index 645f9569ee..15a0a13fde 100644 --- a/src/backend/tests/unmocked/task.test.ts +++ b/src/backend/tests/unmocked/task.test.ts @@ -4,7 +4,6 @@ import { HttpException, NotFoundException, DeletedException, - InvalidOrganizationException } from '../../src/utils/errors.utils.js'; import { createTestOrganization, createTestTask, createTestUser, resetUsers } from '../test-utils.js'; import prisma from '../../src/prisma/prisma.js'; @@ -88,12 +87,21 @@ describe('Task Test', () => { const user = await createTestUser(supermanAdmin, organizationId); const task = await createTestTask(user, 'Test Task', '', [], 'HIGH', 'IN_BACKLOG', organizationId); - // create a second wbs element to move the task to - const newWbsElement = await prisma.wBS_Element.findUnique({ - where: { wbsElementId: task.wbsElementId } + 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 + } }); - // reassign to the same wbs element to make sure it updates without error - const updatedTask = await TasksService.editTaskWbsElement(user, task.taskId, task.wbsElementId, { + + const updatedTask = await TasksService.editTaskWbsElement(user, task.taskId, newWbsElement.wbsElementId, { organizationId } as any); From 4f6f675f8350ce9c44c51199ba65b37b0f0ea798 Mon Sep 17 00:00:00 2001 From: getheobald Date: Thu, 26 Mar 2026 22:54:51 -0400 Subject: [PATCH 16/23] #4048 tsc check and prettier --- src/backend/tests/unmocked/task.test.ts | 7 +------ .../src/pages/CalendarPage/TaskClickPopup.tsx | 1 - .../ProjectGanttChart/ProjectGanttChartPage.tsx | 1 + src/frontend/src/tests/test-support/mock-hooks.ts | 2 ++ .../tests/test-support/test-data/tasks.stub.ts | 2 ++ .../test-support/test-data/work-packages.stub.ts | 15 ++++++++++----- 6 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/backend/tests/unmocked/task.test.ts b/src/backend/tests/unmocked/task.test.ts index 15a0a13fde..e11f6725ef 100644 --- a/src/backend/tests/unmocked/task.test.ts +++ b/src/backend/tests/unmocked/task.test.ts @@ -1,10 +1,5 @@ import { financeMember, supermanAdmin, theVisitorGuest } from '../test-data/users.test-data.js'; -import { - AccessDeniedException, - HttpException, - NotFoundException, - DeletedException, -} from '../../src/utils/errors.utils.js'; +import { AccessDeniedException, HttpException, NotFoundException, DeletedException } from '../../src/utils/errors.utils.js'; import { createTestOrganization, createTestTask, createTestUser, resetUsers } from '../test-utils.js'; import prisma from '../../src/prisma/prisma.js'; import TasksService from '../../src/services/tasks.services.js'; diff --git a/src/frontend/src/pages/CalendarPage/TaskClickPopup.tsx b/src/frontend/src/pages/CalendarPage/TaskClickPopup.tsx index 9733689929..d5db088240 100644 --- a/src/frontend/src/pages/CalendarPage/TaskClickPopup.tsx +++ b/src/frontend/src/pages/CalendarPage/TaskClickPopup.tsx @@ -235,7 +235,6 @@ export const TaskClickContent: React.FC = ({ task, onClos {showEditModal && ( setShowEditModal(false)} onSubmit={handleEditSubmit} diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx index e38991c484..35b422553c 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx @@ -301,6 +301,7 @@ const ProjectGanttChartPage: FC = () => { teamTypes: [], changes: [], events: [], + tasks: [], deleted: false }; diff --git a/src/frontend/src/tests/test-support/mock-hooks.ts b/src/frontend/src/tests/test-support/mock-hooks.ts index a2c6363e47..c851eda916 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, 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..229bf06ba9 100644 --- a/src/frontend/src/tests/test-support/test-data/tasks.stub.ts +++ b/src/frontend/src/tests/test-support/test-data/tasks.stub.ts @@ -10,6 +10,7 @@ import { exampleWbsProject1 } from './wbs-numbers.stub'; export const exampleTask1: Task = { taskId: 'i8f-rotwyv', wbsNum: exampleWbsProject1, + wbsName: 'WP' title: 'Sketches', notes: 'drafting the sketches with very straight lines', dateCreated: new Date('2023-03-04T00:00:00-05:00'), @@ -23,6 +24,7 @@ export const exampleTask1: Task = { export const exampleTask1DueSoon: Task = { taskId: 'i8f-rotwyv', wbsNum: exampleWbsProject1, + wbsName: 'WP' title: 'Sketches', notes: 'drafting the sketches with very straight lines', dateCreated: new Date('2023-03-04T00:00:00-05:00'), diff --git a/src/frontend/src/tests/test-support/test-data/work-packages.stub.ts b/src/frontend/src/tests/test-support/test-data/work-packages.stub.ts index 25a104a2be..3a1b1f3ab2 100644 --- a/src/frontend/src/tests/test-support/test-data/work-packages.stub.ts +++ b/src/frontend/src/tests/test-support/test-data/work-packages.stub.ts @@ -55,7 +55,8 @@ export const exampleResearchWorkPackage: WorkPackage = { stage: WorkPackageStage.Research, blocking: [], teamTypes: [], - events: [] + events: [], + tasks: [] }; export const exampleDesignWorkPackage: WorkPackage = { @@ -101,7 +102,8 @@ export const exampleDesignWorkPackage: WorkPackage = { stage: WorkPackageStage.Design, blocking: [], teamTypes: [], - events: [] + events: [], + tasks: [] }; export const exampleManufacturingWorkPackage: WorkPackage = { @@ -137,7 +139,8 @@ export const exampleManufacturingWorkPackage: WorkPackage = { stage: WorkPackageStage.Manufacturing, blocking: [], teamTypes: [], - events: [] + events: [], + tasks: [] }; export const exampleInstallWorkPackage: WorkPackage = { @@ -173,7 +176,8 @@ export const exampleInstallWorkPackage: WorkPackage = { blocking: [], teamTypes: [], events: [], - deleted: false + deleted: false, + tasks: [] }; export const exampleWorkPackage5: WorkPackage = { @@ -208,7 +212,8 @@ export const exampleWorkPackage5: WorkPackage = { projectName: 'project3', blocking: [], teamTypes: [], - events: [] + events: [], + tasks: [] }; export const exampleAllWorkPackages: WorkPackage[] = [ From 723e5f1fd97068b1b0771eea69a92619d02db07f Mon Sep 17 00:00:00 2001 From: getheobald Date: Thu, 26 Mar 2026 22:57:50 -0400 Subject: [PATCH 17/23] #4048 freaking commas --- src/frontend/src/tests/test-support/test-data/tasks.stub.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 229bf06ba9..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,7 +10,7 @@ import { exampleWbsProject1 } from './wbs-numbers.stub'; export const exampleTask1: Task = { taskId: 'i8f-rotwyv', wbsNum: exampleWbsProject1, - wbsName: 'WP' + wbsName: 'WP', title: 'Sketches', notes: 'drafting the sketches with very straight lines', dateCreated: new Date('2023-03-04T00:00:00-05:00'), @@ -24,7 +24,7 @@ export const exampleTask1: Task = { export const exampleTask1DueSoon: Task = { taskId: 'i8f-rotwyv', wbsNum: exampleWbsProject1, - wbsName: 'WP' + wbsName: 'WP', title: 'Sketches', notes: 'drafting the sketches with very straight lines', dateCreated: new Date('2023-03-04T00:00:00-05:00'), From f4232a01f27dda3f18bd6ed2815199cace85114d Mon Sep 17 00:00:00 2001 From: getheobald Date: Thu, 26 Mar 2026 23:08:09 -0400 Subject: [PATCH 18/23] #4048 omg tsc check everything needs a wbsName --- .../pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx | 1 + src/frontend/src/tests/test-support/mock-hooks.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx index 35b422553c..7f5b687b17 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx @@ -350,6 +350,7 @@ const ProjectGanttChartPage: FC = () => { const newTask: Task = { taskId, wbsNum: parentProject.wbsNum, + wbsName: parentProject.name, title: taskInfo.title, notes: taskInfo.notes, dateCreated: new Date(), diff --git a/src/frontend/src/tests/test-support/mock-hooks.ts b/src/frontend/src/tests/test-support/mock-hooks.ts index c851eda916..14d7c1a519 100644 --- a/src/frontend/src/tests/test-support/mock-hooks.ts +++ b/src/frontend/src/tests/test-support/mock-hooks.ts @@ -109,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, From 094ef6ba51faaacc206fadf61936215ad832373e Mon Sep 17 00:00:00 2001 From: getheobald Date: Fri, 27 Mar 2026 23:18:39 -0400 Subject: [PATCH 19/23] #4048 remove wp-task association in types, transformers, and query args --- .../src/prisma-query-args/work-packages.query-args.ts | 1 - src/backend/src/services/tasks.services.ts | 10 +--------- .../src/transformers/work-packages.transformer.ts | 2 -- .../apis/transformers/work-packages.transformers.ts | 4 +--- src/shared/src/types/project-types.ts | 1 - 5 files changed, 2 insertions(+), 16 deletions(-) diff --git a/src/backend/src/prisma-query-args/work-packages.query-args.ts b/src/backend/src/prisma-query-args/work-packages.query-args.ts index 78c0378d50..dceba4e6cb 100644 --- a/src/backend/src/prisma-query-args/work-packages.query-args.ts +++ b/src/backend/src/prisma-query-args/work-packages.query-args.ts @@ -32,7 +32,6 @@ export const getWorkPackageQueryArgs = (organizationId: string) => }, blocking: { where: { wbsElement: { dateDeleted: null } }, include: { wbsElement: true } }, descriptionBullets: { where: { dateDeleted: null }, ...getDescriptionBulletQueryArgs(organizationId) }, - tasks: { where: { dateDeleted: null }, ...getTaskQueryArgs(organizationId) } } }, blockedBy: { where: { dateDeleted: null } }, diff --git a/src/backend/src/services/tasks.services.ts b/src/backend/src/services/tasks.services.ts index 8ffdc802da..24beb2d508 100644 --- a/src/backend/src/services/tasks.services.ts +++ b/src/backend/src/services/tasks.services.ts @@ -74,15 +74,7 @@ export default class TasksService { workPackages: { include: { wbsElement: true } } } }, - workPackage: { - include: { - project: { - include: { - teams: getTeamQueryArgs(organization.organizationId) - } - } - } - } + workPackage: true } }); if (!requestedWbsElement) throw new NotFoundException('WBS Element', wbsPipe(wbsNum)); diff --git a/src/backend/src/transformers/work-packages.transformer.ts b/src/backend/src/transformers/work-packages.transformer.ts index f90407a723..e5b5c5d807 100644 --- a/src/backend/src/transformers/work-packages.transformer.ts +++ b/src/backend/src/transformers/work-packages.transformer.ts @@ -6,7 +6,6 @@ import { userTransformer } from './user.transformer.js'; import { WorkPackageQueryArgs, WorkPackagePreviewQueryArgs } from '../prisma-query-args/work-packages.query-args.js'; import { teamTypeTransformer } from './team-types.transformer.js'; import { eventPreviewTransformer } from './calendar.transformer.js'; -import { taskTransformer } from './tasks.transformer.js'; const workPackageTransformer = (wpInput: Prisma.Work_PackageGetPayload): WorkPackage => { const wbsNum = wbsNumOf(wpInput.wbsElement); @@ -43,7 +42,6 @@ const workPackageTransformer = (wpInput: Prisma.Work_PackageGetPayload eventPreviewTransformer(event, `${wpInput.project.wbsElement.name} - ${wpInput.wbsElement.name}`) ), - tasks: wpInput.wbsElement.tasks.map(taskTransformer), deleted: wpInput.wbsElement.dateDeleted !== null }; }; diff --git a/src/frontend/src/apis/transformers/work-packages.transformers.ts b/src/frontend/src/apis/transformers/work-packages.transformers.ts index 4a47a1a88c..6c2a9fc04d 100644 --- a/src/frontend/src/apis/transformers/work-packages.transformers.ts +++ b/src/frontend/src/apis/transformers/work-packages.transformers.ts @@ -7,7 +7,6 @@ import { dbDateToLocalDate, RetrospectiveWorkPackage, WorkPackage, WorkPackagePr import { implementedChangeTransformer } from './change-requests.transformers'; import { descriptionBulletTransformer } from './projects.transformers'; import { eventPreviewTransformer } from './calendar.transformer'; -import { taskTransformer } from './tasks.transformers'; /** * Transforms a work package to ensure deep field transformation of date objects. @@ -23,8 +22,7 @@ export const workPackageTransformer = (workPackage: WorkPackage): WorkPackage => endDate: dbDateToLocalDate(new Date(workPackage.endDate)), descriptionBullets: workPackage.descriptionBullets.map(descriptionBulletTransformer), changes: workPackage.changes.map(implementedChangeTransformer), - events: workPackage.events.map(eventPreviewTransformer), - tasks: workPackage.tasks.map(taskTransformer) + events: workPackage.events.map(eventPreviewTransformer) }; }; diff --git a/src/shared/src/types/project-types.ts b/src/shared/src/types/project-types.ts index 544825bc77..97316b6f9d 100644 --- a/src/shared/src/types/project-types.ts +++ b/src/shared/src/types/project-types.ts @@ -107,7 +107,6 @@ export interface WorkPackage extends WbsElement { teamTypes: TeamType[]; projectId: string; events: EventPreview[]; - tasks: Task[]; } export interface WorkPackagePreview extends WbsElementPreview { From af1cf4d3dc254e141ac4f35240116a5197a16221 Mon Sep 17 00:00:00 2001 From: getheobald Date: Sat, 28 Mar 2026 12:41:46 -0400 Subject: [PATCH 20/23] #4048 full getTasksByWbsElement endpoint --- .../src/controllers/tasks.controllers.ts | 10 +++ .../work-packages.query-args.ts | 1 - src/backend/src/routes/tasks.routes.ts | 2 + src/backend/src/services/tasks.services.ts | 66 ++++++++++++++++++- src/frontend/src/apis/tasks.api.ts | 13 ++++ src/frontend/src/hooks/tasks.hooks.ts | 18 ++++- src/frontend/src/utils/urls.ts | 2 + 7 files changed, 109 insertions(+), 3 deletions(-) diff --git a/src/backend/src/controllers/tasks.controllers.ts b/src/backend/src/controllers/tasks.controllers.ts index f0ed554119..07a04cf4db 100644 --- a/src/backend/src/controllers/tasks.controllers.ts +++ b/src/backend/src/controllers/tasks.controllers.ts @@ -135,4 +135,14 @@ export default class TasksController { next(error); } } + + static async getTasksByWbsNum(req: Request, res: Response, next: NextFunction) { + try { + const wbsNum: WbsNumber = validateWBS(req.params.wbsNum as string); + const tasks = await TasksService.getTasksByWbsNum(wbsNum, req.organization); + res.status(200).json(tasks); + } catch (error: unknown) { + next(error); + } + } } diff --git a/src/backend/src/prisma-query-args/work-packages.query-args.ts b/src/backend/src/prisma-query-args/work-packages.query-args.ts index dceba4e6cb..694044c3cc 100644 --- a/src/backend/src/prisma-query-args/work-packages.query-args.ts +++ b/src/backend/src/prisma-query-args/work-packages.query-args.ts @@ -3,7 +3,6 @@ import { getUserPreviewQueryArgs, getUserQueryArgs } from './user.query-args.js' import { getDescriptionBulletQueryArgs } from './description-bullets.query-args.js'; import { getLinkQueryArgs } from './links.query-args.js'; import { getEventQueryArgs } from './event.query-args.js'; -import { getTaskQueryArgs } from './tasks.query-args.js'; export type WorkPackageQueryArgs = ReturnType; export type WorkPackagePreviewQueryArgs = ReturnType; diff --git a/src/backend/src/routes/tasks.routes.ts b/src/backend/src/routes/tasks.routes.ts index 1075b9b04b..f0dd4709d7 100644 --- a/src/backend/src/routes/tasks.routes.ts +++ b/src/backend/src/routes/tasks.routes.ts @@ -69,4 +69,6 @@ tasksRouter.post('/:taskId/delete', validateInputs, TasksController.deleteTask); tasksRouter.get('/overdue-by-team-member/:userId', TasksController.getOverdueTasksByTeamLeadership); +tasksRouter.get('/by-wbs/:wbsNum', TasksController.getTasksByWbsNum); + export default tasksRouter; diff --git a/src/backend/src/services/tasks.services.ts b/src/backend/src/services/tasks.services.ts index 24beb2d508..4ba5db31d0 100644 --- a/src/backend/src/services/tasks.services.ts +++ b/src/backend/src/services/tasks.services.ts @@ -74,7 +74,15 @@ export default class TasksService { workPackages: { include: { wbsElement: true } } } }, - workPackage: true + workPackage: { + include: { + project: { + include: { + teams: getTeamQueryArgs(organization.organizationId) + } + } + } + } } }); if (!requestedWbsElement) throw new NotFoundException('WBS Element', wbsPipe(wbsNum)); @@ -440,4 +448,60 @@ export default class TasksService { return tasks.map(taskCardPreviewTransformer); } + + /** + * Gets all tasks associated with a wbs element + * If the wbs number is a project (workPackageNumber === 0), returns the project's + * own tasks merged with all of its work packages' tasks + * If the wbs number is a work package, returns just that WP's tasks + * @param wbsNum the wbs number to fetch tasks for + * @param organization the organization that the user is currently in + * @returns array of tasks + */ + static async getTasksByWbsNum(wbsNum: WbsNumber, organization: Organization): Promise { + const wbsElement = await prisma.wBS_Element.findUnique({ + where: { + wbsNumber: { + ...wbsNum, + organizationId: organization.organizationId + } + } + }); + + if (!wbsElement) throw new NotFoundException('WBS Element', wbsPipe(wbsNum)); + if (wbsElement.dateDeleted) throw new DeletedException('WBS Element', wbsPipe(wbsNum)); + + // project case, so return project's own tasks and all its wp's tasks + if (wbsNum.workPackageNumber === 0) { + const project = await prisma.project.findUnique({ + where: { wbsElementId: wbsElement.wbsElementId }, + include: { workPackages: { include: { wbsElement: true } } } + }); + + if (!project) throw new NotFoundException('Project', wbsPipe(wbsNum)); + + const wpWbsElementIds = project.workPackages.map((wp) => wp.wbsElementId); + + const tasks = await prisma.task.findMany({ + where: { + dateDeleted: null, + wbsElementId: { in: [wbsElement.wbsElementId, ...wpWbsElementIds] } + }, + ...getTaskQueryArgs(organization.organizationId) + }); + + return tasks.map(taskTransformer); + } + + // work package case, so return just that wp's tasks + const tasks = await prisma.task.findMany({ + where: { + dateDeleted: null, + wbsElementId: wbsElement.wbsElementId + }, + ...getTaskQueryArgs(organization.organizationId) + }); + + return tasks.map(taskTransformer); + } } diff --git a/src/frontend/src/apis/tasks.api.ts b/src/frontend/src/apis/tasks.api.ts index 65e8a8a5f9..e19c635e25 100644 --- a/src/frontend/src/apis/tasks.api.ts +++ b/src/frontend/src/apis/tasks.api.ts @@ -157,3 +157,16 @@ export const getOverdueTasksByTeamLeader = (userId: string) => { transformResponse: (data) => JSON.parse(data).map(taskTransformer) }); }; + +/** + * Gets all tasks for a given WBS element + * For projects, returns project tasks merged with all project's wp's tasks + * For work packages, returns just that wp's tasks + * @param wbsNum the wbs number to fetch tasks for + * @returns array of tasks + */ +export const getTasksByWbsNum = (wbsNum: WbsNumber) => { + return axios.get(apiUrls.tasksByWbsNum(wbsPipe(wbsNum)), { + transformResponse: (data) => JSON.parse(data).map(taskTransformer) + }); +}; diff --git a/src/frontend/src/hooks/tasks.hooks.ts b/src/frontend/src/hooks/tasks.hooks.ts index 846c9a27a4..0ebf06e936 100644 --- a/src/frontend/src/hooks/tasks.hooks.ts +++ b/src/frontend/src/hooks/tasks.hooks.ts @@ -13,8 +13,10 @@ import { editTaskAssignees, editTaskWbsElement, getOverdueTasksByTeamLeader, - getFilterTasks + getFilterTasks, + getTasksByWbsNum } from '../apis/tasks.api'; +import { wbsPipe } from '../utils/pipes'; export interface CreateTaskPayload { wbsNum: WbsNumber; @@ -198,3 +200,17 @@ export const useOverdueTasksByTeamLeader = (userId: string) => { return data; }); }; + +/** + * Custom React Hook to get all tasks for a given wbs element + * For projects, returns project tasks merged with all project's wp's tasks + * For work packages, returns just that wp's tasks + * @param wbsNum the wbs number to fetch tasks for + * @returns the tasks query + */ +export const useTasksByWbsNum = (wbsNum: WbsNumber) => { + return useQuery(['tasks', wbsPipe(wbsNum)], async () => { + const { data } = await getTasksByWbsNum(wbsNum); + return data; + }); +}; diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 6344796cd0..145f779643 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -98,6 +98,7 @@ const editTaskWbsElement = (taskId: string) => `${tasks()}/${taskId}/edit-wbs-el 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 }) => { @@ -595,6 +596,7 @@ export const apiUrls = { editTaskWbsElement, deleteTask, overdueTasksByTeamLeadership, + tasksByWbsNum, workPackages, workPackagesByWbsNum, From 4ccc6ea0cff79f415c82900c1a06813419ac73fd Mon Sep 17 00:00:00 2001 From: getheobald Date: Sat, 28 Mar 2026 13:21:20 -0400 Subject: [PATCH 21/23] #4048 revert tasklist props --- .../ProjectViewContainer/TaskList/v2/TaskColumn.tsx | 4 ---- .../ProjectViewContainer/TaskList/v2/TaskList.tsx | 7 +------ .../ProjectViewContainer/TaskList/v2/TaskListContent.tsx | 4 +--- 3 files changed, 2 insertions(+), 13 deletions(-) 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 d934d9d23c..80b178cea2 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskColumn.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskColumn.tsx @@ -11,10 +11,8 @@ import TaskFormModal, { EditTaskFormInput } from '../TaskFormModal'; export const TaskColumn = ({ status, - tasks, wbsNum, wbsElementId, - workPackages, equalizedHeight, isDragging, onEditTask, @@ -23,10 +21,8 @@ export const TaskColumn = ({ onHeightChange }: { status: TaskStatus; - tasks: TaskWithIndex[]; wbsNum: WbsNumber; wbsElementId: string; - workPackages?: WorkPackage[]; equalizedHeight: number; isDragging: boolean; onEditTask: (task: Task) => void; 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 6562dd3870..34c8dcdcda 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskList.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskList.tsx @@ -9,11 +9,6 @@ export const TaskList = ({ project, isGuest }: { project: Project; isGuest: bool 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 8364df734f..cc5e1d9bc7 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskListContent.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskListContent.tsx @@ -9,13 +9,11 @@ import { TaskColumn } from './TaskColumn'; import confetti from 'canvas-confetti'; interface TaskListContentProps { - tasks: Task[]; wbsNum: WbsNumber; wbsElementId: string; - workPackages?: WorkPackage[]; } -export const TaskListContent = ({ tasks, wbsNum, wbsElementId, workPackages }: TaskListContentProps) => { +export const TaskListContent = ({ wbsNum, wbsElementId }: TaskListContentProps) => { const [tasksByStatus, setTasksByStatus] = useState(getTasksByStatus(tasks)); const { mutateAsync: setTaskStatus } = useSetTaskStatus(); From 9a64299a17373dccc5a95d29ef344bf13a97b423 Mon Sep 17 00:00:00 2001 From: getheobald Date: Sat, 28 Mar 2026 14:22:43 -0400 Subject: [PATCH 22/23] #4048 changed tasklistcontent state to flat array but sadly need to revert, saving for vibes ig --- .../TaskList/v2/TaskColumn.tsx | 2 + .../TaskList/v2/TaskListContent.tsx | 105 ++++-------------- 2 files changed, 26 insertions(+), 81 deletions(-) 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 80b178cea2..18fa35eda3 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskColumn.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskColumn.tsx @@ -11,6 +11,7 @@ import TaskFormModal, { EditTaskFormInput } from '../TaskFormModal'; export const TaskColumn = ({ status, + tasks, wbsNum, wbsElementId, equalizedHeight, @@ -21,6 +22,7 @@ export const TaskColumn = ({ onHeightChange }: { status: TaskStatus; + tasks: TaskWithIndex[]; wbsNum: WbsNumber; wbsElementId: string; equalizedHeight: number; 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 cc5e1d9bc7..51a27dbee7 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskListContent.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskListContent.tsx @@ -3,10 +3,12 @@ import { Box } from '@mui/material'; import { useCallback, useState } from 'react'; import { Task, TaskStatus, TaskWithIndex, WbsNumber, WorkPackage } 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 TaskListContentProps { wbsNum: WbsNumber; @@ -14,7 +16,8 @@ interface TaskListContentProps { } export const TaskListContent = ({ wbsNum, wbsElementId }: TaskListContentProps) => { - const [tasksByStatus, setTasksByStatus] = useState(getTasksByStatus(tasks)); + const { data: tasks, isLoading, isError, error } = useTasksByWbsNum(wbsNum); + const [localTasks, setLocalTasks] = useState(); const { mutateAsync: setTaskStatus } = useSetTaskStatus(); const toast = useToast(); @@ -27,39 +30,22 @@ export const TaskListContent = ({ wbsNum, wbsElementId }: TaskListContentProps) setColumnHeights((prev) => ({ ...prev, [status]: height })); }, []); + if (isLoading || !tasks) return ; + if (isError) return ; + + const displayedTasks = localTasks ?? tasks; + const tasksByStatus = getTasksByStatus(displayedTasks); + const onDeleteTask = (taskId: string) => { - setTasksByStatus((prev) => { - const newTasksByStatus = { ...prev }; - for (const status of statuses) { - const index = newTasksByStatus[status].findIndex((task) => task?.taskId === taskId); - if (index !== -1) { - newTasksByStatus[status].splice(index, 1); - break; - } - } - return newTasksByStatus; - }); + setLocalTasks((prev) => (prev ?? tasks).filter((t) => t.taskId !== taskId)); }; const onEditTask = (task: Task) => { - setTasksByStatus((prev) => { - const newTasksByStatus = { ...prev }; - for (const status of statuses) { - const index = newTasksByStatus[status].findIndex((t) => t?.taskId === task.taskId); - if (index !== -1) { - newTasksByStatus[status][index] = { ...task, index }; - break; - } - } - return newTasksByStatus; - }); + setLocalTasks((prev) => (prev ?? tasks).map((t) => (t.taskId === task.taskId ? task : t))); }; const onAddTask = (task: Task) => { - setTasksByStatus((prev) => ({ - ...prev, - [task.status]: [...prev[task.status], { ...task, index: prev[task.status].length }] - })); + setLocalTasks((prev) => [...(prev ?? tasks), task]); }; const onDragStart: OnDragStartResponder = () => { @@ -80,23 +66,17 @@ export const TaskListContent = ({ wbsNum, wbsElementId }: TaskListContentProps) const sourceStatus = source.droppableId as Task['status']; const destinationStatus = destination.droppableId as Task['status']; - const sourcePost = tasksByStatus[sourceStatus][source.index]!; - - // compute local state change synchronously - setTasksByStatus( - updateTaskStatusLocal( - sourcePost, - { status: sourceStatus, index: source.index }, - { status: destinationStatus, index: destination.index }, - tasksByStatus - ) + const sourceTask = tasksByStatus[sourceStatus][source.index]!; + + // optimistically update local state + setLocalTasks((prev) => + (prev ?? tasks).map((t) => (t.taskId === sourceTask.taskId ? { ...t, status: destinationStatus } : t)) ); - //trigger the mutation to persist the changes try { - await setTaskStatus({ taskId: sourcePost.taskId, status: destinationStatus }); - const confettiPositions = [0, 0.15, 0.3, 0.45, 0.6, 0.75, 0.9]; + await setTaskStatus({ taskId: sourceTask.taskId, status: destinationStatus }); if (destinationStatus === 'DONE' && sourceStatus !== 'DONE') { + const confettiPositions = [0, 0.15, 0.3, 0.45, 0.6, 0.75, 0.9]; confettiPositions.forEach((xPos) => { confetti({ origin: { y: -0.5, x: xPos }, @@ -110,14 +90,9 @@ export const TaskListContent = ({ wbsNum, wbsElementId }: TaskListContentProps) } } catch (error) { if (error instanceof Error) toast.error(error.message); - //revert optimistic updates - setTasksByStatus( - updateTaskStatusLocal( - sourcePost, - { status: destinationStatus, index: destination.index }, - { status: sourceStatus, index: source.index }, - tasksByStatus - ) + // revert optimistic update + setLocalTasks((prev) => + (prev ?? tasks).map((t) => (t.taskId === sourceTask.taskId ? { ...t, status: sourceStatus } : t)) ); } }; @@ -136,7 +111,6 @@ export const TaskListContent = ({ wbsNum, wbsElementId }: TaskListContentProps) key={status} wbsNum={wbsNum} wbsElementId={wbsElementId} - workPackages={workPackages} equalizedHeight={equalizedHeight} isDragging={isDragging} /> @@ -145,34 +119,3 @@ export const TaskListContent = ({ wbsNum, wbsElementId }: TaskListContentProps) ); }; - -const updateTaskStatusLocal = ( - sourceTask: TaskWithIndex, - source: { status: Task['status']; index: number }, - destination: { - status: Task['status']; - index?: number; // undefined if dropped after the last item - }, - tasksByStatus: TasksByStatus -) => { - if (source.status === destination.status) { - // moving deal inside the same column - const column = tasksByStatus[source.status]; - column.splice(source.index, 1); - column.splice(destination.index ?? column.length + 1, 0, sourceTask); - return { - ...tasksByStatus, - [destination.status]: column - }; - } - // moving deal across columns - const sourceColumn = tasksByStatus[source.status]; - const destinationColumn = tasksByStatus[destination.status]; - sourceColumn.splice(source.index, 1); - destinationColumn.splice(destination.index ?? destinationColumn.length + 1, 0, sourceTask); - return { - ...tasksByStatus, - [source.status]: sourceColumn, - [destination.status]: destinationColumn - }; -}; From a76d9bb94a2bcaedb2ee26b250d78ad86c560300 Mon Sep 17 00:00:00 2001 From: getheobald Date: Sat, 28 Mar 2026 14:43:30 -0400 Subject: [PATCH 23/23] 4048 fixed tasklistcontent to use hook but maintain custom ordering --- .../TaskList/v2/TaskListContent.tsx | 109 ++++++++++++++---- 1 file changed, 89 insertions(+), 20 deletions(-) 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 51a27dbee7..20fd3bc938 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskListContent.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskListContent.tsx @@ -1,7 +1,7 @@ import { DragDropContext, OnDragEndResponder, OnDragStartResponder } from '@hello-pangea/dnd'; import { Box } from '@mui/material'; -import { useCallback, useState } from 'react'; -import { Task, TaskStatus, TaskWithIndex, WbsNumber, WorkPackage } from 'shared'; +import { useCallback, useState, useEffect } from 'react'; +import { Task, TaskStatus, TaskWithIndex, WbsNumber } from 'shared'; import { getTasksByStatus, statuses, TasksByStatus } from '.'; import { useSetTaskStatus, useTasksByWbsNum } from '../../../../../hooks/tasks.hooks'; import { useToast } from '../../../../../hooks/toasts.hooks'; @@ -17,7 +17,7 @@ interface TaskListContentProps { export const TaskListContent = ({ wbsNum, wbsElementId }: TaskListContentProps) => { const { data: tasks, isLoading, isError, error } = useTasksByWbsNum(wbsNum); - const [localTasks, setLocalTasks] = useState(); + const [tasksByStatus, setTasksByStatus] = useState(); // can't use getTasksByStatus since tasks are async const { mutateAsync: setTaskStatus } = useSetTaskStatus(); const toast = useToast(); @@ -26,26 +26,53 @@ export const TaskListContent = ({ wbsNum, wbsElementId }: TaskListContentProps) const [columnHeights, setColumnHeights] = useState>>({}); const equalizedHeight = Math.max(...(Object.values(columnHeights) as number[])); + // initialize tasksByStatus once tasks load, but only once + useEffect(() => { + if (tasks && !tasksByStatus) { + setTasksByStatus(getTasksByStatus(tasks)); + } + }, [tasks]); + const onHeightChange = useCallback((status: TaskStatus, height: number) => { setColumnHeights((prev) => ({ ...prev, [status]: height })); }, []); - if (isLoading || !tasks) return ; + if (isLoading || !tasksByStatus) return ; if (isError) return ; - const displayedTasks = localTasks ?? tasks; - const tasksByStatus = getTasksByStatus(displayedTasks); - const onDeleteTask = (taskId: string) => { - setLocalTasks((prev) => (prev ?? tasks).filter((t) => t.taskId !== taskId)); + setTasksByStatus((prev) => { + const newTasksByStatus = { ...prev }; + for (const status of statuses) { + const index = newTasksByStatus[status].findIndex((task) => task?.taskId === taskId); + if (index !== -1) { + newTasksByStatus[status].splice(index, 1); + break; + } + } + return newTasksByStatus; + }); }; const onEditTask = (task: Task) => { - setLocalTasks((prev) => (prev ?? tasks).map((t) => (t.taskId === task.taskId ? task : t))); + setTasksByStatus((prev) => { + const newTasksByStatus = { ...prev }; + for (const status of statuses) { + const index = newTasksByStatus[status].findIndex((t) => t?.taskId === task.taskId); + if (index !== -1) { + newTasksByStatus[status][index] = { ...task, index }; + break; + } + } + return newTasksByStatus; + }); }; const onAddTask = (task: Task) => { - setLocalTasks((prev) => [...(prev ?? tasks), task]); + setTasksByStatus((prev) => ({ + ...prev, + [task.status]: [...prev[task.status], { ...task, index: prev[task.status].length }] + })); }; const onDragStart: OnDragStartResponder = () => { @@ -66,17 +93,23 @@ export const TaskListContent = ({ wbsNum, wbsElementId }: TaskListContentProps) const sourceStatus = source.droppableId as Task['status']; const destinationStatus = destination.droppableId as Task['status']; - const sourceTask = tasksByStatus[sourceStatus][source.index]!; - - // optimistically update local state - setLocalTasks((prev) => - (prev ?? tasks).map((t) => (t.taskId === sourceTask.taskId ? { ...t, status: destinationStatus } : t)) + const sourcePost = tasksByStatus[sourceStatus][source.index]!; + + // compute local state change synchronously + setTasksByStatus( + updateTaskStatusLocal( + sourcePost, + { status: sourceStatus, index: source.index }, + { status: destinationStatus, index: destination.index }, + tasksByStatus + ) ); + //trigger the mutation to persist the changes try { - await setTaskStatus({ taskId: sourceTask.taskId, status: destinationStatus }); + await setTaskStatus({ taskId: sourcePost.taskId, status: destinationStatus }); + const confettiPositions = [0, 0.15, 0.3, 0.45, 0.6, 0.75, 0.9]; if (destinationStatus === 'DONE' && sourceStatus !== 'DONE') { - const confettiPositions = [0, 0.15, 0.3, 0.45, 0.6, 0.75, 0.9]; confettiPositions.forEach((xPos) => { confetti({ origin: { y: -0.5, x: xPos }, @@ -90,9 +123,14 @@ export const TaskListContent = ({ wbsNum, wbsElementId }: TaskListContentProps) } } catch (error) { if (error instanceof Error) toast.error(error.message); - // revert optimistic update - setLocalTasks((prev) => - (prev ?? tasks).map((t) => (t.taskId === sourceTask.taskId ? { ...t, status: sourceStatus } : t)) + //revert optimistic updates + setTasksByStatus( + updateTaskStatusLocal( + sourcePost, + { status: destinationStatus, index: destination.index }, + { status: sourceStatus, index: source.index }, + tasksByStatus + ) ); } }; @@ -119,3 +157,34 @@ export const TaskListContent = ({ wbsNum, wbsElementId }: TaskListContentProps) ); }; + +const updateTaskStatusLocal = ( + sourceTask: TaskWithIndex, + source: { status: Task['status']; index: number }, + destination: { + status: Task['status']; + index?: number; // undefined if dropped after the last item + }, + tasksByStatus: TasksByStatus +) => { + if (source.status === destination.status) { + // moving deal inside the same column + const column = tasksByStatus[source.status]; + column.splice(source.index, 1); + column.splice(destination.index ?? column.length + 1, 0, sourceTask); + return { + ...tasksByStatus, + [destination.status]: column + }; + } + // moving deal across columns + const sourceColumn = tasksByStatus[source.status]; + const destinationColumn = tasksByStatus[destination.status]; + sourceColumn.splice(source.index, 1); + destinationColumn.splice(destination.index ?? destinationColumn.length + 1, 0, sourceTask); + return { + ...tasksByStatus, + [source.status]: sourceColumn, + [destination.status]: destinationColumn + }; +};