diff --git a/src/backend/src/controllers/statistics.controllers.ts b/src/backend/src/controllers/statistics.controllers.ts index 465f10cfa3..39d2c3946a 100644 --- a/src/backend/src/controllers/statistics.controllers.ts +++ b/src/backend/src/controllers/statistics.controllers.ts @@ -1,6 +1,6 @@ import { NextFunction, Request, Response } from 'express'; import StatisticsService from '../services/statistics.services'; -import { Graph } from 'shared'; +import { Graph, GraphCollection } from 'shared'; export default class StatisticsController { static async createGraph(req: Request, res: Response, next: NextFunction) { @@ -39,13 +39,104 @@ export default class StatisticsController { static async getSingleGraph(req: Request, res: Response, next: NextFunction) { try { - const { id } = req.params; + const { graphId } = req.params; - const requestedGraph = await StatisticsService.getSingleGraph(id, req.currentUser, req.organization); + const requestedGraph = await StatisticsService.getSingleGraph(graphId, req.currentUser, req.organization); res.status(200).json(requestedGraph); } catch (error: unknown) { next(error); } } + + static async getAllGraphCollections(req: Request, res: Response, next: NextFunction) { + try { + const graphCollections = await StatisticsService.getAllGraphCollections(req.currentUser, req.organization); + res.status(200).json(graphCollections); + } catch (error: unknown) { + next(error); + } + } + + static async editGraph(req: Request, res: Response, next: NextFunction) { + try { + const { + startDate, + endDate, + title, + graphType, + measure, + graphDisplayType, + graphCollectionId, + carIds, + specialPermissions + } = req.body; + const { graphId } = req.params; + + const updatedGraph = await StatisticsService.editGraph( + req.currentUser, + graphId, + title, + graphType, + measure, + graphDisplayType, + req.organization, + carIds, + specialPermissions, + startDate ? new Date(startDate) : undefined, + endDate ? new Date(endDate) : undefined, + graphCollectionId + ); + + res.status(200).json(updatedGraph); + } catch (error: unknown) { + next(error); + } + } + + static async createGraphCollection(req: Request, res: Response, next: NextFunction) { + try { + const { title, specialPermissions } = req.body; + const graphCollection: GraphCollection = await StatisticsService.createGraphCollection( + req.currentUser, + title, + specialPermissions, + req.organization + ); + res.status(200).json(graphCollection); + } catch (error: unknown) { + next(error); + } + } + + static async editGraphCollection(req: Request, res: Response, next: NextFunction) { + try { + const { title, specialPermissions } = req.body; + const { graphCollectionId } = req.params; + const graphCollection: GraphCollection = await StatisticsService.editGraphCollection( + req.currentUser, + graphCollectionId, + title, + specialPermissions, + req.organization + ); + res.status(200).json(graphCollection); + } catch (error: unknown) { + next(error); + } + } + + static async getSingleGraphCollection(req: Request, res: Response, next: NextFunction) { + try { + const { graphCollectionId } = req.params; + const graphCollection: GraphCollection = await StatisticsService.getSingleGraphCollection( + req.currentUser, + graphCollectionId, + req.organization + ); + res.status(200).json(graphCollection); + } catch (error: unknown) { + next(error); + } + } } diff --git a/src/backend/src/controllers/tasks.controllers.ts b/src/backend/src/controllers/tasks.controllers.ts index bc79cfc152..b99fdf2dab 100644 --- a/src/backend/src/controllers/tasks.controllers.ts +++ b/src/backend/src/controllers/tasks.controllers.ts @@ -13,11 +13,11 @@ export default class TasksController { wbsNum, title, notes, - new Date(deadline), priority, status, assignees, - req.organization + req.organization, + deadline ? new Date(deadline) : undefined ); res.status(200).json(task); diff --git a/src/backend/src/prisma/migrations/20241113231854_optional_deadline_assignees_task/migration.sql b/src/backend/src/prisma/migrations/20241113231854_optional_deadline_assignees_task/migration.sql new file mode 100644 index 0000000000..28f390d560 --- /dev/null +++ b/src/backend/src/prisma/migrations/20241113231854_optional_deadline_assignees_task/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Task" ALTER COLUMN "deadline" DROP NOT NULL; diff --git a/src/backend/src/prisma/migrations/20241217224731_stats_page/migration.sql b/src/backend/src/prisma/migrations/20241227031743_stats_page/migration.sql similarity index 98% rename from src/backend/src/prisma/migrations/20241217224731_stats_page/migration.sql rename to src/backend/src/prisma/migrations/20241227031743_stats_page/migration.sql index 20490b1d39..0d12c002e3 100644 --- a/src/backend/src/prisma/migrations/20241217224731_stats_page/migration.sql +++ b/src/backend/src/prisma/migrations/20241227031743_stats_page/migration.sql @@ -49,6 +49,7 @@ CREATE TABLE "Graph_Collection" ( "title" TEXT NOT NULL, "dateDeleted" TIMESTAMP(3), "viewPermissions" "Special_Permission"[], + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "userCreatedId" TEXT NOT NULL, "userDeletedId" TEXT, "organizationId" TEXT NOT NULL, diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index 6c1af3890e..f68c60b7e9 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -532,7 +532,7 @@ model Task { taskId String @id @default(uuid()) title String notes String - deadline DateTime + deadline DateTime? assignees User[] @relation(name: "assignedTo") priority Task_Priority status Task_Status @@ -1011,6 +1011,7 @@ model Graph_Collection { dateDeleted DateTime? viewPermissions Special_Permission[] graphs Graph[] + dateCreated DateTime @default(now()) userCreatedId String userCreated User @relation(fields: [userCreatedId], references: [userId], name: "graphCollectionsCreator") diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index f6137e61c3..f8155da887 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -27,6 +27,7 @@ import { DesignReviewStatus, MaterialStatus, RoleEnum, + SpecialPermission, StandardChangeRequest, WbsElementStatus, WorkPackageStage @@ -47,6 +48,7 @@ import RecruitmentServices from '../services/recruitment.services'; import OrganizationsService from '../services/organizations.services'; import StatisticsService from '../services/statistics.services'; import { seedGraph } from './seed-data/statistics.seed'; +import { graphCollectionTransformer } from '../transformers/statistics-graphCollection.transformer'; const prisma = new PrismaClient(); @@ -709,6 +711,32 @@ const performSeed: () => Promise = async () => { ner ); + /** + * Graph Collection 1 + */ + const graph2 = await prisma.graph.create({ + data: { + title: 'graph2', + graphType: Graph_Type.PROJECT_BUDGET_BY_PROJECT, + displayGraphType: Graph_Display_Type.PIE, + measure: Measure.SUM, + userCreatedId: thomasEmrax.userId, + organizationId: ner.organizationId + } + }); + + const graphCollection1 = await prisma.graph_Collection.create({ + data: { + title: 'Graph Collection 1', + viewPermissions: [SpecialPermission.FINANCE_ONLY], + graphs: { + connect: [{ id: graph2.id }] + }, + userCreatedId: thomasEmrax.userId, + organizationId: ner.organizationId + } + }); + /** * Change Requests for Creating Work Packages */ @@ -1399,11 +1427,11 @@ const performSeed: () => Promise = async () => { project1WbsNumber, 'Research attenuation', "I don't know what attenuation is yet", - new Date('01/01/2024'), Task_Priority.HIGH, Task_Status.IN_PROGRESS, [joeShmoe.userId], - ner + ner, + new Date('01/01/2024') ); await TasksService.createTask( @@ -1411,11 +1439,11 @@ const performSeed: () => Promise = async () => { project1WbsNumber, 'Design Attenuator', 'Autocad?', - new Date('01/01/2024'), Task_Priority.MEDIUM, Task_Status.IN_BACKLOG, [joeShmoe.userId], - ner + ner, + new Date('01/01/2024') ); await TasksService.createTask( @@ -1423,11 +1451,11 @@ const performSeed: () => Promise = async () => { project1WbsNumber, 'Research Impact', 'Autocad?', - new Date('01/01/2024'), Task_Priority.MEDIUM, Task_Status.IN_PROGRESS, [joeShmoe.userId, joeBlow.userId], - ner + ner, + new Date('01/01/2024') ); await TasksService.createTask( @@ -1436,11 +1464,11 @@ const performSeed: () => Promise = async () => { 'Impact Test', 'Use our conveniently available jumbo watermelon and slingshot to test how well our impact attenuator can ' + 'attenuate impact.', - new Date('2024-02-17T00:00:00-05:00'), Task_Priority.LOW, Task_Status.IN_PROGRESS, [joeBlow.userId], - ner + ner, + new Date('2024-02-17T00:00:00-05:00') ); await TasksService.createTask( @@ -1448,11 +1476,11 @@ const performSeed: () => Promise = async () => { project1WbsNumber, 'Review Compliance', 'I think there are some rules we may or may not have overlooked...', - new Date('2024-01-01T00:00:00-05:00'), Task_Priority.MEDIUM, Task_Status.IN_PROGRESS, [thomasEmrax.userId], - ner + ner, + new Date('2024-01-01T00:00:00-05:00') ); await TasksService.createTask( @@ -1460,11 +1488,11 @@ const performSeed: () => Promise = async () => { project1WbsNumber, 'Decorate Impact Attenuator', 'You know you want to.', - new Date('2024-01-20T00:00:00-05:00'), Task_Priority.LOW, Task_Status.IN_PROGRESS, [thomasEmrax.userId, joeBlow.userId, joeShmoe.userId], - ner + ner, + new Date('2024-01-20T00:00:00-05:00') ); await TasksService.createTask( @@ -1472,11 +1500,11 @@ const performSeed: () => Promise = async () => { project1WbsNumber, 'Meet with the Department of Transportation', 'Discuss design decisions', - new Date('2023-05-19T00:00:00-04:00'), Task_Priority.LOW, Task_Status.IN_PROGRESS, [thomasEmrax.userId], - ner + ner, + new Date('2023-05-19T00:00:00-04:00') ); await TasksService.createTask( @@ -1484,11 +1512,11 @@ const performSeed: () => Promise = async () => { project1WbsNumber, 'Build Attenuator', 'WOOOO', - new Date('01/01/2024'), Task_Priority.LOW, Task_Status.DONE, [joeShmoe.userId], - ner + ner, + new Date('01/01/2024') ); await TasksService.createTask( @@ -1504,11 +1532,11 @@ const performSeed: () => Promise = async () => { '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. ' + '"That\'s over 100 horsepower."', - new Date('2022-11-16T00:00-05:00'), Task_Priority.HIGH, Task_Status.DONE, [joeShmoe.userId], - ner + ner, + new Date('2022-11-16T00:00-05:00') ); await TasksService.createTask( @@ -1516,11 +1544,11 @@ const performSeed: () => Promise = async () => { project1WbsNumber, 'Safety Training', 'how to use (or not use) the impact attenuator', - new Date('2023-03-15T00:00:00-04:00'), Task_Priority.HIGH, Task_Status.DONE, [thomasEmrax.userId, joeBlow.userId, joeShmoe.userId], - ner + ner, + new Date('2023-03-15T00:00:00-04:00') ); await TasksService.createTask( @@ -1528,11 +1556,11 @@ const performSeed: () => Promise = async () => { project2WbsNumber, 'Double-Check Inventory', 'Nobody really wants to do this...', - new Date('2023-04-01T00:00:00-04:00'), Task_Priority.LOW, Task_Status.IN_BACKLOG, [], - ner + ner, + new Date('2023-04-01T00:00:00-04:00') ); await TasksService.createTask( @@ -1540,11 +1568,11 @@ const performSeed: () => Promise = async () => { project2WbsNumber, 'Aerodynamics Test', 'Wind go wooooosh', - new Date('2024-01-01T00:00:00-05:00'), Task_Priority.MEDIUM, Task_Status.IN_PROGRESS, [joeShmoe.userId], - ner + ner, + new Date('2024-01-01T00:00:00-05:00') ); await TasksService.createTask( @@ -1552,11 +1580,11 @@ const performSeed: () => Promise = async () => { project2WbsNumber, 'Ask Sponsors About Logo Sticker Placement', 'the more sponsors the cooler we look', - new Date('2024-01-01T00:00:00-05:00'), Task_Priority.HIGH, Task_Status.IN_PROGRESS, [thomasEmrax.userId, joeShmoe.userId], - ner + ner, + new Date('2024-01-01T00:00:00-05:00') ); await TasksService.createTask( @@ -1564,11 +1592,11 @@ const performSeed: () => Promise = async () => { project2WbsNumber, 'Discuss Design With Powertrain Team', '', - new Date('2023-10-31T00:00:00-04:00'), Task_Priority.MEDIUM, Task_Status.DONE, [thomasEmrax.userId], - ner + ner, + new Date('2023-10-31T00:00:00-04:00') ); await TasksService.createTask( @@ -1576,11 +1604,11 @@ const performSeed: () => Promise = async () => { project3WbsNumber, 'Power the Battery Box', 'With all our powers combined, we can win any Electric Racing competition!', - new Date('2024-05-01T00:00:00-04:00'), Task_Priority.MEDIUM, Task_Status.IN_BACKLOG, [thomasEmrax, joeShmoe, joeBlow].map((user) => user.userId), - ner + ner, + new Date('2024-05-01T00:00:00-04:00') ); await TasksService.createTask( @@ -1588,11 +1616,11 @@ const performSeed: () => Promise = async () => { project3WbsNumber, 'Wire Up Battery Box', 'Too many wires... how to even keep track?', - new Date('2024-02-29T00:00:00-05:00'), Task_Priority.HIGH, Task_Status.IN_PROGRESS, [joeShmoe.userId], - ner + ner, + new Date('2024-02-29T00:00:00-05:00') ); await TasksService.createTask( @@ -1600,11 +1628,11 @@ const performSeed: () => Promise = async () => { project3WbsNumber, 'Vibration Tests', "Battery box shouldn't blow up in the middle of racing...", - new Date('2024-03-17T00:00:00-05:00'), Task_Priority.MEDIUM, Task_Status.IN_BACKLOG, [joeShmoe.userId], - ner + ner, + new Date('2024-03-17T00:00:00-05:00') ); await TasksService.createTask( @@ -1612,11 +1640,11 @@ const performSeed: () => Promise = async () => { project3WbsNumber, 'Buy some Battery Juice', 'mmm battery juice', - new Date('2024-04-15T00:00:00-04:00'), Task_Priority.LOW, Task_Status.DONE, [joeBlow.userId], - ner + ner, + new Date('2024-04-15T00:00:00-04:00') ); await TasksService.createTask( @@ -1624,11 +1652,11 @@ const performSeed: () => Promise = async () => { project4WbsNumber, 'Schematics', 'schematics go brrrrr', - new Date('2024-04-15T00:00:00-04:00'), Task_Priority.HIGH, Task_Status.DONE, [joeBlow.userId], - ner + ner, + new Date('2024-04-15T00:00:00-04:00') ); await TasksService.createTask( @@ -1636,11 +1664,11 @@ const performSeed: () => Promise = async () => { project5WbsNumber, 'Cost Assessment', 'So this is where our funding goes', - new Date('2023-06-23T00:00:00-04:00'), Task_Priority.HIGH, Task_Status.IN_PROGRESS, [regina.userId], - ner + ner, + new Date('2023-06-23T00:00:00-04:00') ); /** diff --git a/src/backend/src/routes/statistics.routes.ts b/src/backend/src/routes/statistics.routes.ts index 4b49f7514d..2c050ff43b 100644 --- a/src/backend/src/routes/statistics.routes.ts +++ b/src/backend/src/routes/statistics.routes.ts @@ -18,6 +18,8 @@ import { body } from 'express-validator'; const statisticsRouter = express.Router(); +statisticsRouter.get('/graph/:graphId', StatisticsController.getSingleGraph); + statisticsRouter.post( '/graph/create', isOptionalDate(body('startDate')), @@ -34,6 +36,42 @@ statisticsRouter.post( StatisticsController.createGraph ); -statisticsRouter.get('/graph/:graphId', StatisticsController.getSingleGraph); +statisticsRouter.post( + '/graph/:graphId/edit', + isOptionalDate(body('startDate')), + isOptionalDate(body('endDate')), + nonEmptyString(body('title')), + isGraphType(body('graphType')), + isGraphDisplayType(body('graphDisplayType')), + isMeasure(body('measure')), + body('carId').optional().isString(), + body('graphCollectionId').optional().isString(), + body('specialPermissions').isArray(), + isSpecialPermission(body('specialPermissions.*')), + validateInputs, + StatisticsController.editGraph +); + +statisticsRouter.get('/graph-collections', StatisticsController.getAllGraphCollections); + +statisticsRouter.post( + '/graph-collections/create', + nonEmptyString(body('title')), + body('specialPermissions').isArray(), + isSpecialPermission(body('specialPermissions.*')), + validateInputs, + StatisticsController.createGraphCollection +); + +statisticsRouter.get('/graph-collections/:graphCollectionId', StatisticsController.getSingleGraphCollection); + +statisticsRouter.post( + '/graph-collections/:graphCollectionId/edit', + nonEmptyString(body('title')), + body('specialPermissions').isArray(), + isSpecialPermission(body('specialPermissions.*')), + validateInputs, + StatisticsController.editGraphCollection +); export default statisticsRouter; diff --git a/src/backend/src/routes/tasks.routes.ts b/src/backend/src/routes/tasks.routes.ts index a3d554cd01..892a22927a 100644 --- a/src/backend/src/routes/tasks.routes.ts +++ b/src/backend/src/routes/tasks.routes.ts @@ -1,14 +1,14 @@ import express from 'express'; import { body } from 'express-validator'; import TasksController from '../controllers/tasks.controllers'; -import { nonEmptyString, isTaskPriority, isTaskStatus, validateInputs } from '../utils/validation.utils'; +import { nonEmptyString, isTaskPriority, isTaskStatus, validateInputs, isOptionalDate } from '../utils/validation.utils'; const tasksRouter = express.Router(); tasksRouter.post( '/:wbsNum', nonEmptyString(body('title')), - body('deadline').isDate(), + isOptionalDate(body('deadline')), body('notes').isString(), isTaskPriority(body('priority')), isTaskStatus(body('status')), @@ -22,7 +22,7 @@ tasksRouter.post( '/:taskId/edit', nonEmptyString(body('title')), nonEmptyString(body('notes')), - body('deadline').isDate(), + isOptionalDate(body('deadline')), isTaskPriority(body('priority')), TasksController.editTask ); diff --git a/src/backend/src/services/notifications.services.ts b/src/backend/src/services/notifications.services.ts index a443d93588..176d4bc73e 100644 --- a/src/backend/src/services/notifications.services.ts +++ b/src/backend/src/services/notifications.services.ts @@ -77,7 +77,8 @@ export default class NotificationsService { const promises = Array.from(teamTaskMap).map(async ([slackId, tasks]) => { const messageBlock = tasks .map((task) => { - const daysUntilDeadline = daysBetween(task.deadline, new Date()); + // prisma call earlier allows the forced unwrap (deadline is guaranteed to be a non-null value) + const daysUntilDeadline = daysBetween(task.deadline!, new Date()); return `${usersToSlackPings(task.assignees ?? [])} { - if (!(await userHasPermissionNew(user.userId, organization.organizationId, ['CREATE_GRAPH']))) { + if (!(await userHasPermissionNew(user.userId, organization.organizationId, [Permission.CREATE_GRAPH]))) { throw new AccessDeniedException('You do not have permission to create a graph'); } @@ -48,6 +49,10 @@ export default class StatisticsService { } } + if (!isUnderWordCount(title, 20)) { + throw new HttpException(400, 'Title must be less than 20 words'); + } + if (carIds.length > 0) { await Promise.all( carIds.map(async (carId) => { @@ -66,6 +71,20 @@ export default class StatisticsService { ); } + if (graphCollectionId) { + const graphCollection = await prisma.graph_Collection.findUnique({ where: { id: graphCollectionId } }); + + if (!graphCollection) { + throw new NotFoundException('Graph Collection', graphCollectionId); + } + if (graphCollection.dateDeleted) { + throw new DeletedException('Graph Collection', graphCollectionId); + } + if (graphCollection.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Graph Collection'); + } + } + const graph = await prisma.graph.create({ data: { startDate: startDate ?? null, @@ -95,6 +114,119 @@ export default class StatisticsService { }); } + /** + * Edits the graph metadata in the database, retrieve the graph data using getGraphData function + * + * Note: the `userCreatedId` and `organizationId` are not editable. + * + * @param userEditing The user editing the graph, must be the user who created the graph + * @param graphId The id of the graph to edit + * @param startDate The start date of when to consider the data + * @param endDate The end date of when to consider the data + * @param title The title of the graph + * @param graphType The type of graph to use + * @param measure The measurement to apply to the data + * @param graphDisplayType The way to display the graph + * @param organization The organization to make the graph under + * @param carIds Array of carIds to segment the data by, if none are supplied will show data for all cars + * @param specialPermissions Array of permissions to apply to this graph + * @param organization The organization the graph belongs to + * @returns The edited graph and its data + */ + static async editGraph( + userEditing: User, + graphId: string, + title: string, + graphType: Graph_Type, + measure: Measure, + graphDisplayType: Graph_Display_Type, + organization: Organization, + carIds: string[], + specialPermissions: Special_Permission[], + startDate?: Date, + endDate?: Date, + graphCollectionId?: string + ): Promise { + if (!(await userHasPermissionNew(userEditing.userId, organization.organizationId, [Permission.EDIT_GRAPH]))) { + throw new AccessDeniedException('You do not have permission to edit a graph'); + } + + const graph = await prisma.graph.findUnique({ + where: { + id: graphId + }, + ...getGraphQueryArgs(organization.organizationId) + }); + + if (!graph) { + throw new NotFoundException('Graph', graphId); + } + if (graph.dateDeleted) { + throw new DeletedException('Graph', graphId); + } + if (graph.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Graph'); + } + + if (startDate && endDate) { + if (startDate.getTime() >= endDate.getTime()) { + throw new HttpException(400, 'End date must be after start date'); + } + } + + if (!isUnderWordCount(title, 20)) { + throw new HttpException(400, 'Title must be less than 20 words'); + } + + if (graphCollectionId) { + const graphCollection = await prisma.graph_Collection.findUnique({ where: { id: graphCollectionId } }); + + if (!graphCollection) { + throw new NotFoundException('Graph Collection', graphCollectionId); + } + if (graphCollection.dateDeleted) { + throw new DeletedException('Graph Collection', graphCollectionId); + } + if (graphCollection.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Graph Collection'); + } + } + + const updatedGraph = await prisma.graph.update({ + where: { + id: graphId + }, + data: { + startDate: startDate ?? null, + endDate: endDate ?? null, + title, + graphType, + measure, + displayGraphType: graphDisplayType, + specialPermissions, + cars: { + connect: carIds.map((carId) => { + return { carId }; + }) + }, + graphCollectionId: graphCollectionId ? graphCollectionId : null + }, + ...getGraphQueryArgs(organization.organizationId) + }); + + return graphTransformer({ + ...updatedGraph, + graphData: await getGraphData( + updatedGraph.graphType, + updatedGraph.measure, + updatedGraph.organizationId, + updatedGraph.startDate, + updatedGraph.endDate, + { carIds: updatedGraph.cars.map((car) => car.carId) } + ) + }); + } + /** * Gets a single graph * @@ -114,11 +246,10 @@ export default class StatisticsService { if (requestedGraph.dateDeleted) throw new DeletedException('Graph', id); if (requestedGraph.organizationId !== organization.organizationId) throw new InvalidOrganizationException('Graph'); if ( - !(await userHasPermissionNew( - user.userId, - organization.organizationId, - ['VIEW_GRAPH'].concat(requestedGraph.specialPermissions) - )) + !(await userHasPermissionNew(user.userId, organization.organizationId, [ + ...requestedGraph.specialPermissions, + Permission.VIEW_GRAPH + ])) ) { throw new AccessDeniedException('You do not have permission to view graphs'); } @@ -135,4 +266,180 @@ export default class StatisticsService { ) }); } + + /** + * Get all graph collections. + * @param user The user trying to get the graph collections + * @param organization organization that the user is in. + * @returns all the graph collections. + */ + static async getAllGraphCollections(user: User, organization: Organization): Promise { + if (!(await userHasPermissionNew(user.userId, organization.organizationId, [Permission.VIEW_GRAPH_COLLECTION]))) { + throw new AccessDeniedException('You do not have permission to view graph collections'); + } + + let graphCollections = await prisma.graph_Collection.findMany({ + where: { + dateDeleted: null, + organizationId: organization.organizationId + }, + ...getGraphCollectionQueryArgs(organization.organizationId) + }); + + // Prisma does not support the kind of filtering we need natively, so do it after the query based on permissions + graphCollections = graphCollections.filter((graphCollection) => + isSubset(graphCollection.viewPermissions, user.additionalPermissions) + ); + + return Promise.all( + graphCollections.map(async (graphCollection) => { + const addedDataGraphs: (Prisma.GraphGetPayload & { graphData: GraphData[] })[] = await Promise.all( + graphCollection.graphs.map(async (graph) => ({ + ...graph, + graphData: await getGraphData( + graph.graphType, + graph.measure, + organization.organizationId, + graph.startDate ?? null, + graph.endDate ?? null, + { + carIds: graph.cars.map((car) => { + return car.carId; + }) + } + ) + })) + ); + + return graphCollectionTransformer(graphCollection, addedDataGraphs); + }) + ); + } + + static async createGraphCollection( + user: User, + title: string, + specialPermissions: Special_Permission[], + organization: Organization + ) { + if (!(await userHasPermissionNew(user.userId, organization.organizationId, [Permission.CREATE_GRAPH_COLLECTION]))) { + throw new AccessDeniedException('You do not have permission to create graph collections'); + } + + if (!isUnderWordCount(title, 20)) { + throw new HttpException(400, 'Title must be less than 20 words'); + } + + const graphCollection = await prisma.graph_Collection.create({ + data: { + organizationId: organization.organizationId, + title, + viewPermissions: specialPermissions, + userCreatedId: user.userId + }, + ...getGraphCollectionQueryArgs(organization.organizationId) + }); + + return graphCollectionTransformer( + graphCollection, + await Promise.all( + graphCollection.graphs.map(async (graph) => { + return { + ...graph, + graphData: await getGraphData( + graph.graphType, + graph.measure, + organization.organizationId, + graph.startDate ?? null, + graph.endDate ?? null, + { + carIds: graph.cars.map((car) => { + return car.carId; + }) + } + ) + }; + }) + ) + ); + } + + static async getSingleGraphCollection(user: User, graphCollectionId: string, organization: Organization) { + const requestedGraphCollection = await getGraphCollectionAndVerifyPermissions(user, graphCollectionId, organization); + + return graphCollectionTransformer( + requestedGraphCollection, + await Promise.all( + requestedGraphCollection.graphs.map(async (graph) => { + return { + ...graph, + graphData: await getGraphData( + graph.graphType, + graph.measure, + organization.organizationId, + graph.startDate ?? null, + graph.endDate ?? null, + { + carIds: graph.cars.map((car) => { + return car.carId; + }) + } + ) + }; + }) + ) + ); + } + + static async editGraphCollection( + user: User, + graphCollectionId: string, + title: string, + specialPermission: Special_Permission[], + organization: Organization + ) { + if (!(await userHasPermissionNew(user.userId, organization.organizationId, [Permission.EDIT_GRAPH_COLLECTION]))) { + throw new AccessDeniedException('You do not have permission to edit graph collections'); + } + + if (!isUnderWordCount(title, 20)) { + throw new HttpException(400, 'Title must be less than 20 words'); + } + + const graphCollection = await getGraphCollectionAndVerifyPermissions(user, graphCollectionId, organization); + + const updatedCollection = await prisma.graph_Collection.update({ + where: { + id: graphCollection.id + }, + data: { + viewPermissions: specialPermission, + title + }, + ...getGraphCollectionQueryArgs(organization.organizationId) + }); + + return graphCollectionTransformer( + updatedCollection, + await Promise.all( + updatedCollection.graphs.map(async (graph) => { + return { + ...graph, + graphData: await getGraphData( + graph.graphType, + graph.measure, + organization.organizationId, + graph.startDate ?? null, + graph.endDate ?? null, + { + carIds: graph.cars.map((car) => { + return car.carId; + }) + } + ) + }; + }) + ) + ); + } } diff --git a/src/backend/src/services/tasks.services.ts b/src/backend/src/services/tasks.services.ts index b4ce1aa842..103f3edc6f 100644 --- a/src/backend/src/services/tasks.services.ts +++ b/src/backend/src/services/tasks.services.ts @@ -18,11 +18,11 @@ export default class TasksService { * @param wbsNum the WBS Number to create the task for * @param title the title of the tas * @param notes the notes of the task - * @param deadline the deadline of the task * @param priority the priority of the task * @param status the status of the task * @param assignees the assignees ids of the task * @param organizationId the organization that the user is currently in + * @param deadline the deadline of the task * @returns the id of the successfully created task * @throws if the user does not have access to create a task, wbs element does not exist, or wbs element is deleted */ @@ -31,11 +31,11 @@ export default class TasksService { wbsNum: WbsNumber, title: string, notes: string, - deadline: Date, priority: Task_Priority, status: Task_Status, assignees: string[], - organization: Organization + organization: Organization, + deadline?: Date ): Promise { const requestedWbsElement = await prisma.wBS_Element.findUnique({ where: { @@ -94,6 +94,10 @@ export default class TasksService { if (!isUnderWordCount(title, 15)) throw new HttpException(400, 'Title must be less than 15 words'); if (!isUnderWordCount(notes, 250)) throw new HttpException(400, 'Notes must be less than 250 words'); + if (status === 'IN_PROGRESS' && (!deadline || assignees.length === 0)) { + throw new HttpException(400, 'Tasks in progress must have a dealine and assignees'); + } + const createdTask = await prisma.task.create({ data: { wbsElement: { @@ -165,10 +169,14 @@ export default class TasksService { */ static async editTaskStatus(user: User, taskId: string, status: Task_Status) { // Get the original task and check if it exists - const originalTask = await prisma.task.findUnique({ where: { taskId }, include: { wbsElement: true } }); + const originalTask = await prisma.task.findUnique({ where: { taskId }, include: { assignees: true, wbsElement: true } }); if (!originalTask) throw new NotFoundException('Task', taskId); if (originalTask.dateDeleted) throw new DeletedException('Task', taskId); + if (status === 'IN_PROGRESS' && (!originalTask.deadline || originalTask.assignees.length === 0)) { + throw new HttpException(400, 'A task in progress must have a deadline and assignees!'); + } + const hasPermission = await hasPermissionToEditTask(user, taskId); if (!hasPermission) throw new AccessDeniedException( diff --git a/src/backend/src/transformers/statistics-graph.transformer.ts b/src/backend/src/transformers/statistics-graph.transformer.ts index 9f7fc1fc0d..1abf40993e 100644 --- a/src/backend/src/transformers/statistics-graph.transformer.ts +++ b/src/backend/src/transformers/statistics-graph.transformer.ts @@ -1,5 +1,5 @@ import { Prisma } from '@prisma/client'; -import { Graph, GraphData, GraphDisplayType, GraphType, SpecialPermission } from 'shared'; +import { Graph, GraphData, GraphDisplayType, GraphType, Measure, SpecialPermission } from 'shared'; import { userTransformer } from './user.transformer'; import { GraphQueryArgs } from '../prisma-query-args/statistics.query-args'; @@ -9,12 +9,14 @@ const graphTransformer = (graph: Prisma.GraphGetPayload & { grap ...graph, graphType: graph.graphType as GraphType, graphDisplayType: graph.displayGraphType as GraphDisplayType, + measure: graph.measure as Measure, userCreated: userTransformer(graph.userCreated), userDeleted: graph.userDeleted ? userTransformer(graph.userDeleted) : undefined, dateDeleted: graph.dateDeleted ?? undefined, graphCollectionId: graph.graphCollectionId ?? undefined, startDate: graph.startDate ?? undefined, endDate: graph.endDate ?? undefined, + carIds: graph.cars.map((car) => car.carId), specialPermissions: graph.specialPermissions as SpecialPermission[] }; }; diff --git a/src/backend/src/transformers/statistics-graphCollection.transformer.ts b/src/backend/src/transformers/statistics-graphCollection.transformer.ts new file mode 100644 index 0000000000..fbe35e2fe7 --- /dev/null +++ b/src/backend/src/transformers/statistics-graphCollection.transformer.ts @@ -0,0 +1,24 @@ +import { Prisma } from '@prisma/client'; +import { userTransformer } from './user.transformer'; +import { GraphCollectionQueryArgs, GraphQueryArgs } from '../prisma-query-args/statistics.query-args'; +import { GraphCollection, GraphData, Graph, SpecialPermission } from 'shared'; +import graphTransformer from './statistics-graph.transformer'; + +export const graphCollectionTransformer = ( + graphCollection: Prisma.Graph_CollectionGetPayload, + graphs: (Prisma.GraphGetPayload & { graphData: GraphData[] })[] +): GraphCollection => { + return { + ...graphCollection, + userCreated: userTransformer(graphCollection.userCreated), + userDeleted: graphCollection.userDeleted ? userTransformer(graphCollection.userDeleted) : undefined, + dateDeleted: graphCollection.dateDeleted ?? undefined, + graphs: graphs.map((graph) => { + return graphTransformer({ ...graph, graphData: graph.graphData }); + }) as Graph[], + title: graphCollection.title, + linkId: graphCollection.id, + permissions: graphCollection.viewPermissions as SpecialPermission[], + dateCreated: graphCollection.dateCreated + }; +}; diff --git a/src/backend/src/transformers/tasks.transformer.ts b/src/backend/src/transformers/tasks.transformer.ts index de4616cfb8..b226e853dc 100644 --- a/src/backend/src/transformers/tasks.transformer.ts +++ b/src/backend/src/transformers/tasks.transformer.ts @@ -12,7 +12,7 @@ const taskTransformer = (task: Prisma.TaskGetPayload): Task => { wbsNum, title: task.title, notes: task.notes, - deadline: task.deadline, + deadline: task.deadline ?? undefined, priority: convertTaskPriority(task.priority), status: convertTaskStatus(task.status), createdBy: userTransformer(task.createdBy), diff --git a/src/backend/src/utils/errors.utils.ts b/src/backend/src/utils/errors.utils.ts index 6523d29ebe..fc6bafe0f7 100644 --- a/src/backend/src/utils/errors.utils.ts +++ b/src/backend/src/utils/errors.utils.ts @@ -139,4 +139,5 @@ export type ExceptionObjectNames = | 'Car' | 'Milestone' | 'Faq' - | 'Graph'; + | 'Graph' + | 'Graph Collection'; diff --git a/src/backend/src/utils/google-integration.utils.ts b/src/backend/src/utils/google-integration.utils.ts index cbb8a90f60..dfc6c45948 100644 --- a/src/backend/src/utils/google-integration.utils.ts +++ b/src/backend/src/utils/google-integration.utils.ts @@ -88,6 +88,11 @@ export const uploadFile = async (fileObject: Express.Multer.File) => { const bufferStream = new stream.PassThrough(); bufferStream.end(fileObject.buffer); + if (fileObject.filename.length > 20) throw new HttpException(400, 'File name can only be at most 20 characters long'); + //The regex /^[\w.]+$/ limits the file name to the set of alphanumeric characters (\w) and dots (for file type) + if (!/^[\w.]+$/.test(fileObject.filename)) + throw new HttpException(400, 'File name should only contain letters and numbers'); + oauth2Client.setCredentials({ refresh_token: DRIVE_REFRESH_TOKEN }); diff --git a/src/backend/src/utils/statistics.utils.ts b/src/backend/src/utils/statistics.utils.ts index 0915142c57..c2f8b396e7 100644 --- a/src/backend/src/utils/statistics.utils.ts +++ b/src/backend/src/utils/statistics.utils.ts @@ -1,6 +1,9 @@ -import { Graph_Type, Measure, Prisma } from '@prisma/client'; -import { GraphData, wbsPipe, wbsNamePipe } from 'shared'; +import { Graph_Type, Measure, Organization, Prisma, User } from '@prisma/client'; +import { GraphData, wbsPipe, wbsNamePipe, Permission } from 'shared'; import prisma from '../prisma/prisma'; +import { getGraphCollectionQueryArgs } from '../prisma-query-args/statistics.query-args'; +import { AccessDeniedException, DeletedException, InvalidOrganizationException, NotFoundException } from './errors.utils'; +import { userHasPermissionNew } from './users.utils'; interface CarSegmentedData { carIds: string[]; @@ -102,7 +105,7 @@ export const getGraphDataForProjectBudgetByTeam = async ( return prev + curr.budget; }, 0); - if (measure === Measure.AVG) { + if (measure === Measure.AVG && team.projects.length > 0) { value = value / team.projects.length; } @@ -153,7 +156,7 @@ export const getGraphDataForProjectBudgetByDivision = async ( ); }, 0); - if (measure === Measure.AVG) { + if (measure === Measure.AVG && numProjects > 0) { value = value / numProjects; } @@ -276,7 +279,7 @@ export const getGraphDataForChangeRequestsByTeam = async ( return prev + curr.wbsElement.changeRequests.length + workPackageChangeRequests; }, 0); - if (measure === Measure.AVG) { + if (measure === Measure.AVG && team.projects.length > 0) { value = value / team.projects.length; } @@ -325,7 +328,7 @@ export const getGraphDataForChangeRequestsByDivision = async ( ); }, 0); - if (measure === Measure.AVG) { + if (measure === Measure.AVG && numProjects > 0) { value = value / numProjects; } @@ -394,7 +397,7 @@ export const getGraphDataForReimbursementRequestsByProject = async ( return prev + (curr.reimbursementProduct?.cost ?? 0); }, 0); - if (measure === Measure.AVG) { + if (measure === Measure.AVG && project.wbsElement.reimbursementProductReasons.length > 0) { value = value / project.wbsElement.reimbursementProductReasons.length; } @@ -448,7 +451,7 @@ export const getGraphDataForReimbursementRequestsByTeam = async ( ); }, 0); - if (measure === Measure.AVG) { + if (measure === Measure.AVG && team.projects.length > 0) { value = value / team.projects.length; } @@ -498,7 +501,7 @@ export const getGraphDataForReimbursementRequestsByDivision = async ( ); }, 0); - if (measure === Measure.AVG) { + if (measure === Measure.AVG && division.teams.length > 0) { value = value / division.teams.length; } @@ -540,3 +543,29 @@ export const getGraphData = ( return getGraphDataForReimbursementRequestsByDivision(measure, organizationId, startDate, endDate, params); } }; + +export const getGraphCollectionAndVerifyPermissions = async ( + user: User, + graphCollectionId: string, + organization: Organization +) => { + const requestedGraphCollection = await prisma.graph_Collection.findUnique({ + where: { id: graphCollectionId, organizationId: organization.organizationId }, + ...getGraphCollectionQueryArgs(organization.organizationId) + }); + + if (!requestedGraphCollection) throw new NotFoundException('Graph Collection', graphCollectionId); + if (requestedGraphCollection.dateDeleted) throw new DeletedException('Graph', graphCollectionId); + if (requestedGraphCollection.organizationId !== organization.organizationId) + throw new InvalidOrganizationException('Graph'); + if ( + !(await userHasPermissionNew(user.userId, organization.organizationId, [ + ...requestedGraphCollection.viewPermissions, + Permission.VIEW_GRAPH + ])) + ) { + throw new AccessDeniedException('You do not have permission to view graphs'); + } + + return requestedGraphCollection; +}; diff --git a/src/backend/src/utils/users.utils.ts b/src/backend/src/utils/users.utils.ts index a81cb5da94..3bebbdc359 100644 --- a/src/backend/src/utils/users.utils.ts +++ b/src/backend/src/utils/users.utils.ts @@ -1,7 +1,15 @@ import { Prisma, User, User_Settings } from '@prisma/client'; import prisma from '../prisma/prisma'; import { HttpException, InvalidOrganizationException, NotFoundException } from './errors.utils'; -import { AvailabilityCreateArgs, getPermissionsForRoleType, isSameDay, PermissionCheck, Role, RoleEnum } from 'shared'; +import { + AvailabilityCreateArgs, + getPermissionsForRoleType, + isSameDay, + isSubset, + PermissionCheck, + Role, + RoleEnum +} from 'shared'; import { UserWithId } from './teams.utils'; import { UserScheduleSettingsQueryArgs } from '../prisma-query-args/user.query-args'; @@ -111,7 +119,7 @@ const getUserWithPermissions = async (userId: string, organizationId: string): P export const userHasPermissionNew = async (userId: string, organizationId: string, permissionsToCheckFor: string[]) => { const user = await getUserWithPermissions(userId, organizationId); - return user.permissions.some((perm) => permissionsToCheckFor.includes(perm)); + return isSubset(permissionsToCheckFor, user.permissions); }; export const userHasPermission = async ( diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index ce2716f028..f6bfb7961d 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -4,6 +4,8 @@ import { Organization, Project, Schedule_Settings, + Task_Priority, + Task_Status, User, User_Secure_Settings, User_Settings, @@ -31,7 +33,7 @@ export interface CreateTestUserParams { emailId?: string | null; googleAuthId: string; role: RoleEnum; - permissions?: Permission[]; + permissions?: string[]; } export const createTestUser = async ( @@ -518,3 +520,47 @@ export const createTestTeam = async (headId?: string, divId?: string, orgId?: st return team; }; + +export const createTestTask = async ( + user: User, + title: string, + notes: string, + assignees: User[], + priority: Task_Priority, + status: Task_Status, + organizationId?: string, + deadline?: Date +) => { + if (!organizationId) organizationId = (await createTestOrganization().then((org) => org.organizationId)) as string; + const task = await prisma.task.create({ + data: { + taskId: '0000000001', + title, + notes, + deadline, + assignees: { + connect: assignees.map((user) => ({ userId: user.userId })) + }, + priority, + status, + dateCreated: new Date(), + createdBy: { + connect: { userId: user.userId } + }, + wbsElement: { + create: { + carNumber: 0, + projectNumber: 0, + workPackageNumber: 0, + dateCreated: new Date('01/01/2023'), + name: 'Car', + status: WBS_Element_Status.INACTIVE, + leadId: user.userId, + managerId: user.userId, + organizationId + } + } + } + }); + return task; +}; diff --git a/src/backend/tests/unmocked/statistics.test.ts b/src/backend/tests/unmocked/statistics.test.ts index b4ab413a18..ce18b61b44 100644 --- a/src/backend/tests/unmocked/statistics.test.ts +++ b/src/backend/tests/unmocked/statistics.test.ts @@ -1,5 +1,5 @@ -import { Graph_Display_Type, Graph_Type, Organization, User } from '@prisma/client'; -import { supermanAdmin, wonderwomanGuest } from '../test-data/users.test-data'; +import { Graph_Type, Organization, User, Graph_Display_Type, Special_Permission } from '@prisma/client'; +import { batmanAppAdmin, supermanAdmin, theVisitorGuest, wonderwomanGuest } from '../test-data/users.test-data'; import { createTestCar, createTestOrganization, @@ -11,13 +11,14 @@ import { } from '../test-utils'; import StatisticsService from '../../src/services/statistics.services'; import { AccessDeniedException, HttpException, NotFoundException } from '../../src/utils/errors.utils'; -import { Measure } from 'shared'; +import { Graph, Measure, SpecialPermission } from 'shared'; +import prisma from '../../src/prisma/prisma'; describe('Statistics Tests', () => { let orgId: string; let organization: Organization; let user: User; - + let graph: Graph; let expectedCreatedGraphBase: any; beforeEach(async () => { @@ -31,6 +32,18 @@ describe('Statistics Tests', () => { userDeletedId: null, organizationId: orgId }; + graph = await StatisticsService.createGraph( + user, + 'New Graph', + Graph_Type.PROJECT_BUDGET_BY_TEAM, + Measure.SUM, + Graph_Display_Type.BAR, + organization, + [], + [], + new Date(), + new Date(new Date().getTime() + 10000) + ); }); afterEach(async () => { @@ -301,4 +314,215 @@ describe('Statistics Tests', () => { ); }); }); + + describe('Get all graph collections', () => { + it('Succeeds and gets all the graphs the user has permission to see without permissions', async () => { + const graph1 = await prisma.graph.create({ + data: { + title: 'graph1', + graphType: Graph_Type.CHANGE_REQUESTS_BY_DIVISION, + displayGraphType: Graph_Display_Type.BAR, + measure: Measure.AVG, + userCreatedId: user.userId, + organizationId: orgId + } + }); + + const graph2 = await prisma.graph.create({ + data: { + title: 'graph2', + graphType: Graph_Type.PROJECT_BUDGET_BY_PROJECT, + displayGraphType: Graph_Display_Type.PIE, + measure: Measure.SUM, + userCreatedId: user.userId, + organizationId: orgId + } + }); + + const graphCollection1 = await prisma.graph_Collection.create({ + data: { + title: 'Graph Collection 1', + viewPermissions: [], + graphs: { + connect: [{ id: graph1.id }, { id: graph2.id }] + }, + userCreatedId: user.userId, + organizationId: orgId + } + }); + + await prisma.graph_Collection.create({ + data: { + title: 'Graph Collection 2', + viewPermissions: [SpecialPermission.FINANCE_ONLY], + graphs: { + connect: [{ id: graph1.id }, { id: graph2.id }] + }, + userCreatedId: user.userId, + organizationId: orgId + } + }); + + const result = await StatisticsService.getAllGraphCollections(user, organization); + expect(result[0].userCreated.userId).toBe(user.userId); + expect(result.length).toBe(1); + expect( + result.map((graphCol) => { + return graphCol.id; + }) + ).toEqual([graphCollection1.id]); + }); + + it('Succeeds and gets all the graphs the user has permission to see with permissions', async () => { + user = await createTestUser({ ...batmanAppAdmin, permissions: [Special_Permission.FINANCE_ONLY] }, orgId); + const graph1 = await prisma.graph.create({ + data: { + title: 'graph1', + graphType: Graph_Type.CHANGE_REQUESTS_BY_DIVISION, + displayGraphType: Graph_Display_Type.BAR, + measure: Measure.AVG, + userCreatedId: user.userId, + organizationId: orgId + } + }); + + const graph2 = await prisma.graph.create({ + data: { + title: 'graph2', + graphType: Graph_Type.PROJECT_BUDGET_BY_PROJECT, + displayGraphType: Graph_Display_Type.PIE, + measure: Measure.SUM, + userCreatedId: user.userId, + organizationId: orgId + } + }); + + const graphCollection1 = await prisma.graph_Collection.create({ + data: { + title: 'Graph Collection 1', + viewPermissions: [], + graphs: { + connect: [{ id: graph1.id }, { id: graph2.id }] + }, + userCreatedId: user.userId, + organizationId: orgId + } + }); + + const graphCollection2 = await prisma.graph_Collection.create({ + data: { + title: 'Graph Collection 2', + viewPermissions: [SpecialPermission.FINANCE_ONLY], + graphs: { + connect: [{ id: graph1.id }, { id: graph2.id }] + }, + userCreatedId: user.userId, + organizationId: orgId + } + }); + + const result = await StatisticsService.getAllGraphCollections(user, organization); + expect(result[0].userCreated.userId).toBe(user.userId); + expect(result.length).toBe(2); + expect( + result.map((graphCol) => { + return graphCol.id; + }) + ).toEqual([graphCollection1.id, graphCollection2.id]); + }); + }); + + describe('Edit Graph', () => { + it('Edit graph correctly updates startDate, endDate, title, and graphType', async () => { + const updatedStartDate = new Date('12/13/2024'); + const updatedEndDate = new Date(updatedStartDate.getTime() + 10000); + const updatedTitle = 'Updated Graph'; + const updatedGraphType = Graph_Type.PROJECT_BUDGET_BY_PROJECT; + const updatedMeasure = Measure.AVG; + const updatedGraphDisplayType = Graph_Display_Type.PIE; + const car = await createTestCar(organization.organizationId, user.userId); + + const updatedGraph = await StatisticsService.editGraph( + user, + graph.graphId, + updatedTitle, + updatedGraphType, + updatedMeasure, + updatedGraphDisplayType, + organization, + [car.carId], + ['FINANCE_ONLY'], + updatedStartDate, + updatedEndDate + ); + + expect(updatedGraph.startDate).toStrictEqual(updatedStartDate); + expect(updatedGraph.endDate).toStrictEqual(updatedEndDate); + expect(updatedGraph.title).toStrictEqual(updatedTitle); + expect(updatedGraph.graphType).toStrictEqual(updatedGraphType); + expect(updatedGraph.carIds).toStrictEqual([car.carId]); + expect(updatedGraph.graphDisplayType).toStrictEqual(updatedGraphDisplayType); + expect(updatedGraph.specialPermissions).toStrictEqual(['FINANCE_ONLY']); + expect(updatedGraph.measure).toStrictEqual(updatedGraph.measure); + }); + + it('Edit graph fails if the graph id is invalid', async () => { + const invalidGraphId = 'foobar'; + await expect( + async () => + await StatisticsService.editGraph( + user, + invalidGraphId, + 'New Graph', + Graph_Type.PROJECT_BUDGET_BY_DIVISION, + Measure.SUM, + Graph_Display_Type.BAR, + organization, + [], + [] + ) + ).rejects.toThrow(new NotFoundException('Graph', invalidGraphId)); + }); + + it('Edit graph fails if editing user does not have edit graph permissions', async () => { + const userEditing = await createTestUser(theVisitorGuest, orgId); + await expect( + async () => + await StatisticsService.editGraph( + userEditing, + graph.graphId, + 'New Graph', + Graph_Type.PROJECT_BUDGET_BY_DIVISION, + Measure.SUM, + Graph_Display_Type.BAR, + organization, + [], + [] + ) + ).rejects.toThrow(new AccessDeniedException('You do not have permission to edit a graph')); + }); + + it('Edit graph fails if graph is deleted', async () => { + // Todo - Implement deleting graphs before testing for this + }); + + it('Throws if end date is before start date', async () => { + await expect( + async () => + await StatisticsService.editGraph( + user, + graph.graphId, + 'New Graph', + Graph_Type.PROJECT_BUDGET_BY_DIVISION, + Measure.SUM, + Graph_Display_Type.BAR, + organization, + [], + [], + new Date(), + new Date(new Date().getTime() - 10000) + ) + ).rejects.toThrow(new HttpException(400, 'End date must be after start date')); + }); + }); }); diff --git a/src/backend/tests/unmocked/task.test.ts b/src/backend/tests/unmocked/task.test.ts new file mode 100644 index 0000000000..f7c971d4fa --- /dev/null +++ b/src/backend/tests/unmocked/task.test.ts @@ -0,0 +1,62 @@ +import { financeMember, supermanAdmin } from '../test-data/users.test-data'; +import { HttpException } from '../../src/utils/errors.utils'; +import { createTestOrganization, createTestTask, createTestUser, resetUsers } from '../test-utils'; +import prisma from '../../src/prisma/prisma'; +import TasksService from '../../src/services/tasks.services'; + +describe('Task Test', () => { + let organizationId: string; + beforeEach(async () => { + ({ organizationId } = await createTestOrganization()); + }); + + afterEach(async () => { + 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, correctTask.taskId, 'IN_PROGRESS'); + const updatedTask = await prisma.task.findUnique({ + where: { + taskId: correctTask.taskId + } + }); + // check that status changed to correct status + expect(updatedTask?.status).toBe('IN_PROGRESS'); + }); + + test('Setting status to in progress does not work when task does not have a deadline and assignees', async () => { + const user = await createTestUser(supermanAdmin, organizationId); + const badTask = await createTestTask(user, 'Test', '', [], 'HIGH', 'DONE', organizationId); + await expect(async () => + TasksService.editTaskStatus(await createTestUser(financeMember, organizationId), 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), 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), badTask.taskId, 'IN_PROGRESS') + ).rejects.toThrow(new HttpException(400, 'A task in progress must have a deadline and assignees!')); + }); +}); diff --git a/src/frontend/src/apis/statistics.api.ts b/src/frontend/src/apis/statistics.api.ts index a350de2f81..b6f64acf55 100644 --- a/src/frontend/src/apis/statistics.api.ts +++ b/src/frontend/src/apis/statistics.api.ts @@ -1,7 +1,46 @@ -import { CreateGraphArgs } from 'shared'; +import { CreateGraphArgs, Graph, GraphCollection, GraphCollectionFormInput } from 'shared'; import axios from '../utils/axios'; import { apiUrls } from '../utils/urls'; +import { graphCollectionTransformer, graphTransformer } from './transformers/statistics.transformers'; export const createGraph = (payload: CreateGraphArgs) => { - return axios.post(apiUrls.createGraph(), payload); + return axios.post(apiUrls.createGraph(), payload, { + transformResponse: (data) => graphTransformer(JSON.parse(data)) + }); +}; + +export const getAllGraphCollections = () => { + return axios.get(apiUrls.graphCollections(), { + transformResponse: (data) => JSON.parse(data).map(graphCollectionTransformer) + }); +}; + +export const getSingleGraphCollection = (id: string) => { + return axios.get(apiUrls.graphCollectionById(id), { + transformResponse: (data) => graphCollectionTransformer(JSON.parse(data)) + }); +}; + +export const createGraphCollection = (payload: GraphCollectionFormInput) => { + return axios.post(apiUrls.createGraphCollection(), payload, { + transformResponse: (data) => graphCollectionTransformer(JSON.parse(data)) + }); +}; + +export const updateGraph = (id: string, payload: CreateGraphArgs) => { + return axios.post(apiUrls.updateGraph(id), payload, { + transformResponse: (data) => graphTransformer(JSON.parse(data)) + }); +}; + +export const getSingleGraph = (id: string) => { + return axios.get(apiUrls.getGraphById(id), { + transformResponse: (data) => graphTransformer(JSON.parse(data)) + }); +}; + +export const updateGraphCollection = (id: string, payload: GraphCollectionFormInput) => { + return axios.post(apiUrls.updateGraphCollection(id), payload, { + transformResponse: (data) => graphCollectionTransformer(JSON.parse(data)) + }); }; diff --git a/src/frontend/src/apis/tasks.api.ts b/src/frontend/src/apis/tasks.api.ts index ee27650e70..e4e0fae176 100644 --- a/src/frontend/src/apis/tasks.api.ts +++ b/src/frontend/src/apis/tasks.api.ts @@ -21,11 +21,11 @@ import { taskTransformer } from './transformers/tasks.transformers'; export const createSingleTask = ( wbsNum: WbsNumber, title: string, - deadline: string, priority: TaskPriority, status: TaskStatus, assignees: string[], - notes: string + notes: string, + deadline?: string ) => { return axios.post( apiUrls.tasksCreate(wbsPipe(wbsNum)), @@ -53,7 +53,7 @@ export const createSingleTask = ( * @param assignees the new assignees * @returns the edited task */ -export const editTask = (taskId: string, title: string, notes: string, priority: TaskPriority, deadline: Date) => { +export const editTask = (taskId: string, title: string, notes: string, priority: TaskPriority, deadline?: Date) => { return axios.post<{ message: string }>(apiUrls.editTaskById(taskId), { title, notes, diff --git a/src/frontend/src/apis/transformers/statistics.transformers.ts b/src/frontend/src/apis/transformers/statistics.transformers.ts new file mode 100644 index 0000000000..cad181fdda --- /dev/null +++ b/src/frontend/src/apis/transformers/statistics.transformers.ts @@ -0,0 +1,24 @@ +import { Graph, GraphCollection } from 'shared'; +import { userTransformer } from './users.transformers'; + +export const graphTransformer = (graph: Graph): Graph => { + return { + ...graph, + userCreated: userTransformer(graph.userCreated), + userDeleted: graph.userDeleted ? userTransformer(graph.userDeleted) : undefined, + startDate: graph.startDate ? new Date(graph.startDate) : undefined, + endDate: graph.endDate ? new Date(graph.endDate) : undefined, + dateDeleted: graph.dateDeleted ? new Date(graph.dateDeleted) : undefined + }; +}; + +export const graphCollectionTransformer = (graphCollection: GraphCollection): GraphCollection => { + return { + ...graphCollection, + userCreated: userTransformer(graphCollection.userCreated), + userDeleted: graphCollection.userDeleted ? userTransformer(graphCollection.userDeleted) : undefined, + dateDeleted: graphCollection.dateDeleted ? new Date(graphCollection.dateDeleted) : undefined, + dateCreated: new Date(graphCollection.dateCreated), + graphs: graphCollection.graphs.map(graphTransformer) + }; +}; diff --git a/src/frontend/src/apis/transformers/tasks.transformers.ts b/src/frontend/src/apis/transformers/tasks.transformers.ts index 16454198bf..0d65123e05 100644 --- a/src/frontend/src/apis/transformers/tasks.transformers.ts +++ b/src/frontend/src/apis/transformers/tasks.transformers.ts @@ -11,6 +11,6 @@ export const taskTransformer = (task: Task): Task => { ...task, dateCreated: new Date(task.dateCreated), dateDeleted: task.dateDeleted ? new Date(task.dateDeleted) : undefined, - deadline: new Date(task.deadline) + deadline: task.deadline ? new Date(task.deadline) : undefined }; }; diff --git a/src/frontend/src/components/GraphCollectionCard.tsx b/src/frontend/src/components/GraphCollectionCard.tsx new file mode 100644 index 0000000000..c245545d89 --- /dev/null +++ b/src/frontend/src/components/GraphCollectionCard.tsx @@ -0,0 +1,46 @@ +import { Card, CardContent, Grid, Link, Typography } from '@mui/material'; +import { GraphCollection } from 'shared'; +import { datePipe, displayEnum, fullNamePipe } from '../utils/pipes'; +import { Link as RouterLink } from 'react-router-dom'; +import { Construction } from '@mui/icons-material'; +import { DateRangeIcon } from '@mui/x-date-pickers'; + +interface GraphCollectionCardProps { + graphCollection: GraphCollection; +} + +const GraphCollectionCard = ({ graphCollection }: GraphCollectionCardProps) => { + return ( + + + + + + {graphCollection.title} + + + + {fullNamePipe(graphCollection.userCreated)} + + + {datePipe(graphCollection.dateCreated)} + + + + Graphs: + + {graphCollection.graphs.map((graph) => ( + + {`${graph.title} - ${displayEnum(graph.graphType)} (${ + graph.measure + })`} + + ))} + + + + + ); +}; + +export default GraphCollectionCard; diff --git a/src/frontend/src/components/NERFormModal.tsx b/src/frontend/src/components/NERFormModal.tsx index 9a5da7b722..27fd75cb40 100644 --- a/src/frontend/src/components/NERFormModal.tsx +++ b/src/frontend/src/components/NERFormModal.tsx @@ -6,6 +6,7 @@ interface NERFormModalProps extends NERModalProps { reset: UseFormReset; handleUseFormSubmit: UseFormHandleSubmit; onFormSubmit: (data: T) => void; + formId: string; children?: ReactNode; } diff --git a/src/frontend/src/components/PageLayout.tsx b/src/frontend/src/components/PageLayout.tsx index 861e363e5e..df9940a606 100644 --- a/src/frontend/src/components/PageLayout.tsx +++ b/src/frontend/src/components/PageLayout.tsx @@ -8,6 +8,7 @@ import React, { ReactNode, ReactElement } from 'react'; import PageTitle from '../layouts/PageTitle/PageTitle'; import { LinkItem } from '../utils/types'; import { Box } from '@mui/system'; +import PageBreadcrumbs from '../layouts/PageTitle/PageBreadcrumbs'; interface PageLayoutProps { children: ReactNode; @@ -36,8 +37,14 @@ const PageLayout: React.FC = ({ {`FinishLine ${title && `| ${title}`}`} + {!hidePageTitle && title && ( - + <> + + + + + )} {children} diff --git a/src/frontend/src/components/StatsBarChart.tsx b/src/frontend/src/components/StatsBarChart.tsx index 77f59ed864..04cadb5d28 100644 --- a/src/frontend/src/components/StatsBarChart.tsx +++ b/src/frontend/src/components/StatsBarChart.tsx @@ -1,6 +1,22 @@ import { Bar } from 'react-chartjs-2'; -import { Chart, CategoryScale, LinearScale, BarController, BarElement, Title, Tooltip, Legend } from 'chart.js'; +import { + Chart, + CategoryScale, + LinearScale, + BarController, + BarElement, + Title, + Tooltip, + Legend, + CoreChartOptions, + ElementChartOptions, + DatasetChartOptions, + PluginChartOptions, + BarControllerChartOptions, + ScaleChartOptions +} from 'chart.js'; import { Box } from '@mui/material'; +import { _DeepPartialObject } from 'chart.js/dist/types/utils'; Chart.register(CategoryScale, LinearScale, BarController, BarElement, Title, Tooltip, Legend); @@ -35,9 +51,16 @@ const StatsBarChart: React.FC = ({ ] }; - const options = { + const options: _DeepPartialObject< + CoreChartOptions<'bar'> & + ElementChartOptions<'bar'> & + PluginChartOptions<'bar'> & + DatasetChartOptions<'bar'> & + ScaleChartOptions<'bar'> & + BarControllerChartOptions + > = { responsive: true, - maintainAspectRatio: true, + maintainAspectRatio: false, plugins: { title: { display: true, @@ -48,8 +71,8 @@ const StatsBarChart: React.FC = ({ color: 'white' }, legend: { - display: true, - position: 'top' as const, + display: false, + position: 'bottom', labels: { font: { size: 14 @@ -93,7 +116,7 @@ const StatsBarChart: React.FC = ({ }; return ( - + ); diff --git a/src/frontend/src/components/StatsLineGraph.tsx b/src/frontend/src/components/StatsLineGraph.tsx index 068c454603..6d593dfe6a 100644 --- a/src/frontend/src/components/StatsLineGraph.tsx +++ b/src/frontend/src/components/StatsLineGraph.tsx @@ -8,9 +8,16 @@ import { PointElement, Title, Tooltip, - Legend + Legend, + CoreChartOptions, + ElementChartOptions, + DatasetChartOptions, + PluginChartOptions, + BarControllerChartOptions, + ScaleChartOptions } from 'chart.js'; import { Box } from '@mui/material'; +import { _DeepPartialObject } from 'chart.js/dist/types/utils'; Chart.register(CategoryScale, LinearScale, LineController, LineElement, PointElement, Title, Tooltip, Legend); @@ -46,9 +53,16 @@ const StatsLineGraph: React.FC = ({ ] }; - const options = { + const options: _DeepPartialObject< + CoreChartOptions<'line'> & + ElementChartOptions<'line'> & + PluginChartOptions<'line'> & + DatasetChartOptions<'line'> & + ScaleChartOptions<'line'> & + BarControllerChartOptions + > = { responsive: true, - maintainAspectRatio: true, + maintainAspectRatio: false, plugins: { title: { display: true, @@ -59,8 +73,8 @@ const StatsLineGraph: React.FC = ({ color: 'white' }, legend: { - display: true, - position: 'top' as const, + display: false, + position: 'bottom', labels: { font: { size: 14 @@ -105,7 +119,7 @@ const StatsLineGraph: React.FC = ({ }; return ( - + ); diff --git a/src/frontend/src/components/StatsPieChart.tsx b/src/frontend/src/components/StatsPieChart.tsx index dd3aacd29d..e24d915a60 100644 --- a/src/frontend/src/components/StatsPieChart.tsx +++ b/src/frontend/src/components/StatsPieChart.tsx @@ -1,6 +1,19 @@ import { Pie } from 'react-chartjs-2'; -import { Chart, ArcElement, Title, Tooltip, Legend } from 'chart.js'; +import { + Chart, + ArcElement, + Title, + Tooltip, + Legend, + CoreChartOptions, + ElementChartOptions, + PluginChartOptions, + DatasetChartOptions, + ScaleChartOptions, + BarControllerChartOptions +} from 'chart.js'; import { Box } from '@mui/material'; +import { _DeepPartialObject } from 'chart.js/dist/types/utils'; Chart.register(ArcElement, Title, Tooltip, Legend); @@ -51,9 +64,16 @@ const StatsPieChart: React.FC = ({ xAxisData, yAxisData, wid ] }; - const options = { + const options: _DeepPartialObject< + CoreChartOptions<'pie'> & + ElementChartOptions<'pie'> & + PluginChartOptions<'pie'> & + DatasetChartOptions<'pie'> & + ScaleChartOptions<'pie'> & + BarControllerChartOptions + > = { responsive: true, - maintainAspectRatio: true, + maintainAspectRatio: false, plugins: { title: { display: true, @@ -65,7 +85,7 @@ const StatsPieChart: React.FC = ({ xAxisData, yAxisData, wid }, legend: { display: true, - position: 'right' as const, + position: 'bottom', labels: { font: { size: 14 @@ -77,7 +97,7 @@ const StatsPieChart: React.FC = ({ xAxisData, yAxisData, wid }; return ( - + ); diff --git a/src/frontend/src/hooks/cars.hooks.ts b/src/frontend/src/hooks/cars.hooks.ts index 58e226929b..53b0f9c02f 100644 --- a/src/frontend/src/hooks/cars.hooks.ts +++ b/src/frontend/src/hooks/cars.hooks.ts @@ -16,6 +16,14 @@ export const useGetAllCars = () => { }); }; +//TODO Move this logic to backend +export const useGetCarsByIds = (ids: Set) => { + return useQuery(['cars'], async () => { + const { data } = await getAllCars(); + return data.filter((car) => ids.has(car.id)); + }); +}; + export const useCreateCar = () => { const queryClient = useQueryClient(); return useMutation( diff --git a/src/frontend/src/hooks/statistics.hooks.ts b/src/frontend/src/hooks/statistics.hooks.ts index d7c60b5c3e..91e3159a14 100644 --- a/src/frontend/src/hooks/statistics.hooks.ts +++ b/src/frontend/src/hooks/statistics.hooks.ts @@ -1,6 +1,14 @@ -import { useMutation } from 'react-query'; -import { CreateGraphArgs } from 'shared'; -import { createGraph } from '../apis/statistics.api'; +import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { CreateGraphArgs, Graph, GraphCollection, GraphCollectionFormInput } from 'shared'; +import { + createGraph, + createGraphCollection, + getAllGraphCollections, + getSingleGraph, + getSingleGraphCollection, + updateGraph, + updateGraphCollection +} from '../apis/statistics.api'; /** * Custom react hook to create a graph @@ -8,8 +16,105 @@ import { createGraph } from '../apis/statistics.api'; * @returns A mutation function that allows you to create a graph */ export const useCreateGraph = () => { - return useMutation([], async (args: CreateGraphArgs) => { + return useMutation([], async (args: CreateGraphArgs) => { const { data } = await createGraph(args); return data; }); }; + +/** + * Custom react hook to retrieve all graph collections + * + * @returns A query function that will contain the graph collections + */ +export const useGetAllGraphCollections = () => { + return useQuery(['graph-collections'], async () => { + const { data } = await getAllGraphCollections(); + return data; + }); +}; + +/** + * Custom react hook to get a single graph collection + * + * @param id The id of the graph collection to retrieve + * @returns Query function that wil contain the graph collection + */ +export const useGetSingleGraphCollection = (id: string) => { + return useQuery(['graph-collections', id], async () => { + const { data } = await getSingleGraphCollection(id); + return data; + }); +}; + +/** + * Custom react hook to create a graph collection + * + * @returns Mutation function that takes in form input to create a graph collection + */ +export const useCreateGraphCollection = () => { + const queryClient = useQueryClient(); + return useMutation( + [], + async (args: GraphCollectionFormInput) => { + const { data } = await createGraphCollection(args); + return data; + }, + { + onSuccess: () => queryClient.invalidateQueries('graph-collections') + } + ); +}; + +/** + * Custom react hook to retrieve a graph by id + * + * @param id The id of the graph to retrieve + * @returns Query function that contains the fetched graph + */ +export const useGetSingleGraph = (id: string) => { + return useQuery(['graph', id], async () => { + const { data } = await getSingleGraph(id); + return data; + }); +}; + +/** + * Custom react hook to update the given graph with the id + * + * @param id The id of the graph to update + * @returns Mutation function to update the graph, when completed contains the updated graph + */ +export const useUpdateGraph = (id: string) => { + const queryClient = useQueryClient(); + return useMutation( + [], + async (args: CreateGraphArgs) => { + const { data } = await updateGraph(id, args); + return data; + }, + { + onSuccess: () => queryClient.invalidateQueries(['graph-collections']) + } + ); +}; + +/** + * Custom react hook to update a graph collection + * + * @param id The id of the graph collection to update + * @returns Mutation function to update the graph collection with the given id + */ +export const useUpdateGraphCollection = (id: string) => { + const queryClient = useQueryClient(); + return useMutation( + [], + async (args: GraphCollectionFormInput) => { + const { data } = await updateGraphCollection(id, args); + return data; + }, + { + onSuccess: () => queryClient.invalidateQueries(['graph-collections', id]) + } + ); +}; diff --git a/src/frontend/src/hooks/tasks.hooks.ts b/src/frontend/src/hooks/tasks.hooks.ts index 4f87995ace..bc77d54af4 100644 --- a/src/frontend/src/hooks/tasks.hooks.ts +++ b/src/frontend/src/hooks/tasks.hooks.ts @@ -9,7 +9,7 @@ import { createSingleTask, deleteSingleTask, editSingleTaskStatus, editTask, edi export interface CreateTaskPayload { title: string; - deadline: string; + deadline?: string; priority: TaskPriority; status: TaskStatus; notes: string; @@ -24,11 +24,11 @@ export const useCreateTask = (wbsNum: WbsNumber) => { const { data } = await createSingleTask( wbsNum, createTaskPayload.title, - createTaskPayload.deadline, createTaskPayload.priority, createTaskPayload.status, createTaskPayload.assignees, - createTaskPayload.notes + createTaskPayload.notes, + createTaskPayload.deadline ); return data; }, @@ -44,7 +44,7 @@ export interface TaskPayload { taskId: string; notes: string; title: string; - deadline: Date; + deadline?: Date; priority: TaskPriority; } diff --git a/src/frontend/src/pages/CalendarPage/CalendarComponents/CalendarDayCard.tsx b/src/frontend/src/pages/CalendarPage/CalendarComponents/CalendarDayCard.tsx index 120cd4697c..5ff85f521c 100644 --- a/src/frontend/src/pages/CalendarPage/CalendarComponents/CalendarDayCard.tsx +++ b/src/frontend/src/pages/CalendarPage/CalendarComponents/CalendarDayCard.tsx @@ -178,6 +178,9 @@ const CalendarDayCard: React.FC = ({ cardDate, events, tea ); }; + const today = new Date().toDateString(); + const isCurrentDay = cardDate.toDateString() === today; + return ( <> = ({ cardDate, events, tea teamTypes={teamTypes} defaultDate={cardDate} /> - + {events.length < 3 ? ( diff --git a/src/frontend/src/pages/CalendarPage/DesignReviewDetailPage/DesignReviewDetailPage.tsx b/src/frontend/src/pages/CalendarPage/DesignReviewDetailPage/DesignReviewDetailPage.tsx index 5d35c691ba..c48d1fa196 100644 --- a/src/frontend/src/pages/CalendarPage/DesignReviewDetailPage/DesignReviewDetailPage.tsx +++ b/src/frontend/src/pages/CalendarPage/DesignReviewDetailPage/DesignReviewDetailPage.tsx @@ -103,6 +103,13 @@ const DesignReviewDetailPage: React.FC = ({ designR } }; + const handleSelectingRequiredUser = (newValue: { label: string; id: string }[]) => { + const newRequiredUserIds = new Set(newValue.map((user) => user.id)); + const filteredOptionalUsers = optionalUsers.filter((user) => !newRequiredUserIds.has(user.id)); + setOptionalUsers(filteredOptionalUsers); + setRequiredUsers(newValue); + }; + const handleEdit = async (data?: FinalizeReviewInformation) => { const times = []; for (let i = startTime; i < endTime; i++) { @@ -254,9 +261,9 @@ const DesignReviewDetailPage: React.FC = ({ designR limitTags={1} renderTags={() => null} id="required-users" - options={users.filter((user) => !optionalUsers.some((optUser) => optUser.id === user.id))} + options={users} value={requiredUsers} - onChange={(_event, newValue) => setRequiredUsers(newValue)} + onChange={(_event, newValue) => handleSelectingRequiredUser(newValue)} getOptionLabel={(option) => option.label} renderOption={(props, option, { selected }) => (
  • diff --git a/src/frontend/src/pages/CreditsPage/CreditsPage.tsx b/src/frontend/src/pages/CreditsPage/CreditsPage.tsx index 830d4c148e..8ff5392526 100644 --- a/src/frontend/src/pages/CreditsPage/CreditsPage.tsx +++ b/src/frontend/src/pages/CreditsPage/CreditsPage.tsx @@ -207,6 +207,7 @@ const CreditsPage: React.FC = () => { { name: 'Rup Jaisinghani', color: '#065535' }, { name: 'Jack Dreifus', color: '#014421' }, { name: 'Vinay Pillai', color: '#42458e' }, + { name: 'Aaron Zhou', color: '#BAB86C' }, { name: 'Benjamin Kataoka', color: '#38FF87' }, { name: 'Meggan Shvartsberg', color: '#00DBFF' }, { name: 'Visisht Kamalapuram', color: '#3083AA' }, @@ -257,6 +258,7 @@ const CreditsPage: React.FC = () => { { name: 'Aryan Gupta', color: '#5a4094' }, { name: 'Lisa Wan', color: '#CCCCFF' }, { name: 'Aidan Wong', color: '#4284f5' }, + { name: 'Joshua Sharma', color: '#50C878' }, { name: 'Sarah Taylor', color: '#278f4b' }, { name: 'Shrey Agarwal', color: '#800080' }, { name: 'Amber Friar', color: '#F5A9B8' } diff --git a/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementFormView.tsx b/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementFormView.tsx index ebff55046d..346075d63a 100644 --- a/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementFormView.tsx +++ b/src/frontend/src/pages/FinancePage/ReimbursementRequestForm/ReimbursementFormView.tsx @@ -367,15 +367,22 @@ const ReimbursementRequestFormView: React.FC onChange={(e) => { if (e.target.files) { [...e.target.files].forEach((file) => { - if (file.size < 1000000) { + /* The regex /^[\w.]+$/ limits the file name to the set of alphanumeric characters (\w) and dots (for file type) */ + if (file.size >= 1000000) { + toast.error(`Error uploading ${file.name}; file must be less than 1 MB`, 5000); + document.getElementById('receipt-image')!.innerHTML = ''; + } else if (file.name.length > 20) { + toast.error(`Error uploading ${file.name}; file name must be less than 20 characters`, 5000); + document.getElementById('receipt-image')!.innerHTML = ''; + } else if (!/^[\w.]+$/.test(file.name)) { + toast.error(`Error uploading ${file.name}; file name must only contain letter and numbers`, 5000); + document.getElementById('receipt-image')!.innerHTML = ''; + } else { receiptPrepend({ file, name: file.name, googleFileId: '' }); - } else { - toast.error(`Error uploading ${file.name}; file must be less than 1 MB`, 5000); - document.getElementById('receipt-image')!.innerHTML = ''; } }); } diff --git a/src/frontend/src/pages/GanttPage/GanttChartComponents/GanttChartColorLegend.tsx b/src/frontend/src/pages/GanttPage/GanttChartComponents/GanttChartColorLegend.tsx index 5f89c86327..3dcfb18d19 100644 --- a/src/frontend/src/pages/GanttPage/GanttChartComponents/GanttChartColorLegend.tsx +++ b/src/frontend/src/pages/GanttPage/GanttChartComponents/GanttChartColorLegend.tsx @@ -8,7 +8,7 @@ import { DesignReviewStatus, WbsElementStatus, WorkPackageStage } from 'shared'; import { GanttDesignReviewStatusColorPipe, GanttWorkPackageStageColorPipe, - GanttWorkPackageTextColorPipe + GanttWorkPackageTextColor } from '../../../utils/gantt.utils'; import { DesignReviewStatusTextPipe, WbsElementStatusTextPipe, WorkPackageStageTextPipe } from '../../../utils/enum-pipes'; @@ -41,7 +41,7 @@ Object.values(WorkPackageStage).map((stage) => alignItems: 'center' }} > - + {WbsElementStatusTextPipe(status)} @@ -135,7 +135,7 @@ const GanttChartColorLegend = () => { > {WorkPackageStageTextPipe(stage)} diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx index ef652d9e77..fb6fdd9600 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/TaskFormModal.tsx @@ -10,7 +10,7 @@ import NERFormModal from '../../../../components/NERFormModal'; const schema = yup.object().shape({ notes: yup.string(), - deadline: yup.date().required(), + deadline: yup.date().optional(), priority: yup.string().required(), assignees: yup.array(), title: yup.string().required() @@ -21,7 +21,7 @@ export interface EditTaskFormInput { title: string; notes: string; assignees: string[]; - deadline: Date; + deadline?: Date; priority: TaskPriority; } @@ -50,7 +50,7 @@ const TaskFormModal: React.FC = ({ task, onSubmit, modalShow title: task?.title ?? '', taskId: task?.taskId ?? '-1', notes: task?.notes ?? '', - deadline: task?.deadline ?? new Date(), + deadline: task?.deadline ?? undefined, priority: task?.priority ?? TaskPriority.Low, assignees: task?.assignees.map((assignee) => assignee.userId) ?? [] } @@ -155,7 +155,7 @@ const TaskFormModal: React.FC = ({ task, onSubmit, modalShow ( { - const deadLine1 = task1.deadline.getTime(); - const deadLine2 = task2.deadline.getTime(); - - if (deadLine1 !== deadLine2) return deadLine1 - deadLine2; - return task1.dateCreated.getTime() - task2.dateCreated.getTime(); + if (task1.deadline && task2.deadline) { + const deadLine1 = task1.deadline.getTime(); + const deadLine2 = task2.deadline.getTime(); + if (deadLine1 !== deadLine2) return deadLine1 - deadLine2; + return task1.dateCreated.getTime() - task2.dateCreated.getTime(); + } else if (task1.deadline && !task2.deadline) { + return 1; + } else if (task2.deadline && !task1.deadline) { + return -1; + } + return 0; }; // Page block containing task list view diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v1/TaskListDataGrid.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v1/TaskListDataGrid.tsx index bd0f24691d..44e133d1a6 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v1/TaskListDataGrid.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v1/TaskListDataGrid.tsx @@ -136,7 +136,7 @@ const TaskListDataGrid: React.FC = ({ icon={} label="Save" onClick={() => { - createTask(title, deadline, priority, assignees); + createTask(title, priority, assignees, deadline); deleteCreateTask(); }} /> @@ -184,7 +184,7 @@ const TaskListDataGrid: React.FC = ({ } label="Move to In Progress" - onClick={moveToInProgress(params.row.taskId)} + onClick={moveToInProgress(params.row.taskId, params.row.deadline, params.row.assignees)} showInMenu disabled={!editTaskPermissions(params.row.task)} /> diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v1/TaskListTabPanel.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v1/TaskListTabPanel.tsx index 246ed5dcec..df58b09bb5 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v1/TaskListTabPanel.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v1/TaskListTabPanel.tsx @@ -67,8 +67,12 @@ const TaskListTabPanel = (props: TaskListTabPanelProps) => { } }; - const moveToInProgress = (id: string) => async () => { + const moveToInProgress = (id: string, assignees: string, deadline?: Date) => async () => { try { + if (!deadline || assignees.length === 0) { + toast.error('A task must have a deadline and assignees to be in progress!'); + return; + } await editTaskStatus.mutateAsync({ taskId: id, status: TaskStatus.IN_PROGRESS }); } catch (e: unknown) { if (e instanceof Error) { @@ -97,11 +101,11 @@ const TaskListTabPanel = (props: TaskListTabPanelProps) => { } }; - const createTask = async (title: string, deadline: Date, priority: TaskPriority, assignees: UserPreview[]) => { + const createTask = async (title: string, priority: TaskPriority, assignees: UserPreview[], deadline?: Date) => { try { await createTaskMutate({ title, - deadline: transformDate(deadline), + deadline: deadline ? transformDate(deadline) : undefined, priority, status, assignees: assignees.map((user) => user.userId), 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 6f1d13143d..3942e48967 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskColumn.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/TaskList/v2/TaskColumn.tsx @@ -33,7 +33,7 @@ export const TaskColumn = ({ try { const task = await createTask({ title, - deadline: transformDate(deadline), + deadline: deadline ? transformDate(deadline) : undefined, priority, status: status as TaskStatus, assignees, diff --git a/src/frontend/src/pages/StatisticsPage/GraphCollectionForm/CreateGraphCollectionForm.tsx b/src/frontend/src/pages/StatisticsPage/GraphCollectionForm/CreateGraphCollectionForm.tsx new file mode 100644 index 0000000000..484c7d7049 --- /dev/null +++ b/src/frontend/src/pages/StatisticsPage/GraphCollectionForm/CreateGraphCollectionForm.tsx @@ -0,0 +1,35 @@ +import { GraphCollectionFormInput } from 'shared'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import { useCreateGraphCollection } from '../../../hooks/statistics.hooks'; +import GraphCollectionForm from './GraphCollectionForm'; + +const defaultValues: GraphCollectionFormInput = { + title: '', + specialPermissions: [] +}; + +interface CreateGraphCollectionFormProps { + open: boolean; + onHide: () => void; +} + +const CreateGraphCollectionForm = ({ open, onHide }: CreateGraphCollectionFormProps) => { + const { mutateAsync: createGraphCollection, isLoading } = useCreateGraphCollection(); + + if (isLoading) { + return ; + } + + return ( + + ); +}; + +export default CreateGraphCollectionForm; diff --git a/src/frontend/src/pages/StatisticsPage/GraphCollectionForm/GraphCollectionForm.tsx b/src/frontend/src/pages/StatisticsPage/GraphCollectionForm/GraphCollectionForm.tsx new file mode 100644 index 0000000000..0f12bfa82b --- /dev/null +++ b/src/frontend/src/pages/StatisticsPage/GraphCollectionForm/GraphCollectionForm.tsx @@ -0,0 +1,106 @@ +import { Controller, useForm } from 'react-hook-form'; +import { GraphCollection, GraphCollectionFormInput, SpecialPermission } from 'shared'; +import NERFormModal from '../../../components/NERFormModal'; +import { useToast } from '../../../hooks/toasts.hooks'; +import { useHistory } from 'react-router-dom'; +import { Autocomplete, FormControl, FormHelperText, FormLabel, Grid, TextField } from '@mui/material'; +import ReactHookTextField from '../../../components/ReactHookTextField'; +import { specialPermissionToAutoCompleteValue } from '../../../utils/statistics.utils'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; + +interface GraphCollectionFormProps { + open: boolean; + onHide: () => void; + defaultValues: GraphCollectionFormInput; + onSubmit: (data: GraphCollectionFormInput) => Promise; + successText: string; + title: string; +} + +const schema = yup.object().shape({ + title: yup.string().required(), + specialPermissions: yup.array().required() +}); + +const GraphCollectionForm = ({ open, onHide, defaultValues, onSubmit, successText, title }: GraphCollectionFormProps) => { + const toast = useToast(); + const history = useHistory(); + + const { + control, + handleSubmit, + reset, + formState: { errors } + } = useForm({ + defaultValues, + resolver: yupResolver(schema) + }); + + const onSubmitWrapper = async (formInput: GraphCollectionFormInput) => { + try { + const createdCollection = await onSubmit(formInput); + toast.success(successText); + history.push(`/statistics/graph-collections/${createdCollection.id}`); + } catch (error) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + return ( + + + + + Graph Collection Title + + + + + + Additional Permissions to Apply to the Collection + { + return ( + option.id === value.id} + filterSelectedOptions + multiple + id="permissionsSelector" + options={Object.values(SpecialPermission).map(specialPermissionToAutoCompleteValue)} + value={value.map(specialPermissionToAutoCompleteValue)} + onChange={(_event, newValue) => onChange(newValue.map((autoCompleteValue) => autoCompleteValue.id))} + getOptionLabel={(option) => option.label} + renderInput={(params) => ( + + )} + /> + ); + }} + /> + {errors.specialPermissions?.message} + + + + + ); +}; + +export default GraphCollectionForm; diff --git a/src/frontend/src/pages/StatisticsPage/GraphCollectionForm/UpdateGraphCollectionForm.tsx b/src/frontend/src/pages/StatisticsPage/GraphCollectionForm/UpdateGraphCollectionForm.tsx new file mode 100644 index 0000000000..020671eef7 --- /dev/null +++ b/src/frontend/src/pages/StatisticsPage/GraphCollectionForm/UpdateGraphCollectionForm.tsx @@ -0,0 +1,36 @@ +import { GraphCollection, GraphCollectionFormInput } from 'shared'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import GraphCollectionForm from './GraphCollectionForm'; +import { useUpdateGraphCollection } from '../../../hooks/statistics.hooks'; + +interface UpdateGraphCollectionFormProps { + open: boolean; + onHide: () => void; + graphCollection: GraphCollection; +} + +const UpdateGraphCollectionForm = ({ open, onHide, graphCollection }: UpdateGraphCollectionFormProps) => { + const { mutateAsync: updateGraphCollection, isLoading } = useUpdateGraphCollection(graphCollection.id); + + if (isLoading) { + return ; + } + + const defaultValues: GraphCollectionFormInput = { + title: graphCollection.title, + specialPermissions: graphCollection.permissions + }; + + return ( + + ); +}; + +export default UpdateGraphCollectionForm; diff --git a/src/frontend/src/pages/StatisticsPage/GraphCollectionViewContainer/GraphCollectionViewContainer.tsx b/src/frontend/src/pages/StatisticsPage/GraphCollectionViewContainer/GraphCollectionViewContainer.tsx new file mode 100644 index 0000000000..8de3f5610e --- /dev/null +++ b/src/frontend/src/pages/StatisticsPage/GraphCollectionViewContainer/GraphCollectionViewContainer.tsx @@ -0,0 +1,74 @@ +import { useHistory, useParams } from 'react-router-dom'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import { Box, Grid } from '@mui/material'; +import GraphView from '../GraphView/GraphView'; +import { useGetSingleGraphCollection } from '../../../hooks/statistics.hooks'; +import { NERButton } from '../../../components/NERButton'; +import { routes } from '../../../utils/routes'; +import PageLayout from '../../../components/PageLayout'; +import UpdateGraphCollectionForm from '../GraphCollectionForm/UpdateGraphCollectionForm'; +import { useState } from 'react'; + +const GraphCollectionViewContainer = () => { + const { graphCollectionId } = useParams<{ graphCollectionId: string }>(); + const { data: graphCollection, isLoading, isError, error } = useGetSingleGraphCollection(graphCollectionId); + const history = useHistory(); + const [showEditGraphCollectionModal, setShowEditGraphCollectionModal] = useState(false); + + if (isError) { + return ; + } + + if (isLoading || !graphCollection) { + return ; + } + + return ( + + setShowEditGraphCollectionModal(true)}> + Edit Graph Collection + + + } + > + + {graphCollection.graphs.map((graph) => ( + + + + ))} + + + history.push(`${routes.STATISTICS}/graph-collections/${graphCollection.id}/graph/create`)} + > + Add Graph + + + + + setShowEditGraphCollectionModal(false)} + graphCollection={graphCollection} + /> + + ); +}; + +export default GraphCollectionViewContainer; diff --git a/src/frontend/src/pages/StatisticsPage/GraphForm/CreateGraphForm.tsx b/src/frontend/src/pages/StatisticsPage/GraphForm/CreateGraphForm.tsx new file mode 100644 index 0000000000..e1e68cb040 --- /dev/null +++ b/src/frontend/src/pages/StatisticsPage/GraphForm/CreateGraphForm.tsx @@ -0,0 +1,35 @@ +import { GraphDisplayType, GraphFormInput, Measure } from 'shared'; +import { useCreateGraph } from '../../../hooks/statistics.hooks'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import GraphForm from './GraphForm'; + +const defaultValues: GraphFormInput = { + measure: Measure.SUM, + startTime: undefined, + endTime: undefined, + title: '', + graphType: null, + graphDisplayType: GraphDisplayType.BAR, + carIds: [], + specialPermissions: [] +}; + +const CreateGraphForm: React.FC = () => { + const { mutateAsync: createGraph, isLoading: createIsLoading } = useCreateGraph(); + + if (createIsLoading) { + return ; + } + + return ( + + ); +}; + +export default CreateGraphForm; diff --git a/src/frontend/src/pages/StatisticsPage/CreateGraphForm/CreateGraphForm.tsx b/src/frontend/src/pages/StatisticsPage/GraphForm/GraphForm.tsx similarity index 66% rename from src/frontend/src/pages/StatisticsPage/CreateGraphForm/CreateGraphForm.tsx rename to src/frontend/src/pages/StatisticsPage/GraphForm/GraphForm.tsx index c33232ad93..0fd96a610a 100644 --- a/src/frontend/src/pages/StatisticsPage/CreateGraphForm/CreateGraphForm.tsx +++ b/src/frontend/src/pages/StatisticsPage/GraphForm/GraphForm.tsx @@ -1,10 +1,9 @@ import { Box } from '@mui/material'; import { useForm } from 'react-hook-form'; -import { GraphDisplayType, GraphFormInput, Measure } from 'shared'; +import { CreateGraphArgs, Graph, GraphFormInput } from 'shared'; import NERSuccessButton from '../../../components/NERSuccessButton'; import NERFailButton from '../../../components/NERFailButton'; -import { useHistory } from 'react-router-dom'; -import { useCreateGraph } from '../../../hooks/statistics.hooks'; +import { useHistory, useParams } from 'react-router-dom'; import { routes } from '../../../utils/routes'; import { useToast } from '../../../hooks/toasts.hooks'; import LoadingIndicator from '../../../components/LoadingIndicator'; @@ -14,17 +13,7 @@ import ErrorPage from '../../ErrorPage'; import PageLayout from '../../../components/PageLayout'; import { GraphFormView } from './GraphFormView'; import { useGetAllCars } from '../../../hooks/cars.hooks'; - -const defaultValues: GraphFormInput = { - measure: Measure.SUM, - startTime: undefined, - endTime: undefined, - title: '', - graphType: null, - graphDisplayType: GraphDisplayType.BAR, - cars: [], - specialPermissions: [] -}; +import { SubmitText } from '../../../utils/teams.utils'; const schema = yup.object().shape({ endTime: yup.date().optional(), @@ -32,14 +21,23 @@ const schema = yup.object().shape({ title: yup.string().required(), graphType: yup.string().required(), graphDisplayType: yup.string().required(), - cars: yup.array().required(), + carIds: yup.array().required(), measure: yup.string().required() }); -const CreateGraphForm: React.FC = () => { +interface GraphFormProps { + action: (data: CreateGraphArgs) => Promise; + defaultValues: Omit; + submitText: SubmitText; + successText: string; + title: string; +} + +const GraphForm = ({ title, action, defaultValues, submitText, successText }: GraphFormProps) => { + const { graphCollectionId } = useParams<{ graphCollectionId: string }>(); + const history = useHistory(); const toast = useToast(); - const { mutateAsync: createGraph, isLoading: createIsLoading } = useCreateGraph(); const { data: cars, isLoading, isError, error } = useGetAllCars(); const { @@ -55,16 +53,16 @@ const CreateGraphForm: React.FC = () => { const onSubmit = async (formInput: GraphFormInput) => { try { if (!formInput.graphType) throw new Error('Please enter graph type'); - await createGraph({ + await action({ ...formInput, startDate: formInput.startTime, endDate: formInput.endTime, graphType: formInput.graphType, - carIds: formInput.cars.map((car) => car.id) + graphCollectionId }); - toast.success('Successfully created graph'); - history.push(routes.STATISTICS); + toast.success(successText); + history.push(routes.STATISTICS + '/graph-collections/' + graphCollectionId); reset(); } catch (error) { if (error instanceof Error) { @@ -74,14 +72,14 @@ const CreateGraphForm: React.FC = () => { }; const exitEditMode = () => { - history.push(routes.STATISTICS); + history.push(routes.STATISTICS + '/graph-collections/' + graphCollectionId); }; if (isError) { return ; } - if (createIsLoading || !cars || isLoading) { + if (!cars || isLoading) { return ; } @@ -96,28 +94,26 @@ const CreateGraphForm: React.FC = () => { > Cancel - Submit + {submitText} } > - + ); }; -export default CreateGraphForm; +export default GraphForm; diff --git a/src/frontend/src/pages/StatisticsPage/CreateGraphForm/GraphFormView.tsx b/src/frontend/src/pages/StatisticsPage/GraphForm/GraphFormView.tsx similarity index 84% rename from src/frontend/src/pages/StatisticsPage/CreateGraphForm/GraphFormView.tsx rename to src/frontend/src/pages/StatisticsPage/GraphForm/GraphFormView.tsx index ae25498f11..f9efa845e2 100644 --- a/src/frontend/src/pages/StatisticsPage/CreateGraphForm/GraphFormView.tsx +++ b/src/frontend/src/pages/StatisticsPage/GraphForm/GraphFormView.tsx @@ -2,26 +2,32 @@ import { Autocomplete, FormControl, FormHelperText, FormLabel, Grid, MenuItem, S import ReactHookTextField from '../../../components/ReactHookTextField'; import { Control, Controller, FieldErrors } from 'react-hook-form'; import { DatePicker } from '@mui/x-date-pickers'; -import { Car, GraphCollection, GraphDisplayType, GraphFormInput, GraphType, Measure, SpecialPermission } from 'shared'; +import { Car, GraphDisplayType, GraphFormInput, GraphType, Measure, SpecialPermission } from 'shared'; import { displayEnum } from '../../../utils/pipes'; import NERAutocomplete from '../../../components/NERAutocomplete'; -import { useState } from 'react'; -import { - graphCollectionToAutoCompleteValue, - graphTypeToAutoCompleteValue, - specialPermissionToAutoCompleteValue -} from '../../../utils/statistics.utils'; +import { useEffect, useState } from 'react'; +import { graphTypeToAutoCompleteValue, specialPermissionToAutoCompleteValue } from '../../../utils/statistics.utils'; interface GraphFormViewProps { control: Control; errors: FieldErrors; - graphCollections: GraphCollection[]; cars: Car[]; } -export const GraphFormView: React.FC = ({ control, errors, graphCollections, cars }) => { +export const GraphFormView: React.FC = ({ control, errors, cars }) => { const [startTimeDatePickerOpen, setStartTimeDatePickerOpen] = useState(false); const [endTimeDatePickerOpen, setEndTimeDatePickerOpen] = useState(false); + const [carMap, setCarMap] = useState(new Map()); + + useEffect(() => { + const tempSet = new Map(); + + cars.forEach((car) => { + tempSet.set(car.id, car); + }); + + setCarMap(tempSet); + }, [cars]); return ( @@ -170,30 +176,6 @@ export const GraphFormView: React.FC = ({ control, errors, g {errors.measure?.message} - - - Select Graph Collection - { - return ( - onChange(collectionValue?.id)} - size="medium" - value={{ label: value ?? '', id: value ?? '' }} - placeholder="Select a collection (optional)" - options={graphCollections.map(graphCollectionToAutoCompleteValue)} - errorMessage={errors.graphCollectionId} - /> - ); - }} - /> - - - Select Data @@ -222,19 +204,19 @@ export const GraphFormView: React.FC = ({ control, errors, g Select Cars To Segment Data By { return ( option.id === value.id} + isOptionEqualToValue={(option, value) => option?.id === value?.id} filterSelectedOptions multiple id="carSelector" options={cars} - value={value} - onChange={(_event, newValue) => onChange(newValue)} - getOptionLabel={(option) => option.name} + value={value.map((carId) => carMap.get(carId))} + onChange={(_event, newValue) => onChange(newValue.map((car) => car?.id))} + getOptionLabel={(option) => option?.name ?? ''} renderInput={(params) => ( )} @@ -242,7 +224,7 @@ export const GraphFormView: React.FC = ({ control, errors, g ); }} /> - {errors.cars?.message} + {errors.carIds?.message} @@ -269,7 +251,7 @@ export const GraphFormView: React.FC = ({ control, errors, g ); }} /> - {errors.cars?.message} + {errors.specialPermissions?.message} diff --git a/src/frontend/src/pages/StatisticsPage/GraphForm/UpdateGraphForm.tsx b/src/frontend/src/pages/StatisticsPage/GraphForm/UpdateGraphForm.tsx new file mode 100644 index 0000000000..e0988e03be --- /dev/null +++ b/src/frontend/src/pages/StatisticsPage/GraphForm/UpdateGraphForm.tsx @@ -0,0 +1,43 @@ +import { GraphFormInput } from 'shared'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import GraphForm from './GraphForm'; +import { useParams } from 'react-router-dom'; +import ErrorPage from '../../ErrorPage'; +import { useGetSingleGraph, useUpdateGraph } from '../../../hooks/statistics.hooks'; + +const UpdateGraphForm: React.FC = () => { + const { graphId } = useParams<{ graphId: string }>(); + const { data: graph, isLoading, isError, error } = useGetSingleGraph(graphId); + const { mutateAsync: updateGraph, isLoading: updateIsLoading } = useUpdateGraph(graphId); + + if (isError) { + return ; + } + + if (updateIsLoading || isLoading || !graph) { + return ; + } + + const defaultValues: GraphFormInput = { + measure: graph.measure, + startTime: graph.startDate, + endTime: graph.endDate, + title: graph.title, + graphType: graph.graphType, + graphDisplayType: graph.graphDisplayType, + carIds: graph.carIds, + specialPermissions: graph.specialPermissions + }; + + return ( + + ); +}; + +export default UpdateGraphForm; diff --git a/src/frontend/src/pages/StatisticsPage/GraphView/GraphBarChartView.tsx b/src/frontend/src/pages/StatisticsPage/GraphView/GraphBarChartView.tsx new file mode 100644 index 0000000000..842a02954f --- /dev/null +++ b/src/frontend/src/pages/StatisticsPage/GraphView/GraphBarChartView.tsx @@ -0,0 +1,25 @@ +import { Car, Graph } from 'shared'; +import StatsBarChart from '../../../components/StatsBarChart'; +import { displayEnum } from '../../../utils/pipes'; +interface GraphBarChartViewProps { + graph: Graph; + height: number; + cars: Car[]; +} + +const GraphBarChartView = ({ graph, height, cars }: GraphBarChartViewProps) => { + return ( + 0 ? `(${cars.map((car) => car.name).join(',')})` : '' + }`} + xAxisData={graph.graphData.map((data) => data.label)} + yAxisData={graph.graphData.map((data) => data.value)} + xAxisLabel="" + yAxisLabel={graph.measure} + height={height} + /> + ); +}; + +export default GraphBarChartView; diff --git a/src/frontend/src/pages/StatisticsPage/GraphView/GraphPieChartView.tsx b/src/frontend/src/pages/StatisticsPage/GraphView/GraphPieChartView.tsx new file mode 100644 index 0000000000..c5455af01b --- /dev/null +++ b/src/frontend/src/pages/StatisticsPage/GraphView/GraphPieChartView.tsx @@ -0,0 +1,24 @@ +import { Car, Graph } from 'shared'; +import StatsPieChart from '../../../components/StatsPieChart'; +import { displayEnum } from '../../../utils/pipes'; + +interface GraphPieChartViewProps { + graph: Graph; + height: number; + cars: Car[]; +} + +const GraphPieChartView = ({ graph, height, cars }: GraphPieChartViewProps) => { + return ( + 0 ? `(${cars.map((car) => car.name).join(',')})` : '' + }`} + xAxisData={graph.graphData.map((data) => data.label)} + yAxisData={graph.graphData.map((data) => data.value)} + height={height} + /> + ); +}; + +export default GraphPieChartView; diff --git a/src/frontend/src/pages/StatisticsPage/GraphView/GraphView.tsx b/src/frontend/src/pages/StatisticsPage/GraphView/GraphView.tsx new file mode 100644 index 0000000000..ddb6675b88 --- /dev/null +++ b/src/frontend/src/pages/StatisticsPage/GraphView/GraphView.tsx @@ -0,0 +1,65 @@ +import { Box, IconButton, Typography } from '@mui/material'; +import { Graph, GraphDisplayType } from 'shared'; +import GraphBarChartView from './GraphBarChartView'; +import GraphPieChartView from './GraphPieChartView'; +import { Edit } from '@mui/icons-material'; +import { useHistory, useParams } from 'react-router-dom'; +import { datePipe } from '../../../utils/pipes'; +import { useGetCarsByIds } from '../../../hooks/cars.hooks'; +import ErrorPage from '../../ErrorPage'; +import LoadingIndicator from '../../../components/LoadingIndicator'; + +interface GraphViewProps { + graph: Graph; + height: number; +} + +const GraphView = ({ graph, height = 500 }: GraphViewProps) => { + const history = useHistory(); + const { graphCollectionId } = useParams<{ graphCollectionId: string }>(); + const { isLoading, data: cars, error, isError } = useGetCarsByIds(new Set(graph.carIds)); + + if (isError) { + return ; + } + + if (isLoading || !cars) { + return ; + } + + const Graph = () => { + switch (graph.graphDisplayType) { + case GraphDisplayType.BAR: + return ; + case GraphDisplayType.PIE: + return ; + default: + return Unsupported graph display type: {graph.graphDisplayType}; + } + }; + + return ( + + + + + history.push('/statistics/graph-collections/' + graphCollectionId + '/graph/' + graph.graphId + '/edit') + } + > + + + + + {!graph.startDate && !graph.endDate + ? 'All time' + : (graph.startDate ? datePipe(graph.startDate) : 'No start date') + + ' to ' + + (graph.endDate ? datePipe(graph.endDate) : 'no end date')} + + + ); +}; + +export default GraphView; diff --git a/src/frontend/src/pages/StatisticsPage/Statistics.tsx b/src/frontend/src/pages/StatisticsPage/Statistics.tsx index 4c033930af..281f36ba32 100644 --- a/src/frontend/src/pages/StatisticsPage/Statistics.tsx +++ b/src/frontend/src/pages/StatisticsPage/Statistics.tsx @@ -1,14 +1,17 @@ import { Switch, Route } from 'react-router-dom'; import { routes } from '../../utils/routes'; import StatisticsPage from './StatisticsPage'; -import CreateGraphForm from './CreateGraphForm/CreateGraphForm'; +import CreateGraphForm from './GraphForm/CreateGraphForm'; +import GraphCollectionViewContainer from './GraphCollectionViewContainer/GraphCollectionViewContainer'; +import UpdateGraphForm from './GraphForm/UpdateGraphForm'; const Statistics: React.FC = () => { return ( + + - {/* Add more routes here */} ); }; diff --git a/src/frontend/src/pages/StatisticsPage/StatisticsPage.tsx b/src/frontend/src/pages/StatisticsPage/StatisticsPage.tsx index 0edecd90c1..3390274421 100644 --- a/src/frontend/src/pages/StatisticsPage/StatisticsPage.tsx +++ b/src/frontend/src/pages/StatisticsPage/StatisticsPage.tsx @@ -3,51 +3,85 @@ * See the LICENSE file in the repository root folder for details. */ +import { Box, Grid, Typography } from '@mui/material'; import PageLayout from '../../components/PageLayout'; -import BarChart from '../../components/StatsBarChart'; -import PieChart from '../../components/StatsPieChart'; +import LoadingIndicator from '../../components/LoadingIndicator'; +import ErrorPage from '../ErrorPage'; +import GraphCollectionCard from '../../components/GraphCollectionCard'; +import { useGetAllGraphCollections } from '../../hooks/statistics.hooks'; +import { NERButton } from '../../components/NERButton'; +import CreateGraphCollectionForm from './GraphCollectionForm/CreateGraphCollectionForm'; +import { useState } from 'react'; import LineGraph from '../../components/StatsLineGraph'; -import { Box } from '@mui/material'; const StatisticsPage: React.FC = () => { - // Testing bar, pie, and line chart components + const { data: graphCollections, isLoading, isError, error } = useGetAllGraphCollections(); + const [showCreateGraphCollectionModal, setShowCreateGraphCollectionModal] = useState(false); + + if (isError) { + return ; + } + + if (!graphCollections || isLoading) { + return ; + } + return ( - - - - + + Graph Collections + + {graphCollections.map((graphCollection) => { + return ( + + + + ); + })} + + + setShowCreateGraphCollectionModal(true)}>Create Graph Collection + + + + + setShowCreateGraphCollectionModal(false)} + /> - + + Graphs = ({ const projectWbsString: string = wbsPipe({ ...workPackage.wbsNum, workPackageNumber: 0 }); return ( - <> - - - - - } - > - {tabValue === 0 ? ( - - ) : tabValue === 1 ? ( - - ) : tabValue === 2 ? ( - - ) : ( - - )} - {showActivateModal && ( - setShowActivateModal(false)} - /> - )} - {showStageGateModal && ( - setShowStageGateModal(false)} - /> - )} - {showDeleteModal && ( - setShowDeleteModal(false)} - /> - )} - - + } + > + {tabValue === 0 ? ( + + ) : tabValue === 1 ? ( + + ) : tabValue === 2 ? ( + + ) : ( + + )} + {showActivateModal && ( + setShowActivateModal(false)} + /> + )} + {showStageGateModal && ( + setShowStageGateModal(false)} + /> + )} + {showDeleteModal && ( + setShowDeleteModal(false)} + /> + )} + ); }; diff --git a/src/frontend/src/tests/pages/ProjectDetailPage/ProjectViewContainer.test.tsx b/src/frontend/src/tests/pages/ProjectDetailPage/ProjectViewContainer.test.tsx index 2b68b38345..17018a8221 100644 --- a/src/frontend/src/tests/pages/ProjectDetailPage/ProjectViewContainer.test.tsx +++ b/src/frontend/src/tests/pages/ProjectDetailPage/ProjectViewContainer.test.tsx @@ -38,7 +38,7 @@ describe('Rendering Project View Container', () => { it('renders the provided project', () => { renderComponent(); - expect(screen.getAllByText('1.1.0 - Impact Attenuator').length).toEqual(1); + expect(screen.getAllByText('1.1.0 - Impact Attenuator').length).toEqual(2); expect(screen.getByText('Details')).toBeInTheDocument(); expect(screen.getByText('Work Packages')).toBeInTheDocument(); expect(screen.getByText('Bodywork Concept of Design')).toBeInTheDocument(); diff --git a/src/frontend/src/utils/gantt.utils.ts b/src/frontend/src/utils/gantt.utils.ts index bdda2e7438..29e9e73f94 100644 --- a/src/frontend/src/utils/gantt.utils.ts +++ b/src/frontend/src/utils/gantt.utils.ts @@ -21,7 +21,7 @@ import { } from 'shared'; import { projectWbsPipe } from './pipes'; import dayjs from 'dayjs'; -import { deepOrange, green, grey, indigo, orange, pink, yellow } from '@mui/material/colors'; +import { deepOrange, green, grey, indigo, orange, pink } from '@mui/material/colors'; import { projectPreviewTranformer } from '../apis/transformers/projects.transformers'; export const NO_TEAM = 'No Team'; @@ -360,7 +360,7 @@ export const transformWorkPackageToGanttTask = ( allWorkPackages, blocking: workPackage.blocking, styles: { - color: GanttWorkPackageTextColorPipe(workPackage.stage), + color: GanttWorkPackageTextColor, backgroundColor: GanttWorkPackageStageColorPipe(workPackage.stage, workPackage.status) }, onClick: () => { @@ -450,7 +450,7 @@ export const GanttWorkPackageStageColorPipe: (stage: WorkPackageStage | undefine case WorkPackageStage.Install: return pink[500]; case WorkPackageStage.Testing: - return yellow[600]; + return '#44a0b1'; default: return grey[500]; } @@ -465,7 +465,7 @@ export const GanttWorkPackageStageColorPipe: (stage: WorkPackageStage | undefine case WorkPackageStage.Install: return pink[300]; case WorkPackageStage.Testing: - return yellow[300]; + return '#55c7dd'; default: return grey[500]; } @@ -480,27 +480,14 @@ export const GanttWorkPackageStageColorPipe: (stage: WorkPackageStage | undefine case WorkPackageStage.Install: return pink[800]; case WorkPackageStage.Testing: - return yellow[800]; + return '#2d6b77'; default: return grey[500]; } } }; -// maps stage to the desired text color -export const GanttWorkPackageTextColorPipe: (stage: WorkPackageStage | undefined) => string = (stage) => { - switch (stage) { - case WorkPackageStage.Research: - case WorkPackageStage.Design: - case WorkPackageStage.Manufacturing: - case WorkPackageStage.Install: - return '#ffffff'; - case WorkPackageStage.Testing: - return '#000000'; - default: - return '#ffffff'; - } -}; +export const GanttWorkPackageTextColor: string = '#ffffff'; /** * Determines if the highlighted change is on the wbs elements project. diff --git a/src/frontend/src/utils/routes.ts b/src/frontend/src/utils/routes.ts index 2208048e61..c4afe0e730 100644 --- a/src/frontend/src/utils/routes.ts +++ b/src/frontend/src/utils/routes.ts @@ -58,7 +58,10 @@ const ORGANIZATIONS = `/organizations`; /**************** Statistics ****************/ const STATISTICS = `/statistics`; -const CREATE_GRAPH = `/statistics/graph/create`; +const CREATE_GRAPH = `/statistics/graph-collections/:graphCollectionId/graph/create`; +const EDIT_GRAPH = `/statistics/graph-collections/:graphCollectionId/graph/:graphId/edit`; + +const GRAPH_COLLECTION_BY_ID = '/statistics/graph-collections/:graphCollectionId'; export const routes = { BASE, @@ -108,5 +111,7 @@ export const routes = { ORGANIZATIONS, STATISTICS, - CREATE_GRAPH + CREATE_GRAPH, + EDIT_GRAPH, + GRAPH_COLLECTION_BY_ID }; diff --git a/src/frontend/src/utils/task.utils.ts b/src/frontend/src/utils/task.utils.ts index ab597b785f..31a04723d0 100644 --- a/src/frontend/src/utils/task.utils.ts +++ b/src/frontend/src/utils/task.utils.ts @@ -25,7 +25,7 @@ declare global { export type Row = { id: number; title: string; - deadline: Date; + deadline?: Date; priority: TaskPriority; assignees: UserPreview[]; taskId: string; @@ -49,12 +49,12 @@ export interface TaskListDataGridProps { tableRowCount: string; setSelectedTask: Dispatch>; setModalShow: Dispatch>; - createTask: (title: string, deadline: Date, priority: TaskPriority, assignees: UserPreview[]) => Promise; + createTask: (title: string, priority: TaskPriority, assignees: UserPreview[], deadline?: Date) => Promise; status: TaskStatus; addTask: boolean; onAddCancel: () => void; deleteRow: (taskId: string) => MouseEventHandler; - moveToInProgress: (taskId: string) => MouseEventHandler; + moveToInProgress: (taskId: string, assignees: string, deadline: Date | undefined) => MouseEventHandler; moveToDone: (taskId: string) => MouseEventHandler; moveToBacklog: (taskId: string) => MouseEventHandler; editTask: (editInfo: EditTaskFormInput) => Promise; diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 113350e24d..c0f505f89e 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -194,8 +194,13 @@ const faqDelete = (id: string) => `${recruitment()}/faq/${id}/delete`; /************** Statistics Endpoints ***************/ const statistics = () => `${API_URL}/statistics`; -const graphConfig = () => `${statistics()}/graph/config`; const createGraph = () => `${statistics()}/graph/create`; +const graphCollections = () => `${statistics()}/graph-collections`; +const graphCollectionById = (id: string) => `${graphCollections()}/${id}`; +const createGraphCollection = () => `${graphCollections()}/create`; +const getGraphById = (id: string) => `${statistics()}/graph/${id}`; +const updateGraph = (id: string) => `${getGraphById(id)}/edit`; +const updateGraphCollection = (id: string) => `${graphCollectionById(id)}/edit`; /**************** Other Endpoints ****************/ const version = () => `https://api.github.com/repos/Northeastern-Electric-Racing/FinishLine/releases/latest`; @@ -354,8 +359,13 @@ export const apiUrls = { faqDelete, statistics, - graphConfig, createGraph, + graphCollections, + graphCollectionById, + createGraphCollection, + getGraphById, + updateGraph, + updateGraphCollection, version }; diff --git a/src/shared/src/permission-utils.ts b/src/shared/src/permission-utils.ts index 18dfe6427f..1dcd33ff63 100644 --- a/src/shared/src/permission-utils.ts +++ b/src/shared/src/permission-utils.ts @@ -57,6 +57,7 @@ const MEMBER_PERMISSIONS = GUEST_PERMISSIONS.concat([ Permission.CREATE_GRAPH, Permission.VIEW_GRAPH, Permission.DELETE_GRAPH, + Permission.VIEW_GRAPH_COLLECTION, Permission.EDIT_GRAPH_COLLECTION, Permission.CREATE_GRAPH_COLLECTION, Permission.DELETE_GRAPH_COLLECTION diff --git a/src/shared/src/types/statistics-types.ts b/src/shared/src/types/statistics-types.ts index 2a05d83393..4ef72d78ba 100644 --- a/src/shared/src/types/statistics-types.ts +++ b/src/shared/src/types/statistics-types.ts @@ -1,4 +1,3 @@ -import { Car } from './project-types'; import { User } from './user-types'; export enum GraphDisplayType { @@ -39,6 +38,7 @@ export interface Graph { startDate?: Date; endDate?: Date; title: string; + measure: Measure; graphType: GraphType; graphDisplayType: GraphDisplayType; userCreated: User; @@ -46,6 +46,7 @@ export interface Graph { dateDeleted?: Date; graphData: GraphData[]; graphCollectionId?: String; + carIds: string[]; specialPermissions: SpecialPermission[]; } @@ -57,6 +58,7 @@ export interface GraphCollection { userCreated: User; userDeleted?: User; dateDeleted?: Date; + dateCreated: Date; permissions: SpecialPermission[]; } @@ -79,6 +81,11 @@ export interface GraphFormInput { endTime?: Date; graphDisplayType: GraphDisplayType; graphCollectionId?: string; - cars: Car[]; + carIds: string[]; + specialPermissions: SpecialPermission[]; +} + +export interface GraphCollectionFormInput { + title: string; specialPermissions: SpecialPermission[]; } diff --git a/src/shared/src/types/task-types.ts b/src/shared/src/types/task-types.ts index 0a9be6c74d..55718b611d 100644 --- a/src/shared/src/types/task-types.ts +++ b/src/shared/src/types/task-types.ts @@ -28,7 +28,7 @@ export interface Task { createdBy: UserPreview; deletedBy?: UserPreview; assignees: UserPreview[]; - deadline: Date; + deadline?: Date; priority: TaskPriority; status: TaskStatus; } diff --git a/src/shared/src/utils.ts b/src/shared/src/utils.ts index 08b4b78278..2d2500e275 100644 --- a/src/shared/src/utils.ts +++ b/src/shared/src/utils.ts @@ -20,3 +20,7 @@ const deeplyCopyObj = (obj: T, transformer: (obj: T) => T = (obj) => obj): T export const wbsNamePipe = (wbsElement: { wbsNum: WbsNumber; name: string }) => { return `${wbsPipe(wbsElement.wbsNum)} - ${wbsElement.name}`; }; + +export const isSubset = (elements: string[], suppliedArray: string[]): boolean => { + return elements.every((element) => suppliedArray.includes(element)); +};