diff --git a/src/backend/custom.d.ts b/src/backend/custom.d.ts index 964ba270d6..99f4413227 100644 --- a/src/backend/custom.d.ts +++ b/src/backend/custom.d.ts @@ -1,4 +1,4 @@ -import { Organization } from '@prisma/client'; +import { Car, Organization } from '@prisma/client'; import { User as SharedUser } from 'shared'; declare global { @@ -6,6 +6,7 @@ declare global { export interface Request { currentUser: SharedUser; organization: Organization; + currentCar?: Car; } } } diff --git a/src/backend/index.ts b/src/backend/index.ts index 0df1277a2d..6443801418 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -3,6 +3,7 @@ import cors from 'cors'; import cookieParser from 'cookie-parser'; import { getUserAndOrganization, prodHeaders, requireJwtDev, requireJwtProd } from './src/utils/auth.utils.js'; import { errorHandler } from './src/utils/errors.utils.js'; +import { getCurrentCar } from './src/utils/car.utils.js'; import userRouter from './src/routes/users.routes.js'; import projectRouter from './src/routes/projects.routes.js'; import teamsRouter from './src/routes/teams.routes.js'; @@ -90,6 +91,9 @@ app.use(isProd ? requireJwtProd : requireJwtDev); // get user and organization app.use(getUserAndOrganization); +// get current car +app.use(getCurrentCar); + // routes app.use('/users', userRouter); app.use('/projects', projectRouter); diff --git a/src/backend/src/controllers/cars.controllers.ts b/src/backend/src/controllers/cars.controllers.ts index c5d577de51..8346ede8fe 100644 --- a/src/backend/src/controllers/cars.controllers.ts +++ b/src/backend/src/controllers/cars.controllers.ts @@ -22,4 +22,14 @@ export default class CarsController { next(error); } } + + static async getCurrentCar(req: Request, res: Response, next: NextFunction) { + try { + const car = await CarsService.getCurrentCar(req.organization); + + res.status(200).json(car); + } catch (error: unknown) { + next(error); + } + } } diff --git a/src/backend/src/controllers/change-requests.controllers.ts b/src/backend/src/controllers/change-requests.controllers.ts index 208937bac8..f85daf2ce7 100644 --- a/src/backend/src/controllers/change-requests.controllers.ts +++ b/src/backend/src/controllers/change-requests.controllers.ts @@ -16,7 +16,7 @@ export default class ChangeRequestsController { static async getAllChangeRequests(req: Request, res: Response, next: NextFunction) { try { - const changeRequests = await ChangeRequestsService.getAllChangeRequests(req.organization); + const changeRequests = await ChangeRequestsService.getAllChangeRequests(req.organization, req.currentCar?.carId); res.status(200).json(changeRequests); } catch (error: unknown) { next(error); @@ -25,7 +25,11 @@ export default class ChangeRequestsController { static async getToReviewChangeRequests(req: Request, res: Response, next: NextFunction) { try { - const changeRequests = await ChangeRequestsService.getToReviewChangeRequests(req.currentUser, req.organization); + const changeRequests = await ChangeRequestsService.getToReviewChangeRequests( + req.currentUser, + req.organization, + req.currentCar?.carId + ); res.status(200).json(changeRequests); } catch (error: unknown) { next(error); @@ -41,7 +45,8 @@ export default class ChangeRequestsController { const changeRequests = await ChangeRequestsService.getUnreviewedChangeRequests( req.currentUser, validatedWbs, - req.organization + req.organization, + req.currentCar?.carId ); res.status(200).json(changeRequests); } catch (error: unknown) { @@ -58,7 +63,8 @@ export default class ChangeRequestsController { const changeRequests = await ChangeRequestsService.getApprovedChangeRequests( req.currentUser, validatedWbs, - req.organization + req.organization, + req.currentCar?.carId ); res.status(200).json(changeRequests); } catch (error: unknown) { diff --git a/src/backend/src/controllers/projects.controllers.ts b/src/backend/src/controllers/projects.controllers.ts index a76df6cdd3..058e6af3c8 100644 --- a/src/backend/src/controllers/projects.controllers.ts +++ b/src/backend/src/controllers/projects.controllers.ts @@ -16,7 +16,7 @@ import BillOfMaterialsService from '../services/boms.services.js'; export default class ProjectsController { static async getAllProjectsGantt(req: Request, res: Response, next: NextFunction) { try { - const projects: ProjectGantt[] = await ProjectsService.getAllProjectsGantt(req.organization); + const projects: ProjectGantt[] = await ProjectsService.getAllProjectsGantt(req.organization, req.currentCar?.carId); res.status(200).json(projects); } catch (error: unknown) { next(error); @@ -25,7 +25,7 @@ export default class ProjectsController { static async getAllProjects(req: Request, res: Response, next: NextFunction) { try { - const projects: ProjectPreview[] = await ProjectsService.getAllProjects(req.organization); + const projects: ProjectPreview[] = await ProjectsService.getAllProjects(req.organization, req.currentCar?.carId); res.status(200).json(projects); } catch (error: unknown) { next(error); @@ -34,7 +34,11 @@ export default class ProjectsController { static async getUsersTeamsProjects(req: Request, res: Response, next: NextFunction) { try { - const projects: ProjectOverview[] = await ProjectsService.getUsersTeamsProjects(req.currentUser, req.organization); + const projects: ProjectOverview[] = await ProjectsService.getUsersTeamsProjects( + req.currentUser, + req.organization, + req.currentCar?.carId + ); res.status(200).json(projects); } catch (error: unknown) { next(error); @@ -43,7 +47,11 @@ export default class ProjectsController { static async getUsersLeadingProjects(req: Request, res: Response, next: NextFunction) { try { - const projects: ProjectOverview[] = await ProjectsService.getUsersLeadingProjects(req.currentUser, req.organization); + const projects: ProjectOverview[] = await ProjectsService.getUsersLeadingProjects( + req.currentUser, + req.organization, + req.currentCar?.carId + ); res.status(200).json(projects); } catch (error: unknown) { next(error); @@ -53,7 +61,7 @@ export default class ProjectsController { static async getTeamsProjects(req: Request, res: Response, next: NextFunction) { try { const { teamId } = req.params as Record; - const projects: Project[] = await ProjectsService.getTeamsProjects(req.organization, teamId); + const projects: Project[] = await ProjectsService.getTeamsProjects(req.organization, teamId, req.currentCar?.carId); res.status(200).json(projects); } catch (error: unknown) { next(error); diff --git a/src/backend/src/controllers/work-packages.controllers.ts b/src/backend/src/controllers/work-packages.controllers.ts index 2c0b34ddf9..9aaec541c2 100644 --- a/src/backend/src/controllers/work-packages.controllers.ts +++ b/src/backend/src/controllers/work-packages.controllers.ts @@ -8,8 +8,15 @@ export default class WorkPackagesController { static async getAllWorkPackages(req: Request, res: Response, next: NextFunction) { try { const { query } = req; + const queryWithCarId = { + ...query, + carId: req.currentCar?.carId + }; - const outputWorkPackages: WorkPackage[] = await WorkPackagesService.getAllWorkPackages(query, req.organization); + const outputWorkPackages: WorkPackage[] = await WorkPackagesService.getAllWorkPackages( + queryWithCarId, + req.organization + ); res.status(200).json(outputWorkPackages); } catch (error: unknown) { @@ -24,7 +31,8 @@ export default class WorkPackagesController { const outputWorkPackages: WorkPackagePreview[] = await WorkPackagesService.getAllWorkPackagesPreview( status, - req.organization + req.organization, + req.currentCar?.carId ); res.status(200).json(outputWorkPackages); @@ -50,7 +58,11 @@ export default class WorkPackagesController { try { const { wbsNums } = req.body; - const workPackages: WorkPackage[] = await WorkPackagesService.getManyWorkPackages(wbsNums, req.organization); + const workPackages: WorkPackage[] = await WorkPackagesService.getManyWorkPackages( + wbsNums, + req.organization, + req.currentCar?.carId + ); res.status(200).json(workPackages); } catch (error: unknown) { next(error); @@ -131,7 +143,8 @@ export default class WorkPackagesController { const blockingWorkPackages: WorkPackage[] = await WorkPackagesService.getBlockingWorkPackages( wbsNum, - req.organization + req.organization, + req.currentCar?.carId ); res.status(200).json(blockingWorkPackages); @@ -158,7 +171,8 @@ export default class WorkPackagesController { const workPackages: WorkPackagePreview[] = await WorkPackagesService.getHomePageWorkPackages( req.currentUser, req.organization, - selection as WorkPackageSelection + selection as WorkPackageSelection, + req.currentCar?.carId ); res.status(200).json(workPackages); diff --git a/src/backend/src/prisma/seed-data/reimbursement-requests.seed.ts b/src/backend/src/prisma/seed-data/reimbursement-requests.seed.ts index fd7ddf3ffe..b003210813 100644 --- a/src/backend/src/prisma/seed-data/reimbursement-requests.seed.ts +++ b/src/backend/src/prisma/seed-data/reimbursement-requests.seed.ts @@ -81,7 +81,7 @@ export const seedReimbursementRequests = async ( { name: 'High Performance Battery Pack', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -112,7 +112,7 @@ export const seedReimbursementRequests = async ( { name: 'Development Tools Kit', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -148,7 +148,7 @@ export const seedReimbursementRequests = async ( { name: 'Cloud Storage Subscription', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -189,7 +189,7 @@ export const seedReimbursementRequests = async ( { name: 'Unnecessary Luxury Item', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -220,7 +220,7 @@ export const seedReimbursementRequests = async ( { name: 'Safety Equipment - Helmets', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -235,7 +235,7 @@ export const seedReimbursementRequests = async ( { name: 'Safety Equipment - Gloves', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -271,7 +271,7 @@ export const seedReimbursementRequests = async ( { name: 'Office Supplies', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -312,7 +312,7 @@ export const seedReimbursementRequests = async ( { name: 'Testing Equipment', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -342,7 +342,7 @@ export const seedReimbursementRequests = async ( { name: 'Software Licenses', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -383,7 +383,7 @@ export const seedReimbursementRequests = async ( { name: 'Training Materials', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -419,7 +419,7 @@ export const seedReimbursementRequests = async ( { name: 'Research Database Access', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -460,7 +460,7 @@ export const seedReimbursementRequests = async ( { name: 'Workshop Snacks', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -490,7 +490,7 @@ export const seedReimbursementRequests = async ( { name: 'Sensor Components', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -537,7 +537,7 @@ export const seedReimbursementRequests = async ( { name: 'Emergency Replacement Parts', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -573,7 +573,7 @@ export const seedReimbursementRequests = async ( { name: 'Team Building Materials', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -608,7 +608,7 @@ export const seedReimbursementRequests = async ( { name: 'Learning Resources', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -644,7 +644,7 @@ export const seedReimbursementRequests = async ( { name: 'Presentation Materials', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -685,7 +685,7 @@ export const seedReimbursementRequests = async ( { name: 'Personal Electronics', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -716,7 +716,7 @@ export const seedReimbursementRequests = async ( { name: 'CAD Software License', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -757,7 +757,7 @@ export const seedReimbursementRequests = async ( { name: 'Microcontrollers', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -793,7 +793,7 @@ export const seedReimbursementRequests = async ( { name: 'Video Conferencing Equipment', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -840,7 +840,7 @@ export const seedReimbursementRequests = async ( { name: 'Workshop Cleaning Supplies', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -870,7 +870,7 @@ export const seedReimbursementRequests = async ( { name: 'Hand Tools Set', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -911,7 +911,7 @@ export const seedReimbursementRequests = async ( { name: '3D Printing Filament', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -947,7 +947,7 @@ export const seedReimbursementRequests = async ( { name: 'Carbon Fiber Sheets', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -962,7 +962,7 @@ export const seedReimbursementRequests = async ( { name: 'Epoxy Resin', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -977,7 +977,7 @@ export const seedReimbursementRequests = async ( { name: 'Aluminum Stock', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -1024,7 +1024,7 @@ export const seedReimbursementRequests = async ( { name: 'High-Speed Data Acquisition System', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -1054,7 +1054,7 @@ export const seedReimbursementRequests = async ( { name: 'Power Supply Units', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -1105,7 +1105,7 @@ export const seedReimbursementRequests = async ( { name: 'Development Software Licenses', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -1156,7 +1156,7 @@ export const seedReimbursementRequests = async ( { name: 'Cloud Computing Credits', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -1213,7 +1213,7 @@ export const seedReimbursementRequests = async ( { name: 'Safety Equipment', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -1270,7 +1270,7 @@ export const seedReimbursementRequests = async ( { name: 'Tablets for Design Team', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -1338,7 +1338,7 @@ export const seedReimbursementRequests = async ( { name: 'Bulk Workshop Supplies', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -1406,7 +1406,7 @@ export const seedReimbursementRequests = async ( { name: 'Battery Testing Equipment', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, @@ -1479,7 +1479,7 @@ export const seedReimbursementRequests = async ( { name: 'PCB Manufacturing', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 2, workPackageNumber: 0 }, @@ -1552,7 +1552,7 @@ export const seedReimbursementRequests = async ( { name: 'Team Event Supplies', reason: { - carNumber: 0, + carNumber: 25, projectNumber: 1, workPackageNumber: 0 }, diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 6ae11e728b..062d17e693 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -286,6 +286,40 @@ const performSeed: () => Promise = async () => { } }); + const car24 = await prisma.car.create({ + data: { + wbsElement: { + create: { + name: 'NER-24', + carNumber: 24, + projectNumber: 0, + workPackageNumber: 0, + organizationId + } + } + }, + include: { + wbsElement: true + } + }); + + const car25 = await prisma.car.create({ + data: { + wbsElement: { + create: { + name: 'NER-25', + carNumber: 25, + projectNumber: 0, + workPackageNumber: 0, + organizationId + } + } + }, + include: { + wbsElement: true + } + }); + const miles = await prisma.car.create({ data: { wbsElement: { @@ -304,11 +338,11 @@ const performSeed: () => Promise = async () => { }); /** - * Make an initial change request for car 1 using the wbs of the genesis project + * Make an initial change request for NER-25 using the wbs of the genesis project */ const changeRequest1: StandardChangeRequest = await ChangeRequestsService.createStandardChangeRequest( cyborg, - fergus.wbsElement.carNumber, + car25.wbsElement.carNumber, fergus.wbsElement.projectNumber, fergus.wbsElement.workPackageNumber, CR_Type.OTHER, @@ -577,7 +611,7 @@ const performSeed: () => Promise = async () => { } = await seedProject( thomasEmrax, changeRequest1.crId, - fergus.wbsElement.carNumber, + car25.wbsElement.carNumber, 'Impact Attenuator', 'Develop rules-compliant impact attenuator', [huskies.teamId], @@ -605,7 +639,7 @@ const performSeed: () => Promise = async () => { const { projectWbsNumber: project2WbsNumber, projectId: project2Id } = await seedProject( thomasEmrax, changeRequest1.crId, - fergus.wbsElement.carNumber, + car25.wbsElement.carNumber, 'Bodywork', 'Develop rules-compliant bodywork', [huskies.teamId], @@ -633,7 +667,7 @@ const performSeed: () => Promise = async () => { const { projectWbsNumber: project3WbsNumber, projectId: project3Id } = await seedProject( thomasEmrax, changeRequest1.crId, - fergus.wbsElement.carNumber, + car25.wbsElement.carNumber, 'Battery Box', 'Develop rules-compliant battery box.', [huskies.teamId], @@ -661,7 +695,7 @@ const performSeed: () => Promise = async () => { const { projectWbsNumber: project4WbsNumber, projectId: project4Id } = await seedProject( thomasEmrax, changeRequest1.crId, - fergus.wbsElement.carNumber, + car25.wbsElement.carNumber, 'Motor Controller Integration', 'Develop rules-compliant motor controller integration.', [huskies.teamId], @@ -693,7 +727,7 @@ const performSeed: () => Promise = async () => { } = await seedProject( thomasEmrax, changeRequest1.crId, - fergus.wbsElement.carNumber, + car25.wbsElement.carNumber, 'Wiring Harness', 'Develop rules-compliant wiring harness.', [slackBotTeam.teamId], @@ -721,7 +755,7 @@ const performSeed: () => Promise = async () => { const { projectWbsNumber: project6WbsNumber, projectId: project6Id } = await seedProject( aang, changeRequest1.crId, - 0, + car25.wbsElement.carNumber, 'Appa Plush', 'Manufacture plushes of Appa for moral support.', [avatarBenders.teamId], @@ -749,7 +783,7 @@ const performSeed: () => Promise = async () => { const { projectWbsNumber: project7WbsNumber, projectId: project7Id } = await seedProject( lexLuther, changeRequest1.crId, - 0, + car25.wbsElement.carNumber, 'Laser Cannon Prototype', 'Develop a prototype of a laser cannon for the Justice League', [justiceLeague.teamId], @@ -777,7 +811,7 @@ const performSeed: () => Promise = async () => { const { projectWbsNumber: project8WbsNumber } = await seedProject( ryanGiggs, changeRequest1.crId, - 0, + car25.wbsElement.carNumber, 'Stadium Renovation', `Renovate the team's stadium to improve fan experience`, [ravens.teamId], @@ -805,7 +839,7 @@ const performSeed: () => Promise = async () => { const { projectWbsNumber: project9WbsNumber } = await seedProject( glen, changeRequest1.crId, - 0, + car25.wbsElement.carNumber, 'Community Outreach Program', 'Initiate a community outreach program to engage with local schools', [slackBotTeam.teamId], @@ -2288,7 +2322,7 @@ const performSeed: () => Promise = async () => { '1', thomasEmrax, { - carNumber: 0, + carNumber: car25.wbsElement.carNumber, projectNumber: 1, workPackageNumber: 0 }, @@ -2302,7 +2336,7 @@ const performSeed: () => Promise = async () => { 'Resistor', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', { - carNumber: 0, + carNumber: car25.wbsElement.carNumber, projectNumber: 1, workPackageNumber: 0 }, @@ -2325,7 +2359,7 @@ const performSeed: () => Promise = async () => { 'Resistor', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', { - carNumber: 0, + carNumber: car25.wbsElement.carNumber, projectNumber: 1, workPackageNumber: 0 }, @@ -2348,7 +2382,7 @@ const performSeed: () => Promise = async () => { 'Resistor', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', { - carNumber: 0, + carNumber: car25.wbsElement.carNumber, projectNumber: 1, workPackageNumber: 0 }, @@ -2375,7 +2409,7 @@ const performSeed: () => Promise = async () => { [thomasEmrax.userId, batman.userId], [superman.userId, wonderwoman.userId], { - carNumber: 0, + carNumber: car25.wbsElement.carNumber, projectNumber: 1, workPackageNumber: 0 }, diff --git a/src/backend/src/routes/cars.routes.ts b/src/backend/src/routes/cars.routes.ts index cb81d94916..91b91cfac0 100644 --- a/src/backend/src/routes/cars.routes.ts +++ b/src/backend/src/routes/cars.routes.ts @@ -5,6 +5,8 @@ const carsRouter = express.Router(); carsRouter.get('/', CarsController.getAllCars); +carsRouter.get('/current', CarsController.getCurrentCar); + carsRouter.post('/create', CarsController.createCar); export default carsRouter; diff --git a/src/backend/src/routes/work-packages.routes.ts b/src/backend/src/routes/work-packages.routes.ts index 328bcad2a5..b3aa8ac529 100644 --- a/src/backend/src/routes/work-packages.routes.ts +++ b/src/backend/src/routes/work-packages.routes.ts @@ -62,7 +62,9 @@ workPackagesRouter.post( WorkPackagesController.editWorkPackage ); workPackagesRouter.delete('/:wbsNum/delete', WorkPackagesController.deleteWorkPackage); + workPackagesRouter.get('/:wbsNum/blocking', WorkPackagesController.getBlockingWorkPackages); + workPackagesRouter.post( '/slack-upcoming-deadlines', isDateOnly(body('deadline')), diff --git a/src/backend/src/services/car.services.ts b/src/backend/src/services/car.services.ts index 6e10a1baf2..f23b71dbf3 100644 --- a/src/backend/src/services/car.services.ts +++ b/src/backend/src/services/car.services.ts @@ -53,4 +53,26 @@ export default class CarsService { return carTransformer(car); } + + static async getCurrentCar(organization: Organization) { + const car = await prisma.car.findFirst({ + where: { + wbsElement: { + organizationId: organization.organizationId + } + }, + orderBy: { + wbsElement: { + carNumber: 'desc' + } + }, + ...getCarQueryArgs(organization.organizationId) + }); + + if (!car) { + return null; + } + + return carTransformer(car); + } } diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index 27202c9c16..b62b3cc17f 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -88,9 +88,13 @@ export default class ChangeRequestsService { * @param organization The organization the user is currently in * @returns All of the change requests */ - static async getAllChangeRequests(organization: Organization): Promise { + static async getAllChangeRequests(organization: Organization, carId?: string): Promise { const changeRequests = await prisma.change_Request.findMany({ - where: { dateDeleted: null, organizationId: organization.organizationId }, + where: { + dateDeleted: null, + organizationId: organization.organizationId, + ...(carId && { wbsElement: { OR: [{ project: { carId } }, { workPackage: { project: { carId } } }] } }) + }, ...getManyChangeRequestQueryArgs(organization.organizationId) }); @@ -104,7 +108,7 @@ export default class ChangeRequestsService { * @param organization The organization the user is in * @returns The user's change requests for them to review */ - static async getToReviewChangeRequests(user: User, organization: Organization): Promise { + static async getToReviewChangeRequests(user: User, organization: Organization, carId?: string): Promise { const wbsOr: Prisma.WBS_ElementWhereInput[] = [{ managerId: user.userId }, { leadId: user.userId }]; if (await userHasPermission(user.userId, organization.organizationId, isLeadership)) { @@ -148,7 +152,8 @@ export default class ChangeRequestsService { }, { NOT: [{ scopeChangeRequest: null }, { submitterId: user.userId }] - } + }, + ...(carId ? [{ wbsElement: { OR: [{ project: { carId } }, { workPackage: { project: { carId } } }] } }] : []) ], organizationId: organization.organizationId, OR: queryOr @@ -170,7 +175,8 @@ export default class ChangeRequestsService { static async getUnreviewedChangeRequests( user: User, wbsnum: WbsNumber | undefined, - organization: Organization + organization: Organization, + carId?: string ): Promise { // Check that its unreviewed and a scope change request, omit activation and stage gate const queryAnd: Prisma.Change_RequestWhereInput[] = [ @@ -183,7 +189,12 @@ export default class ChangeRequestsService { ]; if (wbsnum) queryAnd.push({ wbsElementId: (await validateWbsElement(wbsnum, organization)).wbsElementId }); - else queryAnd.push({ submitterId: user.userId }); + else { + queryAnd.push({ submitterId: user.userId }); + queryAnd.push( + ...(carId ? [{ wbsElement: { OR: [{ project: { carId } }, { workPackage: { project: { carId } } }] } }] : []) + ); + } const changeRequests = await prisma.change_Request.findMany({ where: { @@ -208,13 +219,17 @@ export default class ChangeRequestsService { static async getApprovedChangeRequests( user: User, wbsnum: WbsNumber | undefined, - organization: Organization + organization: Organization, + carId?: string ): Promise { const currentDate = new Date(); const fiveDaysAgo = new Date(currentDate.getTime() - 1000 * 60 * 60 * 24 * 5); // Change requests that were reviewed less than five days ago const queryAnd = wbsnum ? [{ wbsElementId: (await validateWbsElement(wbsnum, organization)).wbsElementId }] - : [{ submitterId: user.userId }]; + : [ + { submitterId: user.userId }, + ...(carId ? [{ wbsElement: { OR: [{ project: { carId } }, { workPackage: { project: { carId } } }] } }] : []) + ]; const changeRequests = await prisma.change_Request.findMany({ where: { diff --git a/src/backend/src/services/projects.services.ts b/src/backend/src/services/projects.services.ts index f0684d5885..5267a81529 100644 --- a/src/backend/src/services/projects.services.ts +++ b/src/backend/src/services/projects.services.ts @@ -47,11 +47,12 @@ export default class ProjectsService { /** * Get all the non deleted projects in the database for the given organization * @param organization the organization the user is currently in + * @param carId optional car id to filter projects by * @returns all the projects with query args for use in the gantt chart */ - static async getAllProjectsGantt(organization: Organization): Promise { + static async getAllProjectsGantt(organization: Organization, carId?: string): Promise { const projects = await prisma.project.findMany({ - where: { wbsElement: { dateDeleted: null, organizationId: organization.organizationId } }, + where: { wbsElement: { dateDeleted: null, organizationId: organization.organizationId }, ...(carId && { carId }) }, ...getProjectGanttQueryArgs(organization.organizationId) }); @@ -61,11 +62,12 @@ export default class ProjectsService { /** * Get all projects for given organization * @param organization the organization the user is in + * @param carId optional car id to filter projects by * @returns all the projects with preview query args */ - static async getAllProjects(organization: Organization): Promise { + static async getAllProjects(organization: Organization, carId?: string): Promise { const projects = await prisma.project.findMany({ - where: { wbsElement: { dateDeleted: null, organizationId: organization.organizationId } }, + where: { wbsElement: { dateDeleted: null, organizationId: organization.organizationId }, ...(carId && { carId }) }, orderBy: { wbsElement: { dateCreated: 'desc' } }, ...getProjectPreviewQueryArgs(organization.organizationId) }); @@ -77,16 +79,18 @@ export default class ProjectsService { * Get all projects that the user is the lead or manager of * @param user the user making the request * @param organization the oranization the user is in + * @param carId optional car id to filter projects by * @returns the projects the user is a lead or manager of with preview query args */ - static async getUsersLeadingProjects(user: User, organization: Organization): Promise { + static async getUsersLeadingProjects(user: User, organization: Organization, carId?: string): Promise { const projects = await prisma.project.findMany({ where: { wbsElement: { organizationId: organization.organizationId, dateDeleted: null, OR: [{ leadId: user.userId }, { managerId: user.userId }] - } + }, + ...(carId && { carId }) }, ...getProjectOverviewQueryArgs(organization.organizationId) }); @@ -98,9 +102,10 @@ export default class ProjectsService { * Get all projects related to teams the user is on * @param user the user making the request * @param organization the organization the user is in + * @param carId optional car id to filter projects by * @returns all projects associated with teams the user is on with overview card query args */ - static async getUsersTeamsProjects(user: User, organization: Organization): Promise { + static async getUsersTeamsProjects(user: User, organization: Organization, carId?: string): Promise { const projects = await prisma.project.findMany({ where: { wbsElement: { @@ -129,7 +134,8 @@ export default class ProjectsService { } ] } - } + }, + ...(carId && { carId }) }, ...getProjectOverviewQueryArgs(organization.organizationId) }); @@ -141,9 +147,10 @@ export default class ProjectsService { * Get the projects for a given team * @param organization * @param teamId + * @param carId optional car id to filter projects by * @returns all the projects for the given team with full project query args */ - static async getTeamsProjects(organization: Organization, teamId: string): Promise { + static async getTeamsProjects(organization: Organization, teamId: string, carId?: string): Promise { const projects = await prisma.project.findMany({ where: { wbsElement: { @@ -154,7 +161,8 @@ export default class ProjectsService { some: { teamId } - } + }, + ...(carId && { carId }) }, ...getProjectQueryArgs(organization.organizationId) }); diff --git a/src/backend/src/services/work-packages.services.ts b/src/backend/src/services/work-packages.services.ts index 5db4b1a99f..1c1334ab0a 100644 --- a/src/backend/src/services/work-packages.services.ts +++ b/src/backend/src/services/work-packages.services.ts @@ -46,21 +46,26 @@ export default class WorkPackagesService { * * @param query the filters on the query * @param organizationId the id of the organization that the user is currently in + * @param carId the car number to filter by (only returns work packages from this car when provided) * @returns a list of work packages */ static async getAllWorkPackages( query: { status?: WbsElementStatus; daysUntilDeadline?: string; + carId?: string; }, organization: Organization ): Promise { const workPackages = await prisma.work_Package.findMany({ - where: { wbsElement: { dateDeleted: null, organizationId: organization.organizationId } }, + where: { + wbsElement: { dateDeleted: null, organizationId: organization.organizationId }, + ...(query.carId && { project: { carId: query.carId } }) + }, ...getWorkPackageQueryArgs(organization.organizationId) }); - const outputWorkPackages = workPackages.map(workPackageTransformer).filter((wp) => { + const filteredWorkPackages = workPackages.map(workPackageTransformer).filter((wp) => { let passes = true; if (query.status) passes &&= wp.status === query.status; if (query.daysUntilDeadline) { @@ -70,9 +75,9 @@ export default class WorkPackagesService { return passes; }); - outputWorkPackages.sort((wpA, wpB) => wpA.endDate.getTime() - wpB.endDate.getTime()); + filteredWorkPackages.sort((wpA, wpB) => wpA.endDate.getTime() - wpB.endDate.getTime()); - return outputWorkPackages; + return filteredWorkPackages; } /** @@ -80,18 +85,21 @@ export default class WorkPackagesService { * * @param status Optional status filter * @param organization the organization + * @param carId the car number to filter by (only returns work packages from this car when provided) * @returns a list of work package previews */ static async getAllWorkPackagesPreview( status: WbsElementStatus | string | undefined, - organization: Organization + organization: Organization, + carId?: string ): Promise { const workPackages = await prisma.work_Package.findMany({ where: { wbsElement: { dateDeleted: null, organizationId: organization.organizationId, - ...(status ? { status: status as WbsElementStatus } : {}) + ...(status ? { status: status as WbsElementStatus } : {}), + ...(carId ? { project: { carId } } : {}) } }, ...getWorkPackagePreviewQueryArgs() @@ -141,10 +149,15 @@ export default class WorkPackagesService { * Retrieve a subset of work packages. * @param wbsNums the WBS numbers of the work packages to retrieve * @param organizationId the id of the organization that the user is currently in + * @param carId optional car number to filter work packages by * @returns the work packages with the given WBS numbers * @throws if any of the work packages are not found or are not part of the organization */ - static async getManyWorkPackages(wbsNums: WbsNumber[], organization: Organization): Promise { + static async getManyWorkPackages( + wbsNums: WbsNumber[], + organization: Organization, + carId?: string + ): Promise { wbsNums.forEach((wbsNum) => { if (!isWorkPackageWbs(wbsNum)) { throw new HttpException( @@ -154,12 +167,25 @@ export default class WorkPackagesService { } }); - const workPackagePromises = wbsNums.map(async (wbsNum) => { - return WorkPackagesService.getSingleWorkPackage(wbsNum, organization); + const whereConditions = wbsNums.map((wbsNum) => ({ + wbsElement: { + carNumber: wbsNum.carNumber, + projectNumber: wbsNum.projectNumber, + workPackageNumber: wbsNum.workPackageNumber, + organizationId: organization.organizationId, + dateDeleted: null + }, + ...(carId && { project: { carId } }) + })); + + const workPackages = await prisma.work_Package.findMany({ + where: { + OR: whereConditions + }, + ...getWorkPackageQueryArgs(organization.organizationId) }); - const resolvedWorkPackages = await Promise.all(workPackagePromises); - return resolvedWorkPackages; + return workPackages.map(workPackageTransformer); } /** @@ -518,9 +544,14 @@ export default class WorkPackagesService { * Gets the work packages the given work package is blocking * @param wbsNum the wbs number of the work package to get the blocking work packages for * @param organizationId the id of the organization that the user is currently in + * @param carId the optional carId to filter work packages by * @returns the blocking work packages for the given work package */ - static async getBlockingWorkPackages(wbsNum: WbsNumber, organization: Organization): Promise { + static async getBlockingWorkPackages( + wbsNum: WbsNumber, + organization: Organization, + carId?: string + ): Promise { const { carNumber, projectNumber, workPackageNumber } = wbsNum; // is a project or car so just return empty array until we implement blocking projects/cars @@ -549,7 +580,11 @@ export default class WorkPackagesService { const blockingWorkPackages = await getBlockingWorkPackages(workPackage); - return blockingWorkPackages.map(workPackageTransformer); + const filteredWorkPackages = carId + ? blockingWorkPackages.filter((wp) => wp.project.carId === carId) + : blockingWorkPackages; + + return filteredWorkPackages.map(workPackageTransformer); } /** @@ -593,12 +628,14 @@ export default class WorkPackagesService { * * @param user The current user * @param organization The organization the current user is logged in for - * @param onlyOverdue Whether to only return overdue workpackages + * @param selection The selection type for filtering workpackages + * @param carId Optional car number to filter work packages by */ static async getHomePageWorkPackages( user: User, organization: Organization, - selection: WorkPackageSelection + selection: WorkPackageSelection, + carId?: string ): Promise { const selectionArgs = selection === WorkPackageSelection.ALL_OVERDUE @@ -634,7 +671,8 @@ export default class WorkPackagesService { dateDeleted: null, organizationId: organization.organizationId, status: { not: WBS_Element_Status.COMPLETE } - } + }, + ...(carId && { project: { carId } }) }, select: { project: { select: { projectId: true, wbsElement: { select: { name: true } } } }, diff --git a/src/backend/src/utils/car.utils.ts b/src/backend/src/utils/car.utils.ts new file mode 100644 index 0000000000..c0df3ea59d --- /dev/null +++ b/src/backend/src/utils/car.utils.ts @@ -0,0 +1,26 @@ +import { Request, Response, NextFunction } from 'express'; +import prisma from '../prisma/prisma.js'; +import { NotFoundException } from './errors.utils.js'; + +export const getCurrentCar = async (req: Request, _res: Response, next: NextFunction) => { + const carId = req.headers.carid; + + if (!carId || typeof carId !== 'string') { + return next(); + } + + try { + const car = await prisma.car.findUnique({ + where: { carId } + }); + + if (!car) { + throw new NotFoundException('Car', carId); + } + + req.currentCar = car; + return next(); + } catch (error) { + return next(error); + } +}; diff --git a/src/backend/tests/unmocked/cars.test.ts b/src/backend/tests/unmocked/cars.test.ts new file mode 100644 index 0000000000..0f9d90c689 --- /dev/null +++ b/src/backend/tests/unmocked/cars.test.ts @@ -0,0 +1,344 @@ +import { Organization, User } from '@prisma/client'; +import { createTestOrganization, createTestUser, resetUsers, createTestCar } from '../test-utils'; +import { supermanAdmin, member } from '../test-data/users.test-data'; +import CarsService from '../../src/services/car.services'; +import { AccessDeniedAdminOnlyException } from '../../src/utils/errors.utils'; +import prisma from '../../src/prisma/prisma'; + +describe('Cars Tests', () => { + let org: Organization; + let adminUser: User; + let nonAdminUser: User; + + beforeEach(async () => { + org = await createTestOrganization(); + adminUser = await createTestUser(supermanAdmin, org.organizationId); + nonAdminUser = await createTestUser(member, org.organizationId); + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('getAllCars', () => { + test('getAllCars returns empty array when no cars exist', async () => { + const cars = await CarsService.getAllCars(org); + expect(cars).toEqual([]); + }); + + test('getAllCars returns all cars for organization', async () => { + // Create test cars manually with unique car numbers + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 0, + projectNumber: 0, + workPackageNumber: 0, + name: 'Car 1', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 1, + projectNumber: 0, + workPackageNumber: 0, + name: 'Car 2', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + const cars = await CarsService.getAllCars(org); + expect(cars).toHaveLength(2); + }); + + test('getAllCars only returns cars for specified organization', async () => { + // Create car in our org + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 0, + projectNumber: 0, + workPackageNumber: 0, + name: 'Our Car', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + // Create car in different org + const uniqueId = `${Date.now()}-${Math.random()}`; + const orgCreator = await prisma.user.create({ + data: { + firstName: 'Org', + lastName: 'Creator', + email: `org-${uniqueId}@test.com`, + googleAuthId: `org-${uniqueId}` + } + }); + + const otherOrg = await prisma.organization.create({ + data: { + name: 'Other Org', + description: 'Other organization', + applicationLink: '', + userCreatedId: orgCreator.userId + } + }); + + const otherUser = await createTestUser( + { + ...supermanAdmin, + googleAuthId: `admin-${uniqueId}`, + email: `admin-${uniqueId}@test.com`, + emailId: `admin-${uniqueId}` + }, + otherOrg.organizationId + ); + + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 0, + projectNumber: 0, + workPackageNumber: 0, + name: 'Other Car', + organizationId: otherOrg.organizationId, + leadId: otherUser.userId, + managerId: otherUser.userId + } + } + } + }); + + const cars = await CarsService.getAllCars(org); + expect(cars).toHaveLength(1); + }); + }); + + describe('getCurrentCar', () => { + test('getCurrentCar returns null when no cars exist', async () => { + const currentCar = await CarsService.getCurrentCar(org); + expect(currentCar).toBeNull(); + }); + + test('getCurrentCar returns the only car when one exists', async () => { + const testCar = await createTestCar(org.organizationId, adminUser.userId); + + const currentCar = await CarsService.getCurrentCar(org); + expect(currentCar).not.toBeNull(); + expect(currentCar!.id).toBe(testCar.carId); + }); + + test('getCurrentCar returns car with highest car number', async () => { + // Create multiple cars with different car numbers + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 1, + projectNumber: 0, + workPackageNumber: 0, + name: 'Car 1', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 3, + projectNumber: 0, + workPackageNumber: 0, + name: 'Car 3', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 2, + projectNumber: 0, + workPackageNumber: 0, + name: 'Car 2', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + const currentCar = await CarsService.getCurrentCar(org); + expect(currentCar).not.toBeNull(); + expect(currentCar!.wbsNum.carNumber).toBe(3); + }); + + test('getCurrentCar only considers cars from specified organization', async () => { + // Create car in our org with car number 1 + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 1, + projectNumber: 0, + workPackageNumber: 0, + name: 'Our Car', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + // Create car in different org with higher car number + const uniqueId = `${Date.now()}-${Math.random()}`; + const orgCreator = await prisma.user.create({ + data: { + firstName: 'Org', + lastName: 'Creator', + email: `org-${uniqueId}@test.com`, + googleAuthId: `org-${uniqueId}` + } + }); + + const otherOrg = await prisma.organization.create({ + data: { + name: 'Other Org', + description: 'Other organization', + applicationLink: '', + userCreatedId: orgCreator.userId + } + }); + + const otherUser = await createTestUser( + { + ...supermanAdmin, + googleAuthId: `admin-${uniqueId}`, + email: `admin-${uniqueId}@test.com`, + emailId: `admin-${uniqueId}` + }, + otherOrg.organizationId + ); + + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 5, + projectNumber: 0, + workPackageNumber: 0, + name: 'Other Car', + organizationId: otherOrg.organizationId, + leadId: otherUser.userId, + managerId: otherUser.userId + } + } + } + }); + + const currentCar = await CarsService.getCurrentCar(org); + expect(currentCar).not.toBeNull(); + expect(currentCar!.wbsNum.carNumber).toBe(1); + expect(currentCar!.name).toBe('Our Car'); + }); + }); + + describe('createCar', () => { + test('createCar successfully creates car with admin permissions', async () => { + const carName = 'Test Car'; + + const createdCar = await CarsService.createCar(org, adminUser, carName); + + expect(createdCar.name).toBe(carName); + expect(createdCar.wbsNum.carNumber).toBe(0); // First car should have car number 0 + expect(createdCar.wbsNum.projectNumber).toBe(0); + expect(createdCar.wbsNum.workPackageNumber).toBe(0); + }); + + test('createCar assigns correct car number based on existing cars', async () => { + // Create first car + await CarsService.createCar(org, adminUser, 'Car 1'); + + // Create second car + const secondCar = await CarsService.createCar(org, adminUser, 'Car 2'); + + expect(secondCar.wbsNum.carNumber).toBe(1); // Should be incremented + }); + + test('createCar throws AccessDeniedAdminOnlyException for non-admin user', async () => { + await expect(CarsService.createCar(org, nonAdminUser, 'Test Car')).rejects.toThrow(AccessDeniedAdminOnlyException); + }); + + test('createCar car numbers are organization-specific', async () => { + // Create car in first org + const firstCar = await CarsService.createCar(org, adminUser, 'First Org Car'); + + // Create different org and admin + const uniqueId = `${Date.now()}-${Math.random()}`; + const orgCreator = await prisma.user.create({ + data: { + firstName: 'Org', + lastName: 'Creator', + email: `org2-${uniqueId}@test.com`, + googleAuthId: `org2-${uniqueId}` + } + }); + + const otherOrg = await prisma.organization.create({ + data: { + name: 'Second Org', + description: 'Second organization', + applicationLink: '', + userCreatedId: orgCreator.userId + } + }); + + const otherAdmin = await createTestUser( + { + ...supermanAdmin, + googleAuthId: `admin2-${uniqueId}`, + email: `admin2-${uniqueId}@test.com`, + emailId: `admin2-${uniqueId}` + }, + otherOrg.organizationId + ); + + // Create car in second org + const secondCar = await CarsService.createCar(otherOrg, otherAdmin, 'Second Org Car'); + + // Both should start from car number 0 + expect(firstCar.wbsNum.carNumber).toBe(0); + expect(secondCar.wbsNum.carNumber).toBe(0); + }); + }); +}); diff --git a/src/frontend/src/apis/cars.api.ts b/src/frontend/src/apis/cars.api.ts index b1869c606a..28efd2ab2f 100644 --- a/src/frontend/src/apis/cars.api.ts +++ b/src/frontend/src/apis/cars.api.ts @@ -7,6 +7,10 @@ export const getAllCars = async () => { return await axios.get(apiUrls.cars()); }; +export const getCurrentCar = async () => { + return await axios.get(apiUrls.carsCurrent()); +}; + export const createCar = async (payload: CreateCarPayload) => { return await axios.post(apiUrls.carsCreate(), payload); }; diff --git a/src/frontend/src/app/AppAuthenticated.tsx b/src/frontend/src/app/AppAuthenticated.tsx index 0094ddba8c..4e1f1b56e3 100644 --- a/src/frontend/src/app/AppAuthenticated.tsx +++ b/src/frontend/src/app/AppAuthenticated.tsx @@ -31,6 +31,7 @@ import ArrowCircleRightTwoToneIcon from '@mui/icons-material/ArrowCircleRightTwo import HiddenContentMargin from '../components/HiddenContentMargin'; import { useHomePageContext } from './HomePageContext'; import { useCurrentOrganization } from '../hooks/organizations.hooks'; +import { GlobalCarFilterProvider } from './AppGlobalCarFilterContext'; import Statistics from '../pages/StatisticsPage/Statistics'; import RetrospectiveGanttChartPage from '../pages/RetrospectivePage/Retrospective'; import Calendar from '../pages/CalendarPage/Calendar'; @@ -68,76 +69,80 @@ const AppAuthenticated: React.FC = ({ userId, userRole }) return ; } - return userSettingsData.slackId || isGuest(userRole) ? ( - - { - <> - { - setDrawerOpen(true); - }} - sx={{ - height: '100vh', - position: 'fixed', - width: 15, - borderRight: 2, - borderRightColor: theme.palette.background.paper - }} - /> - { - setDrawerOpen(true); - setMoveContent(true); - }} - sx={{ position: 'fixed', left: -8, top: '3%' }} - id="sidebar-button" - > - - - - - } - - - - - - - - - - - - - - - - - - - - - - - - - ) : ( - + return ( + + {userSettingsData.slackId || isGuest(userRole) ? ( + + { + <> + { + setDrawerOpen(true); + }} + sx={{ + height: '100vh', + position: 'fixed', + width: 15, + borderRight: 2, + borderRightColor: theme.palette.background.paper + }} + /> + { + setDrawerOpen(true); + setMoveContent(true); + }} + sx={{ position: 'fixed', left: -8, top: '3%' }} + id="sidebar-button" + > + + + + + } + + + + + + + + + + + + + + + + + + + + + + + + + ) : ( + + )} + ); }; diff --git a/src/frontend/src/app/AppGlobalCarFilterContext.tsx b/src/frontend/src/app/AppGlobalCarFilterContext.tsx new file mode 100644 index 0000000000..d724e61c64 --- /dev/null +++ b/src/frontend/src/app/AppGlobalCarFilterContext.tsx @@ -0,0 +1,76 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import React, { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react'; +import { Car } from 'shared'; +import { useGetAllCars } from '../hooks/cars.hooks'; +import { setCurrentCarId } from '../utils/axios'; + +interface GlobalCarFilterContextType { + selectedCar: Car | null; + allCars: Car[]; + setSelectedCar: (car: Car | null) => void; + isLoading: boolean; + error: Error | null; +} + +const GlobalCarFilterContext = createContext(undefined); + +interface GlobalCarFilterProviderProps { + children: ReactNode; +} + +export const GlobalCarFilterProvider: React.FC = ({ children }) => { + const [selectedCar, setSelectedCarState] = useState(null); + const hasInitialized = useRef(false); + + const { data: allCars = [], isLoading, error } = useGetAllCars(); + + useEffect(() => { + if (!isLoading && !hasInitialized.current) { + hasInitialized.current = true; + + const savedCarName = sessionStorage.getItem('selectedCarName'); + if (savedCarName) { + const savedCar = allCars.find((car) => car.name === savedCarName); + if (savedCar) { + setSelectedCar(savedCar); + return; + } + } + + // Default to null (all cars) + setSelectedCarState(null); + } + }, [allCars, isLoading]); + + const setSelectedCar = (car: Car | null) => { + setSelectedCarState(car); + setCurrentCarId(car ? car.id : null); + if (car) { + sessionStorage.setItem('selectedCarName', car.name); + } else { + sessionStorage.removeItem('selectedCarName'); + } + }; + + const value: GlobalCarFilterContextType = { + selectedCar, + allCars, + setSelectedCar, + isLoading, + error + }; + + return {children}; +}; + +export const useGlobalCarFilter = (): GlobalCarFilterContextType => { + const context = useContext(GlobalCarFilterContext); + if (context === undefined) { + throw new Error('useGlobalCarFilter must be used within a GlobalCarFilterProvider'); + } + return context; +}; diff --git a/src/frontend/src/components/FinanceDashboardCarFilter.tsx b/src/frontend/src/components/FinanceDashboardCarFilter.tsx new file mode 100644 index 0000000000..a0cde006bd --- /dev/null +++ b/src/frontend/src/components/FinanceDashboardCarFilter.tsx @@ -0,0 +1,152 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import React from 'react'; +import { Box, Typography, Tooltip, FormControl, FormLabel } from '@mui/material'; +import { HelpOutline as HelpIcon } from '@mui/icons-material'; +import { DatePicker } from '@mui/x-date-pickers'; +import NERAutocomplete from './NERAutocomplete'; +import type { FinanceDashboardCarFilter as FinanceDashboardCarFilterType } from '../hooks/finance-car-filter.hooks'; + +interface FinanceDashboardCarFilterProps { + filter: FinanceDashboardCarFilterType; + sx?: object; + size?: 'small' | 'medium'; + controlSx?: object; +} + +const FinanceDashboardCarFilterComponent: React.FC = ({ + filter, + sx = {}, + size = 'small', + controlSx = {} +}) => { + const { selectedCar, allCars, startDate, endDate, setSelectedCar, setStartDate, setEndDate, isLoading } = filter; + + const sortedCars = [...allCars].sort((a, b) => b.wbsNum.carNumber - a.wbsNum.carNumber); + + const carAutocompleteOptions = sortedCars.map((car) => ({ + label: car.wbsNum.carNumber === 0 ? car.name : `${car.name} (Car ${car.wbsNum.carNumber})`, + id: car.id, + carNumber: car.wbsNum.carNumber + })); + + const handleCarChange = (_event: any, newValue: any) => { + if (newValue) { + const car = allCars.find((c) => c.id === newValue.id); + if (car) setSelectedCar(car); + } + }; + + const selectedCarOption = selectedCar ? carAutocompleteOptions.find((option) => option.id === selectedCar.id) : null; + + if (isLoading) { + return ( + + Loading car data... + + ); + } + + return ( + + + + Car Filter + + + + + + + + + + Start Date + + + + + (endDate ? date > endDate : false)} + slotProps={{ + textField: { + size, + sx: { minWidth: 150, ...controlSx } + }, + field: { clearable: true } + }} + onChange={(newValue: Date | null) => setStartDate(newValue ?? undefined)} + /> + + + + + End Date + + + + + (startDate ? date < startDate : false)} + slotProps={{ + textField: { + size, + sx: { minWidth: 150, ...controlSx } + }, + field: { clearable: true } + }} + onChange={(newValue: Date | null) => setEndDate(newValue ?? undefined)} + /> + + + {selectedCar && ( + + + Filtering by: {selectedCar.name} + + {startDate && endDate && ( + + {new Date(startDate).toLocaleDateString()} - {new Date(endDate).toLocaleDateString()} + + )} + + )} + + ); +}; + +export default FinanceDashboardCarFilterComponent; diff --git a/src/frontend/src/components/GlobalCarFilterDropdown.tsx b/src/frontend/src/components/GlobalCarFilterDropdown.tsx new file mode 100644 index 0000000000..bc0e0b7494 --- /dev/null +++ b/src/frontend/src/components/GlobalCarFilterDropdown.tsx @@ -0,0 +1,162 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import React, { useState } from 'react'; +import { Box, Typography, Chip, Collapse, IconButton } from '@mui/material'; +import { ExpandMore as ExpandMoreIcon, DirectionsCar as CarIcon } from '@mui/icons-material'; +import { Car } from 'shared'; +import { useGlobalCarFilter } from '../app/AppGlobalCarFilterContext'; +import LoadingIndicator from './LoadingIndicator'; + +interface GlobalCarFilterDropdownProps { + compact?: boolean; + sx?: object; +} + +const GlobalCarFilterDropdown: React.FC = ({ compact = false, sx = {} }) => { + const { selectedCar, allCars, setSelectedCar, isLoading, error } = useGlobalCarFilter(); + const [expanded, setExpanded] = useState(false); + + const handleToggle = () => { + setExpanded(!expanded); + }; + + const handleCarSelect = (car: Car | null) => { + setSelectedCar(car); + setExpanded(false); + }; + + if (isLoading) { + return ; + } + + if (error) { + return ( + + + {error.message} + + + ); + } + + if (allCars.length === 0) { + return ( + + + No cars available + + + ); + } + + const sortedCars = [...allCars].sort((a, b) => b.wbsNum.carNumber - a.wbsNum.carNumber); + + const currentCarLabel = selectedCar ? selectedCar.name : 'All Cars'; + + if (compact) { + return ( + + + + + + Working with: + + + {currentCarLabel} + + + + + + + + + handleCarSelect(null)} + variant="outlined" + sx={{ + borderColor: 'white', + color: 'white', + backgroundColor: 'transparent', + fontWeight: !selectedCar ? 'bold' : 'normal', + borderWidth: !selectedCar ? 2 : 1, + '&:hover': { backgroundColor: 'rgba(255,255,255,0.1)' }, + whiteSpace: 'nowrap' + }} + /> + {sortedCars.map((car) => { + const isSelected = selectedCar ? car.id === selectedCar.id : false; + return ( + handleCarSelect(car)} + variant="outlined" + sx={{ + borderColor: 'white', + color: 'white', + backgroundColor: 'transparent', + fontWeight: isSelected ? 'bold' : 'normal', + borderWidth: isSelected ? 2 : 1, + '&:hover': { backgroundColor: 'rgba(255,255,255,0.1)' }, + whiteSpace: 'nowrap' + }} + /> + ); + })} + + + + ); + } + + // Non-compact mode (not used in current implementation) + return ( + + + Working with: + + + + + {currentCarLabel} + + + + ); +}; + +export default GlobalCarFilterDropdown; diff --git a/src/frontend/src/hooks/cars.hooks.ts b/src/frontend/src/hooks/cars.hooks.ts index 53b0f9c02f..053cfa1f6a 100644 --- a/src/frontend/src/hooks/cars.hooks.ts +++ b/src/frontend/src/hooks/cars.hooks.ts @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from 'react-query'; import { Car } from 'shared'; -import { createCar, getAllCars } from '../apis/cars.api'; +import { createCar, getAllCars, getCurrentCar } from '../apis/cars.api'; export interface CreateCarPayload { name: string; @@ -16,6 +16,16 @@ export const useGetAllCars = () => { }); }; +/** + * Custom React Hook to get the current car (most recent car by car number). + */ +export const useGetCurrentCar = () => { + return useQuery(['cars', 'current'], async () => { + const { data } = await getCurrentCar(); + return data; + }); +}; + //TODO Move this logic to backend export const useGetCarsByIds = (ids: Set) => { return useQuery(['cars'], async () => { diff --git a/src/frontend/src/hooks/change-requests.hooks.ts b/src/frontend/src/hooks/change-requests.hooks.ts index 84061fac51..0d405e19a0 100644 --- a/src/frontend/src/hooks/change-requests.hooks.ts +++ b/src/frontend/src/hooks/change-requests.hooks.ts @@ -4,6 +4,7 @@ */ import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { useGlobalCarFilter } from '../app/AppGlobalCarFilterContext'; import { ChangeRequest, ChangeRequestReason, @@ -35,28 +36,32 @@ import { * Custom React Hook to supply all change requests. */ export const useAllChangeRequests = () => { - return useQuery(['change requests'], async () => { + const { selectedCar } = useGlobalCarFilter(); + return useQuery(['change requests', selectedCar?.id], async () => { const { data } = await getAllChangeRequests(); return data; }); }; export const useGetToReviewChangeRequests = () => { - return useQuery(['change requests', 'to-review'], async () => { + const { selectedCar } = useGlobalCarFilter(); + return useQuery(['change requests', 'to-review', selectedCar?.id], async () => { const { data } = await getToReviewChangeRequests(); return data; }); }; export const useGetUnreviewedChangeRequests = (wbsNum?: WbsNumber) => { - return useQuery(['change requests', 'unreviewed'], async () => { + const { selectedCar } = useGlobalCarFilter(); + return useQuery(['change requests', 'unreviewed', selectedCar?.id], async () => { const { data } = await getUnreviewedChangeRequests(wbsNum); return data; }); }; export const useGetApprovedChangeRequests = (wbsNum?: WbsNumber) => { - return useQuery(['change requests', 'approved'], async () => { + const { selectedCar } = useGlobalCarFilter(); + return useQuery(['change requests', 'approved', selectedCar?.id], async () => { const { data } = await getApprovedChangeRequests(wbsNum); return data; }); diff --git a/src/frontend/src/hooks/finance-car-filter.hooks.ts b/src/frontend/src/hooks/finance-car-filter.hooks.ts new file mode 100644 index 0000000000..ed7d69fe1f --- /dev/null +++ b/src/frontend/src/hooks/finance-car-filter.hooks.ts @@ -0,0 +1,100 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { useEffect, useState } from 'react'; +import { Car } from 'shared'; +import { useGlobalCarFilter } from '../app/AppGlobalCarFilterContext'; + +export interface FinanceDashboardCarFilter { + selectedCar: Car | null; + allCars: Car[]; + startDate: Date | undefined; + endDate: Date | undefined; + carNumber: number | undefined; + setSelectedCar: (car: Car) => void; + setStartDate: (date: Date | undefined) => void; + setEndDate: (date: Date | undefined) => void; + isLoading: boolean; + error: Error | null; +} + +/** + * Hook for Finance Dashboard car filtering with automatic date population + * When a car is selected, it populates: + * - Start date: When the car was initialized (car.dateCreated) + * - End date: Today (if current car) or end date of that car (if previous car) + */ +export const useFinanceDashboardCarFilter = ( + initialStartDate?: Date, + initialEndDate?: Date, + initialCarNumber?: number +): FinanceDashboardCarFilter => { + const { selectedCar, allCars, setSelectedCar: setGlobalSelectedCar, isLoading, error } = useGlobalCarFilter(); + + const [startDate, setStartDate] = useState(initialStartDate); + const [endDate, setEndDate] = useState(initialEndDate); + + useEffect(() => { + if (initialCarNumber !== undefined && allCars.length > 0 && !selectedCar) { + const initialCar = allCars.find((car) => car.wbsNum.carNumber === initialCarNumber); + if (initialCar) { + setGlobalSelectedCar(initialCar); + } + } + }, [initialCarNumber, allCars, selectedCar, setGlobalSelectedCar]); + useEffect(() => { + if (selectedCar && allCars.length > 0) { + setStartDate(selectedCar.dateCreated); + + const isCurrentCar = isCarCurrent(selectedCar, allCars); + if (isCurrentCar) { + setEndDate(new Date()); + } else { + const nextCar = findNextCar(selectedCar, allCars); + if (nextCar) { + setEndDate(nextCar.dateCreated); + } else { + setEndDate(new Date()); + } + } + } + }, [selectedCar, allCars]); + + const setSelectedCar = (car: Car) => { + setGlobalSelectedCar(car); + }; + + return { + selectedCar, + allCars, + startDate, + endDate, + carNumber: selectedCar?.wbsNum.carNumber, + setSelectedCar, + setStartDate, + setEndDate, + isLoading, + error + }; +}; + +/** + * Determines if the given car is the current/most recent car + */ +const isCarCurrent = (car: Car, allCars: Car[]): boolean => { + const maxCarNumber = Math.max(...allCars.map((c) => c.wbsNum.carNumber)); + return car.wbsNum.carNumber === maxCarNumber; +}; + +/** + * Finds the next car in chronological order (by car number) + */ +const findNextCar = (car: Car, allCars: Car[]): Car | null => { + const sortedCars = allCars + .filter((c) => c.wbsNum.carNumber > car.wbsNum.carNumber) + .sort((a, b) => a.wbsNum.carNumber - b.wbsNum.carNumber); + + return sortedCars[0] || null; +}; diff --git a/src/frontend/src/hooks/page-car-filter.hooks.ts b/src/frontend/src/hooks/page-car-filter.hooks.ts new file mode 100644 index 0000000000..0947da4068 --- /dev/null +++ b/src/frontend/src/hooks/page-car-filter.hooks.ts @@ -0,0 +1,94 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { useEffect, useState } from 'react'; +import { Car } from 'shared'; +import { useGlobalCarFilter } from '../app/AppGlobalCarFilterContext'; + +export interface PageCarFilter { + /** The currently selected car for this page (can be different from global) */ + selectedCar: Car | null; + /** All available cars */ + allCars: Car[]; + /** Whether this page is using the global filter or a local override */ + usingGlobalFilter: boolean; + /** Set the car for this page only (creates local override) */ + setLocalCar: (car: Car | null) => void; + /** Reset to use the global filter */ + resetToGlobalFilter: () => void; + /** Loading and error states */ + isLoading: boolean; + error: Error | null; +} + +/** + * Hook for pages that want to support both global car filtering and page-specific overrides + * + * Behavior: + * - By default, uses the global car filter + * - When user changes filter on the page, creates a local override + * - When user navigates away and returns, reverts to global filter + * + * Usage: + * const carFilter = usePageCarFilter('gantt-page'); + */ +export const usePageCarFilter = (pageKey: string): PageCarFilter => { + const { selectedCar: globalCar, allCars, isLoading, error } = useGlobalCarFilter(); + + const [localCar, setLocalCar] = useState(null); + const [hasLocalOverride, setHasLocalOverride] = useState(false); + + // Session key for storing page-specific overrides + const sessionKey = `page-car-filter-${pageKey}`; + + // Initialize from session storage on mount + useEffect(() => { + const savedLocalCarId = sessionStorage.getItem(sessionKey); + if (savedLocalCarId && allCars.length > 0) { + const savedCar = allCars.find((car) => car.id === savedLocalCarId); + if (savedCar) { + setLocalCar(savedCar); + setHasLocalOverride(true); + } + } + }, [sessionKey, allCars]); + + // Clean up session storage when component unmounts (user navigates away) + useEffect(() => { + return () => { + sessionStorage.removeItem(sessionKey); + setHasLocalOverride(false); + setLocalCar(null); + }; + }, [sessionKey]); + + const setLocalCarHandler = (car: Car | null) => { + setLocalCar(car); + setHasLocalOverride(true); + + // Save to session storage + if (car) { + sessionStorage.setItem(sessionKey, car.id); + } else { + sessionStorage.removeItem(sessionKey); + } + }; + + const resetToGlobalFilter = () => { + setLocalCar(null); + setHasLocalOverride(false); + sessionStorage.removeItem(sessionKey); + }; + + return { + selectedCar: hasLocalOverride ? localCar : globalCar, + allCars, + usingGlobalFilter: !hasLocalOverride, + setLocalCar: setLocalCarHandler, + resetToGlobalFilter, + isLoading, + error + }; +}; diff --git a/src/frontend/src/hooks/projects.hooks.ts b/src/frontend/src/hooks/projects.hooks.ts index a95e17313c..09ffc2a5f0 100644 --- a/src/frontend/src/hooks/projects.hooks.ts +++ b/src/frontend/src/hooks/projects.hooks.ts @@ -39,12 +39,14 @@ import { } from '../apis/projects.api'; import { CreateSingleProjectPayload, EditSingleProjectPayload } from '../utils/types'; import { useCurrentUser } from './users.hooks'; +import { useGlobalCarFilter } from '../app/AppGlobalCarFilterContext'; /** * Custom React Hook to supply all projects with Gantt querry args */ export const useAllProjectsGantt = () => { - return useQuery(['projects'], async () => { + const { selectedCar } = useGlobalCarFilter(); + return useQuery(['projects', selectedCar?.id], async () => { const { data } = await getAllProjectsGantt(); return data; }); @@ -54,7 +56,8 @@ export const useAllProjectsGantt = () => { * Custom React Hook to supply all projects */ export const useAllProjects = () => { - return useQuery(['projects', 'previews'], async () => { + const { selectedCar } = useGlobalCarFilter(); + return useQuery(['projects', 'previews', selectedCar?.id], async () => { const { data } = await getAllProjects(); return data; }); @@ -64,7 +67,8 @@ export const useAllProjects = () => { * Custom React Hook to supply all of the projects that are on the users teams */ export const useGetUsersTeamsProjects = () => { - return useQuery(['projects', 'teams'], async () => { + const { selectedCar } = useGlobalCarFilter(); + return useQuery(['projects', 'teams', selectedCar?.id], async () => { const { data } = await getUsersTeamsProjects(); return data; }); @@ -74,7 +78,8 @@ export const useGetUsersTeamsProjects = () => { * Custom React Hook to supply all of the projects that the user is the manager or lead of */ export const useGetUsersLeadingProjects = () => { - return useQuery(['projects', 'leading'], async () => { + const { selectedCar } = useGlobalCarFilter(); + return useQuery(['projects', 'leading', selectedCar?.id], async () => { const { data } = await getUsersLeadingProjects(); return data; }); @@ -84,7 +89,8 @@ export const useGetUsersLeadingProjects = () => { * Custom React Hook to supply all of the projects for a given team */ export const useGetTeamsProjects = (teamId: string) => { - return useQuery(['projects', 'teams'], async () => { + const { selectedCar } = useGlobalCarFilter(); + return useQuery(['projects', 'teams', teamId, selectedCar?.id], async () => { const { data } = await getTeamsProjects(teamId); return data; }); diff --git a/src/frontend/src/hooks/work-packages.hooks.ts b/src/frontend/src/hooks/work-packages.hooks.ts index acf0fa7d92..4b88d17b3b 100644 --- a/src/frontend/src/hooks/work-packages.hooks.ts +++ b/src/frontend/src/hooks/work-packages.hooks.ts @@ -19,12 +19,14 @@ import { WorkPackageEditArgs, getHomePageWorkPackages } from '../apis/work-packages.api'; +import { useGlobalCarFilter } from '../app/AppGlobalCarFilterContext'; /** * Custom React Hook to supply all work packages. */ export const useAllWorkPackages = (queryParams?: { [field: string]: string }) => { - return useQuery(['work packages', queryParams], async () => { + const { selectedCar } = useGlobalCarFilter(); + return useQuery(['work packages', queryParams, selectedCar?.id], async () => { const { data } = await getAllWorkPackages(queryParams); return data; }); @@ -34,7 +36,8 @@ export const useAllWorkPackages = (queryParams?: { [field: string]: string }) => * Custom React Hook to supply all work packages in preview format (minimal data). */ export const useAllWorkPackagesPreview = (status?: string) => { - return useQuery(['work packages', 'preview', status], async () => { + const { selectedCar } = useGlobalCarFilter(); + return useQuery(['work packages', 'preview', status, selectedCar?.id], async () => { const { data } = await getAllWorkPackagesPreview(status); return data; }); @@ -145,7 +148,8 @@ export const useSlackUpcomingDeadlines = () => { }; export const useHomeScreenWorkPackages = (selection: WorkPackageSelection) => { - return useQuery(['teams', 'work-packages', selection], async () => { + const { selectedCar } = useGlobalCarFilter(); + return useQuery(['teams', 'work-packages', selection, selectedCar?.id], async () => { const { data } = await getHomePageWorkPackages(selection); return data; }); diff --git a/src/frontend/src/layouts/Sidebar/NavPageLink.tsx b/src/frontend/src/layouts/Sidebar/NavPageLink.tsx index 9fe391bb53..6dcd5cbe8e 100644 --- a/src/frontend/src/layouts/Sidebar/NavPageLink.tsx +++ b/src/frontend/src/layouts/Sidebar/NavPageLink.tsx @@ -94,7 +94,7 @@ const NavPageLink: React.FC = ({ {subItems && ( {subItems.map((subItem) => ( - + ))} )} diff --git a/src/frontend/src/layouts/Sidebar/Sidebar.tsx b/src/frontend/src/layouts/Sidebar/Sidebar.tsx index 93e731e345..9ad3a7242c 100644 --- a/src/frontend/src/layouts/Sidebar/Sidebar.tsx +++ b/src/frontend/src/layouts/Sidebar/Sidebar.tsx @@ -37,6 +37,7 @@ import QueryStatsIcon from '@mui/icons-material/QueryStats'; import CurrencyExchangeIcon from '@mui/icons-material/CurrencyExchange'; import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; import { useState } from 'react'; +import GlobalCarFilterDropdown from '../../components/GlobalCarFilterDropdown'; interface SidebarProps { drawerOpen: boolean; @@ -226,6 +227,10 @@ const Sidebar = ({ drawerOpen, setDrawerOpen, moveContent, setMoveContent }: Sid handleMoveContent()}>{moveContent ? : } + + + + {linkItems.map((linkItem) => ( handleOpenSubmenu(linkItem.name)} onSubmenuCollapse={() => handleCloseSubmenu()} /> ))} + diff --git a/src/frontend/src/pages/ChangeRequestsPage/ChangeRequestsView.tsx b/src/frontend/src/pages/ChangeRequestsPage/ChangeRequestsView.tsx index c6b22d0b05..eae653f061 100644 --- a/src/frontend/src/pages/ChangeRequestsPage/ChangeRequestsView.tsx +++ b/src/frontend/src/pages/ChangeRequestsPage/ChangeRequestsView.tsx @@ -5,6 +5,7 @@ import { routes } from '../../utils/routes'; import { isGuest } from 'shared'; import { Add } from '@mui/icons-material'; import { useCurrentUser } from '../../hooks/users.hooks'; +import { useGlobalCarFilter } from '../../app/AppGlobalCarFilterContext'; import ChangeRequestsOverview from './ChangeRequestsOverview'; import ChangeRequestsTable from './ChangeRequestsTable'; import PageLayout from '../../components/PageLayout'; @@ -13,6 +14,7 @@ import FullPageTabs from '../../components/FullPageTabs'; const ChangeRequestsView: React.FC = () => { const history = useHistory(); const user = useCurrentUser(); + const { selectedCar } = useGlobalCarFilter(); // Default to the "overview" tab const [tabIndex, setTabIndex] = useState(0); @@ -30,7 +32,7 @@ const ChangeRequestsView: React.FC = () => { return ( = ({ startDate, endDate, carNumber }) => { const user = useCurrentUser(); + const { selectedCar } = useGlobalCarFilter(); const [anchorEl, setAnchorEl] = useState(null); const [tabIndex, setTabIndex] = useState(0); @@ -61,11 +64,19 @@ const AdminFinanceDashboard: React.FC = ({ startDate const { data: allCars, isLoading: allCarsIsLoading, isError: allCarsIsError, error: allCarsError } = useGetAllCars(); + // Sync with global car filter from sidebar useEffect(() => { - if (carNumberState === undefined && allCars && allCars.length > 0) { + if (selectedCar) { + setCarNumberState(selectedCar.wbsNum.carNumber); + } + }, [selectedCar]); + + // Set default car if none selected + useEffect(() => { + if (carNumberState === undefined && allCars && allCars.length > 0 && !selectedCar) { setCarNumberState(allCars[allCars.length - 1].wbsNum.carNumber); } - }, [allCars, carNumberState]); + }, [allCars, carNumberState, selectedCar]); if (allCarsIsError) { return ; @@ -209,56 +220,81 @@ const AdminFinanceDashboard: React.FC = ({ startDate ml: 'auto' }} > - setCarNumberState(newValue ? Number(newValue.id) : undefined)} - options={carAutocompleteOptions} - size="small" - placeholder="Select A Car" - value={ - carNumberState !== undefined ? carAutocompleteOptions.find((car) => car.id === carNumberState.toString()) : null - } - sx={datePickerStyle} - /> - (endDateState ? date > endDateState : false)} - slotProps={{ - textField: { - size: 'small', - sx: datePickerStyle - }, - field: { clearable: true } - }} - onChange={(newValue: Date | null) => setStartDateState(newValue ?? undefined)} - /> + + setCarNumberState(newValue ? Number(newValue.id) : undefined)} + options={carAutocompleteOptions} + size="small" + placeholder="Select A Car" + value={ + carNumberState !== undefined ? carAutocompleteOptions.find((car) => car.id === carNumberState.toString()) : null + } + sx={datePickerStyle} + /> + + + + + + (endDateState ? date > endDateState : false)} + slotProps={{ + textField: { + size: 'small', + sx: datePickerStyle + }, + field: { clearable: true } + }} + onChange={(newValue: Date | null) => setStartDateState(newValue ?? undefined)} + /> + + + + - - (startDateState ? date < startDateState : false)} - slotProps={{ - textField: { - size: 'small', - sx: datePickerStyle - }, - field: { clearable: true } - }} - onChange={(newValue: Date | null) => setEndDateState(newValue ?? undefined)} - /> + + (startDateState ? date < startDateState : false)} + slotProps={{ + textField: { + size: 'small', + sx: datePickerStyle + }, + field: { clearable: true } + }} + onChange={(newValue: Date | null) => setEndDateState(newValue ?? undefined)} + /> + + + + } variant="contained" id="project-actions-dropdown" onClick={handleClick} + sx={{ flexShrink: 0 }} > Actions diff --git a/src/frontend/src/pages/FinancePage/FinanceDashboard/GeneralFinanceDashboard.tsx b/src/frontend/src/pages/FinancePage/FinanceDashboard/GeneralFinanceDashboard.tsx index feb70ea017..1a00d5451d 100644 --- a/src/frontend/src/pages/FinancePage/FinanceDashboard/GeneralFinanceDashboard.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceDashboard/GeneralFinanceDashboard.tsx @@ -5,11 +5,10 @@ import PageLayout from '../../../components/PageLayout'; import { Box } from '@mui/system'; import FullPageTabs from '../../../components/FullPageTabs'; import { routes } from '../../../utils/routes'; -import { DatePicker } from '@mui/x-date-pickers'; import { useGetUsersTeams } from '../../../hooks/teams.hooks'; import FinanceDashboardTeamView from './FinanceDashboardTeamView'; -import { useGetAllCars } from '../../../hooks/cars.hooks'; -import NERAutocomplete from '../../../components/NERAutocomplete'; +import FinanceDashboardCarFilter from '../../../components/FinanceDashboardCarFilter'; +import { useFinanceDashboardCarFilter } from '../../../hooks/finance-car-filter.hooks'; interface GeneralFinanceDashboardProps { startDate?: Date; @@ -19,9 +18,8 @@ interface GeneralFinanceDashboardProps { const GeneralFinanceDashboard: React.FC = ({ startDate, endDate, carNumber }) => { const [tabIndex, setTabIndex] = useState(0); - const [startDateState, setStartDateState] = useState(startDate); - const [endDateState, setEndDateState] = useState(endDate); - const [carNumberState, setCarNumberState] = useState(carNumber); + + const filter = useFinanceDashboardCarFilter(startDate, endDate, carNumber); const { data: allTeams, @@ -30,159 +28,34 @@ const GeneralFinanceDashboard: React.FC = ({ start error: allTeamsError } = useGetUsersTeams(); - const { data: allCars, isLoading: allCarsIsLoading, isError: allCarsIsError, error: allCarsError } = useGetAllCars(); - - if (allCarsIsError) { - return ; - } - if (allTeamsIsError) { return ; } - if (!allTeams || allTeamsIsLoading || !allCars || allCarsIsLoading) { + if (!allTeams || allTeamsIsLoading || filter.isLoading) { return ; } - const carAutocompleteOptions = allCars.map((car) => { - return { - label: car.name, - id: car.id, - number: car.wbsNum.carNumber - }; - }); - - const datePickerStyle = { - width: 180, - height: 36, - color: 'white', - fontSize: '13px', - textTransform: 'none', - fontWeight: 400, - borderRadius: '4px', - boxShadow: 'none', - - '.MuiInputBase-root': { - height: '36px', - padding: '0 8px', - backgroundColor: '#ef4345', - color: 'white', - fontSize: '13px', - borderRadius: '4px', - '&:hover': { - backgroundColor: '#ef4345' - }, - '&.Mui-focused': { - backgroundColor: '#ef4345', - color: 'white' - } - }, - - '.MuiInputLabel-root': { - color: 'white', - fontSize: '14px', - transform: 'translate(15px, 7px) scale(1)', - '&.Mui-focused': { - color: 'white' - } - }, - - '.MuiInputLabel-shrink': { - transform: 'translate(14px, -6px) scale(0.75)', - color: 'white' - }, - - '& .MuiInputBase-input': { - color: 'white', - paddingTop: '8px', - cursor: 'pointer', - '&:focus': { - color: 'white' - } - }, - - '& .MuiOutlinedInput-notchedOutline': { - border: '1px solid #fff', - '&:hover': { - borderColor: '#fff' - }, - '&.Mui-focused': { - borderColor: '#fff' - } - }, - - '& .MuiSvgIcon-root': { - color: 'white', - '&:hover': { - color: 'white' - }, - '&.Mui-focused': { - color: 'white' - } - } - }; + if (filter.error) { + return ; + } - const dates = ( + const filterComponent = ( - setCarNumberState(newValue ? Number(newValue.id) : undefined)} - options={carAutocompleteOptions} - size="small" - placeholder="Select A Car" - value={ - carNumberState !== undefined ? carAutocompleteOptions.find((car) => car.id === carNumberState.toString()) : null - } - sx={datePickerStyle} - /> - (endDateState ? date > endDateState : false)} - slotProps={{ - textField: { - size: 'small', - sx: datePickerStyle - }, - field: { clearable: true } - }} - onChange={(newValue: Date | null) => setStartDateState(newValue ?? undefined)} - /> - - - - - - - (startDateState ? date < startDateState : false)} - slotProps={{ - textField: { - size: 'small', - sx: datePickerStyle - }, - field: { clearable: true } - }} - onChange={(newValue: Date | null) => setEndDateState(newValue ?? undefined)} - /> + ); if (allTeams.length === 0) { return ( - + ); @@ -190,13 +63,13 @@ const GeneralFinanceDashboard: React.FC = ({ start if (allTeams.length === 1) { return ( - + ); @@ -214,7 +87,7 @@ const GeneralFinanceDashboard: React.FC = ({ start return ( = ({ start {selectedTab && ( )} diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx index e38991c484..c957d99691 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx @@ -111,6 +111,7 @@ const ProjectGanttChartPage: FC = () => { let allProjects: ProjectGantt[] = JSON.parse(JSON.stringify(projects.concat(addedProjects))).map( projectGanttTransformer ); + allProjects = allProjects.map((project) => { const editedProject = editedProjects.find((proj) => proj.id === project.id); return editedProject ? editedProject : project; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectCreateContainer.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectCreateContainer.tsx index 9cad6c1605..8b8721ed97 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectCreateContainer.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectCreateContainer.tsx @@ -23,7 +23,7 @@ import { ProjectCreateChangeRequestFormInput } from './ProjectEditContainer'; import { dateToMidnightUTC, ProjectProposedChangesCreateArgs, WbsNumber, WorkPackageStage } from 'shared'; import { CreateStandardChangeRequestPayload, useCreateStandardChangeRequest } from '../../../hooks/change-requests.hooks'; import { useCreateSingleWorkPackage } from '../../../hooks/work-packages.hooks'; -import { useGetAllCars } from '../../../hooks/cars.hooks'; +import { useGlobalCarFilter } from '../../../app/AppGlobalCarFilterContext'; import { ChangeRequestReason } from 'shared'; import { yupResolver } from '@hookform/resolvers/yup'; import { ChangeRequestType } from 'shared'; @@ -35,8 +35,7 @@ const ProjectCreateContainer: React.FC = () => { const [managerId, setManagerId] = useState(); const [leadId, setLeadId] = useState(); - const [carNumber, setCarNumber] = useState(); - const { data: cars, isLoading: carsIsLoading, isError: carsIsError, error: carsError } = useGetAllCars(); + const { selectedCar, isLoading: carFilterIsLoading } = useGlobalCarFilter(); const { mutateAsync: createProjectMutateAsync, isLoading: createProjectIsLoading } = useCreateSingleProject(); const { mutateAsync: mutateCRAsync, isLoading: isCRHookLoading } = useCreateStandardChangeRequest(); @@ -47,7 +46,7 @@ const ProjectCreateContainer: React.FC = () => { budget: 0, summary: '', teamIds: [], - carNumber, + carNumber: selectedCar?.wbsNum.carNumber, links: [], crId: query.get('crId') || undefined, descriptionBullets: [], @@ -133,14 +132,11 @@ const ProjectCreateContainer: React.FC = () => { createWpIsLoading || !allLinkTypes || allLinkTypesIsLoading || - carsIsLoading || - !cars + carFilterIsLoading ) return ; if (allLinkTypesIsError) return ; - if (carsIsError) return ; - const requiredLinkTypeNames = getRequiredLinkTypeNames(allLinkTypes); const onSubmitChangeRequest = async (data: ProjectCreateChangeRequestFormInput) => { @@ -280,7 +276,6 @@ const ProjectCreateContainer: React.FC = () => { leadId={leadId} managerId={managerId} onSubmitChangeRequest={onSubmitChangeRequest} - setCarNumber={setCarNumber} changeRequestFormReturn={changeRequestFormMethods} /> ); diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx index b1034e9ff8..65c1c43731 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectEditContainer.tsx @@ -45,7 +45,6 @@ const ProjectEditContainer: React.FC = ({ project, ex const { name, budget, summary, workPackages } = project; const [managerId, setManagerId] = useState(project.manager?.userId.toString()); const [leadId, setLeadId] = useState(project.lead?.userId.toString()); - const [carNumber, setCarNumber] = useState(project.wbsNum.carNumber); const descriptionBullets = bulletsToObject(project.descriptionBullets); const { mutateAsync, isLoading } = useEditSingleProject(project.wbsNum); @@ -141,7 +140,7 @@ const ProjectEditContainer: React.FC = ({ project, ex summary, // teamId and carNumber aren't used for projectEdit teamIds: [], - carNumber, + carNumber: project.wbsNum.carNumber, links, crId: query.get('crId') || '', descriptionBullets, @@ -290,7 +289,6 @@ const ProjectEditContainer: React.FC = ({ project, ex leadId={leadId} managerId={managerId} onSubmitChangeRequest={onSubmitChangeRequest} - setCarNumber={setCarNumber} onlyLeadershipChanged={onlyLeadershipChanged} /> ); diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx index dfcfc9fd2b..8829498789 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectForm.tsx @@ -56,8 +56,6 @@ interface ProjectFormContainerProps { leadId?: string; managerId?: string; onSubmitChangeRequest?: (data: ProjectCreateChangeRequestFormInput) => void; - setCarNumber: (carNumber: number) => void; - carNumber?: number; changeRequestFormReturn: ChangeRequestFormReturn; onlyLeadershipChanged?: boolean; } @@ -73,7 +71,6 @@ const ProjectFormContainer: React.FC = ({ leadId, managerId, onSubmitChangeRequest, - setCarNumber, changeRequestFormReturn, onlyLeadershipChanged }) => { @@ -307,7 +304,6 @@ const ProjectFormContainer: React.FC = ({ leadId={leadId} managerId={managerId} project={project} - setCarNumber={setCarNumber} /> diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectFormDetails.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectFormDetails.tsx index 4aa5f88d2b..482225eb64 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectFormDetails.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectFormDetails.tsx @@ -1,5 +1,5 @@ import { Project, User } from 'shared'; -import { Box, FormControl, FormLabel, Grid, MenuItem, Select, Typography } from '@mui/material'; +import { Box, FormControl, FormHelperText, FormLabel, Grid, MenuItem, TextField, Typography } from '@mui/material'; import ReactHookTextField from '../../../components/ReactHookTextField'; import { fullNamePipe } from '../../../utils/pipes'; import NERAutocomplete from '../../../components/NERAutocomplete'; @@ -8,9 +8,8 @@ import { Control, Controller, FieldErrorsImpl } from 'react-hook-form'; import { AttachMoney } from '@mui/icons-material'; import TeamDropdown from '../../../components/TeamsDropdown'; import ChangeRequestDropdown from '../../../components/ChangeRequestDropdown'; -import { useGetAllCars } from '../../../hooks/cars.hooks'; +import { useGlobalCarFilter } from '../../../app/AppGlobalCarFilterContext'; import LoadingIndicator from '../../../components/LoadingIndicator'; -import ErrorPage from '../../ErrorPage'; interface ProjectEditDetailsProps { users: User[]; @@ -22,7 +21,6 @@ interface ProjectEditDetailsProps { setManagerId: (id?: string) => void; setLeadId: (id?: string) => void; setcrId?: (crId?: number) => void; - setCarNumber: (carNumber: number) => void; } const userToAutocompleteOption = (user?: User): { label: string; id: string } => { @@ -38,18 +36,15 @@ const ProjectFormDetails: React.FC = ({ managerId, leadId, setLeadId, - setManagerId, - setCarNumber + setManagerId }) => { - const { data: cars, isLoading, isError, error } = useGetAllCars(); + const { selectedCar, allCars, isLoading: carFilterIsLoading } = useGlobalCarFilter(); - if (isLoading || !cars) { + if (carFilterIsLoading) { return ; } - if (isError) { - return ; - } + const sortedCars = [...allCars].sort((a, b) => b.wbsNum.carNumber - a.wbsNum.carNumber); return ( @@ -68,43 +63,33 @@ const ProjectFormDetails: React.FC = ({ /> + {!project && !selectedCar && ( + + + Car + ( + + {sortedCars.map((car) => ( + + {car.name} + + ))} + + )} + /> + {errors.carNumber?.message} + + + )} {!project && ( - <> - - - Car - ( - - )} - > - - - - - - - - + + + + + )} diff --git a/src/frontend/src/pages/ProjectsPage/ProjectsOverview.tsx b/src/frontend/src/pages/ProjectsPage/ProjectsOverview.tsx index 2539a7d3d8..35e7b4e071 100644 --- a/src/frontend/src/pages/ProjectsPage/ProjectsOverview.tsx +++ b/src/frontend/src/pages/ProjectsPage/ProjectsOverview.tsx @@ -10,12 +10,14 @@ import { useCurrentUser, useUsersFavoriteProjects } from '../../hooks/users.hook import ProjectsOverviewCards from './ProjectsOverviewCards'; import { useGetUsersLeadingProjects, useGetUsersTeamsProjects } from '../../hooks/projects.hooks'; import { WbsElementStatus } from 'shared'; +import { useGlobalCarFilter } from '../../app/AppGlobalCarFilterContext'; /** * Cards of all projects this user has favorited */ const ProjectsOverview: React.FC = () => { const user = useCurrentUser(); + const { selectedCar } = useGlobalCarFilter(); const { isLoading, data: favoriteProjects, isError, error } = useUsersFavoriteProjects(user.userId); const { @@ -48,6 +50,10 @@ const ProjectsOverview: React.FC = () => { const favoriteProjectsSet: Set = new Set(favoriteProjects.map((project) => project.id)); + const carFilteredFavorites = selectedCar + ? favoriteProjects.filter((project) => project.wbsNum.carNumber === selectedCar.wbsNum.carNumber) + : favoriteProjects; + // Keeps only favorite team/leading projects (even when completed) or incomplete projects const filteredTeamsProjects = teamsProjects.filter( (project) => project.status !== WbsElementStatus.Complete || favoriteProjectsSet.has(project.id) @@ -59,7 +65,7 @@ const ProjectsOverview: React.FC = () => { return ( { const { isLoading, data, error } = useAllProjects(); + if (!localStorage.getItem('projectsTableRowCount')) localStorage.setItem('projectsTableRowCount', '30'); const [pageSize, setPageSize] = useState(localStorage.getItem('projectsTableRowCount')); const [windowSize, setWindowSize] = useState(window.innerWidth); diff --git a/src/frontend/src/tests/app/AppContext.test.tsx b/src/frontend/src/tests/app/AppContext.test.tsx index f3ffdc7a2f..9232cfe0b8 100644 --- a/src/frontend/src/tests/app/AppContext.test.tsx +++ b/src/frontend/src/tests/app/AppContext.test.tsx @@ -33,6 +33,15 @@ vi.mock('../../app/AppContextTheme', () => { }; }); +vi.mock('../../app/AppGlobalCarFilterContext', () => { + return { + __esModule: true, + GlobalCarFilterProvider: (props: { children: React.ReactNode }) => { + return
{props.children}
; + } + }; +}); + // Sets up the component under test with the desired values and renders it const renderComponent = () => { render( diff --git a/src/frontend/src/tests/app/AppContextQuery.test.tsx b/src/frontend/src/tests/app/AppContextQuery.test.tsx index 415c35ebcb..140d47d2ee 100644 --- a/src/frontend/src/tests/app/AppContextQuery.test.tsx +++ b/src/frontend/src/tests/app/AppContextQuery.test.tsx @@ -7,6 +7,10 @@ import { render, screen } from '@testing-library/react'; // avoid circular depen import { useAllChangeRequests } from '../../hooks/change-requests.hooks'; import AppContextQuery from '../../app/AppContextQuery'; +vi.mock('../../app/AppGlobalCarFilterContext', () => ({ + useGlobalCarFilter: () => ({ selectedCar: null, allCars: [], setSelectedCar: vi.fn(), isLoading: false, error: null }) +})); + describe('app context', () => { it('renders simple text as children', () => { render(hello); diff --git a/src/frontend/src/tests/hooks/ChangeRequests.hooks.test.tsx b/src/frontend/src/tests/hooks/ChangeRequests.hooks.test.tsx index 8ce2a1e18b..acbae43405 100644 --- a/src/frontend/src/tests/hooks/ChangeRequests.hooks.test.tsx +++ b/src/frontend/src/tests/hooks/ChangeRequests.hooks.test.tsx @@ -13,6 +13,9 @@ import { getAllChangeRequests, getSingleChangeRequest } from '../../apis/change- import { useAllChangeRequests, useSingleChangeRequest } from '../../hooks/change-requests.hooks'; vi.mock('../../apis/change-requests.api'); +vi.mock('../../app/AppGlobalCarFilterContext', () => ({ + useGlobalCarFilter: () => ({ selectedCar: null, allCars: [], setSelectedCar: vi.fn(), isLoading: false, error: null }) +})); describe('change request hooks', () => { it('handles getting a list of change requests', async () => { diff --git a/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx b/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx new file mode 100644 index 0000000000..01bf025936 --- /dev/null +++ b/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx @@ -0,0 +1,203 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { renderHook, act, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { GlobalCarFilterProvider, useGlobalCarFilter } from '../../app/AppGlobalCarFilterContext'; +import * as carsHooks from '../../hooks/cars.hooks'; +import { exampleAllCars } from '../test-support/test-data/cars.stub'; + +// Mock the hooks +vi.mock('../../hooks/cars.hooks'); +const mockUseGetAllCars = vi.mocked(carsHooks.useGetAllCars); + +// Create wrapper with providers +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false } + } + }); + + return ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); +}; + +describe('useGlobalCarFilter', () => { + beforeEach(() => { + sessionStorage.clear(); + vi.clearAllMocks(); + }); + + it('should initialize with null when no saved car name in session storage', async () => { + mockUseGetAllCars.mockReturnValue({ + data: exampleAllCars, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.selectedCar).toBeNull(); + expect(result.current.allCars).toEqual(exampleAllCars); + }); + + it('should restore car from session storage by name', async () => { + sessionStorage.setItem('selectedCarName', exampleAllCars[0].name); + + mockUseGetAllCars.mockReturnValue({ + data: exampleAllCars, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + await waitFor(() => { + expect(result.current.selectedCar).toEqual(exampleAllCars[0]); + }); + }); + + it('should default to null when saved car name does not match any car', async () => { + sessionStorage.setItem('selectedCarName', 'NER-Nonexistent'); + + mockUseGetAllCars.mockReturnValue({ + data: exampleAllCars, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.selectedCar).toBeNull(); + }); + + it('should persist car name to session storage when selecting a car', async () => { + mockUseGetAllCars.mockReturnValue({ + data: exampleAllCars, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + act(() => { + result.current.setSelectedCar(exampleAllCars[1]); + }); + + expect(sessionStorage.getItem('selectedCarName')).toBe(exampleAllCars[1].name); + expect(result.current.selectedCar).toEqual(exampleAllCars[1]); + }); + + it('should clear session storage when selecting null (all cars)', async () => { + sessionStorage.setItem('selectedCarName', exampleAllCars[0].name); + + mockUseGetAllCars.mockReturnValue({ + data: exampleAllCars, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + await waitFor(() => { + expect(result.current.selectedCar).toEqual(exampleAllCars[0]); + }); + + act(() => { + result.current.setSelectedCar(null); + }); + + expect(sessionStorage.getItem('selectedCarName')).toBeNull(); + expect(result.current.selectedCar).toBeNull(); + }); + + it('should handle loading state', () => { + mockUseGetAllCars.mockReturnValue({ + data: undefined, + isLoading: true, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.selectedCar).toBeNull(); + }); + + it('should handle error state', () => { + const error = new Error('Failed to load cars'); + + mockUseGetAllCars.mockReturnValue({ + data: undefined, + isLoading: false, + error + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + expect(result.current.error).toBe(error); + expect(result.current.isLoading).toBe(false); + }); + + it('should update session storage when switching between cars', async () => { + mockUseGetAllCars.mockReturnValue({ + data: exampleAllCars, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + act(() => { + result.current.setSelectedCar(exampleAllCars[0]); + }); + + expect(sessionStorage.getItem('selectedCarName')).toBe(exampleAllCars[0].name); + + act(() => { + result.current.setSelectedCar(exampleAllCars[2]); + }); + + expect(sessionStorage.getItem('selectedCarName')).toBe(exampleAllCars[2].name); + expect(result.current.selectedCar).toEqual(exampleAllCars[2]); + }); +}); diff --git a/src/frontend/src/tests/hooks/Projects.hooks.test.tsx b/src/frontend/src/tests/hooks/Projects.hooks.test.tsx index c35e2b989c..cbd97bf150 100644 --- a/src/frontend/src/tests/hooks/Projects.hooks.test.tsx +++ b/src/frontend/src/tests/hooks/Projects.hooks.test.tsx @@ -6,14 +6,29 @@ import { renderHook, waitFor } from '@testing-library/react'; import { AxiosResponse } from 'axios'; import { Project } from 'shared'; -import wrapper from '../../app/AppContextQuery'; +import AppContextQuery from '../../app/AppContextQuery'; +import { GlobalCarFilterProvider } from '../../app/AppGlobalCarFilterContext'; import { mockPromiseAxiosResponse } from '../test-support/test-data/test-utils.stub'; import { exampleAllProjects, exampleProject1 } from '../test-support/test-data/projects.stub'; import { exampleWbsProject1 } from '../test-support/test-data/wbs-numbers.stub'; import { getAllProjectsGantt, getSingleProject } from '../../apis/projects.api'; import { useAllProjectsGantt, useSingleProject } from '../../hooks/projects.hooks'; +import * as carsHooks from '../../hooks/cars.hooks'; +import { exampleAllCars, exampleCurrentCar } from '../test-support/test-data/cars.stub'; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); vi.mock('../../apis/projects.api'); +vi.mock('../../hooks/cars.hooks'); + +beforeEach(() => { + vi.mocked(carsHooks.useGetCurrentCar).mockReturnValue({ data: exampleCurrentCar, isLoading: false, error: null } as any); + vi.mocked(carsHooks.useGetAllCars).mockReturnValue({ data: exampleAllCars, isLoading: false, error: null } as any); +}); describe('project hooks', () => { it('handles getting a list of projects', async () => { diff --git a/src/frontend/src/tests/hooks/WorkPackages.hooks.test.tsx b/src/frontend/src/tests/hooks/WorkPackages.hooks.test.tsx index 3b4ce0a8d5..8d497cc2e0 100644 --- a/src/frontend/src/tests/hooks/WorkPackages.hooks.test.tsx +++ b/src/frontend/src/tests/hooks/WorkPackages.hooks.test.tsx @@ -6,14 +6,29 @@ import { renderHook, waitFor } from '@testing-library/react'; import { AxiosResponse } from 'axios'; import { WorkPackage } from 'shared'; -import wrapper from '../../app/AppContextQuery'; +import AppContextQuery from '../../app/AppContextQuery'; +import { GlobalCarFilterProvider } from '../../app/AppGlobalCarFilterContext'; import { mockPromiseAxiosResponse } from '../test-support/test-data/test-utils.stub'; import { exampleAllWorkPackages, exampleResearchWorkPackage } from '../test-support/test-data/work-packages.stub'; import { exampleWbsWorkPackage1 } from '../test-support/test-data/wbs-numbers.stub'; import { getAllWorkPackages, getSingleWorkPackage } from '../../apis/work-packages.api'; import { useAllWorkPackages, useSingleWorkPackage } from '../../hooks/work-packages.hooks'; +import * as carsHooks from '../../hooks/cars.hooks'; +import { exampleAllCars, exampleCurrentCar } from '../test-support/test-data/cars.stub'; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); vi.mock('../../apis/work-packages.api'); +vi.mock('../../hooks/cars.hooks'); + +beforeEach(() => { + vi.mocked(carsHooks.useGetCurrentCar).mockReturnValue({ data: exampleCurrentCar, isLoading: false, error: null } as any); + vi.mocked(carsHooks.useGetAllCars).mockReturnValue({ data: exampleAllCars, isLoading: false, error: null } as any); +}); describe('work package hooks', () => { it('handles getting a list of work packages', async () => { diff --git a/src/frontend/src/tests/layouts/Sidebar/Sidebar.test.tsx b/src/frontend/src/tests/layouts/Sidebar/Sidebar.test.tsx index fb1231df40..1e42519067 100644 --- a/src/frontend/src/tests/layouts/Sidebar/Sidebar.test.tsx +++ b/src/frontend/src/tests/layouts/Sidebar/Sidebar.test.tsx @@ -11,6 +11,16 @@ import Sidebar from '../../../layouts/Sidebar/Sidebar'; import { ToastContext, ToastInputs } from '../../../components/Toast/ToastProvider'; import { exampleAuthenticatedAdminUser } from '../../test-support/test-data/authenticated-user.stub'; +vi.mock('../../../app/AppGlobalCarFilterContext', () => ({ + useGlobalCarFilter: () => ({ + selectedCar: null, + allCars: [], + setSelectedCar: vi.fn(), + isLoading: false, + error: null + }) +})); + const addToast = (message: ToastInputs) => { console.log(message); }; diff --git a/src/frontend/src/tests/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.test.tsx b/src/frontend/src/tests/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.test.tsx index 292bfed62e..8c997a0b56 100644 --- a/src/frontend/src/tests/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.test.tsx +++ b/src/frontend/src/tests/pages/ChangeRequestDetailPage/ChangeRequestDetailsView.test.tsx @@ -24,6 +24,15 @@ import { exampleAuthenticatedAdminUser } from '../../test-support/test-data/auth vi.mock('../../../hooks/projects.hooks'); vi.mock('../../../hooks/users.hooks'); +vi.mock('../../../app/AppGlobalCarFilterContext', () => ({ + useGlobalCarFilter: () => ({ + selectedCar: null, + allCars: [], + setSelectedCar: vi.fn(), + isLoading: false, + error: null + }) +})); const mockedUseSingleProject = useSingleProject as jest.Mock>; const mockSingleProjectHook = (isLoading: boolean, isError: boolean, data?: Project, error?: Error) => { diff --git a/src/frontend/src/tests/pages/HomePage/Home.test.tsx b/src/frontend/src/tests/pages/HomePage/Home.test.tsx index e337d2d16e..1074716021 100644 --- a/src/frontend/src/tests/pages/HomePage/Home.test.tsx +++ b/src/frontend/src/tests/pages/HomePage/Home.test.tsx @@ -13,6 +13,16 @@ import { mockAuth } from '../../test-support/test-data/test-utils.stub'; import { mockUseSingleUserSettings } from '../../test-support/mock-hooks'; import { exampleAuthenticatedAdminUser } from '../../test-support/test-data/authenticated-user.stub'; +vi.mock('../../../app/AppGlobalCarFilterContext', () => ({ + useGlobalCarFilter: () => ({ + selectedCar: null, + allCars: [], + setSelectedCar: vi.fn(), + isLoading: false, + error: null + }) +})); + vi.mock('../../../pages/HomePage/components/UsefulLinks', () => { return { __esModule: true, diff --git a/src/frontend/src/tests/test-support/test-data/cars.stub.ts b/src/frontend/src/tests/test-support/test-data/cars.stub.ts new file mode 100644 index 0000000000..db7d813004 --- /dev/null +++ b/src/frontend/src/tests/test-support/test-data/cars.stub.ts @@ -0,0 +1,66 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { Car, WbsElementStatus } from 'shared'; + +export const exampleCar1: Car = { + wbsElementId: 'wbs-element-1', + id: 'car-1', + name: 'Car 2023', + wbsNum: { + carNumber: 23, + projectNumber: 0, + workPackageNumber: 0 + }, + dateCreated: new Date('2023-01-01'), + deleted: false, + status: WbsElementStatus.Active, + links: [], + changes: [], + descriptionBullets: [] +}; + +export const exampleCar2: Car = { + wbsElementId: 'wbs-element-2', + id: 'car-2', + name: 'Car 2024', + wbsNum: { + carNumber: 24, + projectNumber: 0, + workPackageNumber: 0 + }, + dateCreated: new Date('2024-01-01'), + deleted: false, + status: WbsElementStatus.Active, + links: [], + changes: [], + descriptionBullets: [] +}; + +export const exampleCar3: Car = { + wbsElementId: 'wbs-element-3', + id: 'car-3', + name: 'Car 2025', + wbsNum: { + carNumber: 25, + projectNumber: 0, + workPackageNumber: 0 + }, + dateCreated: new Date('2025-01-01'), + deleted: false, + status: WbsElementStatus.Active, + links: [], + changes: [], + descriptionBullets: [] +}; + +export const exampleAllCars: Car[] = [exampleCar1, exampleCar2, exampleCar3]; + +export const exampleCurrentCar: Car = exampleCar3; // Latest car by car number + +// Additional test data for global car filter +export const exampleEmptyCarArray: Car[] = []; + +export const exampleSingleCar: Car[] = [exampleCar3]; diff --git a/src/frontend/src/utils/axios.ts b/src/frontend/src/utils/axios.ts index 85eb74d43a..025323e4a9 100644 --- a/src/frontend/src/utils/axios.ts +++ b/src/frontend/src/utils/axios.ts @@ -4,6 +4,14 @@ const axios = axiosStatic.create({ withCredentials: import.meta.env.MODE !== 'development' ? true : undefined }); +// holds the validated car UUID in memory, set by GlobalCarFilterProvider after login. +// Storing only in memory prevents stale UUIDs from being sent +// before the car list has been loaded and validated post-login. +let currentCarId: string | null = null; +export const setCurrentCarId = (id: string | null) => { + currentCarId = id; +}; + // This allows us to get good server errors // All express responses must be: res.status(404).json({ message: "You are not authorized to do that." }) axios.interceptors.response.use( @@ -37,6 +45,7 @@ axios.interceptors.request.use( if (import.meta.env.MODE === 'development') request.headers!['Authorization'] = localStorage.getItem('devUserId') || ''; const organizationId = localStorage.getItem('organizationId'); request.headers!['organizationId'] = organizationId ?? ''; + if (currentCarId) request.headers!['carId'] = currentCarId; return request; }, (error) => { diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 447553c5f2..e8a2d0abec 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -256,8 +256,8 @@ const getReimbursementRequestCategoryData = ( const getAllReimbursementRequestData = (startDate?: Date, endDate?: Date, carNumber?: number): string => { const url = new URL(`${financeRoutesEndpoints()}/reimbursement-request-data`); const params = new URLSearchParams(); - if (startDate) params.set('startDate', startDate.toISOString()); - if (endDate) params.set('endDate', endDate.toISOString()); + if (startDate) params.set('startDate', new Date(startDate).toISOString()); + if (endDate) params.set('endDate', new Date(endDate).toISOString()); if (carNumber !== undefined) params.set('carNumber', carNumber.toString()); const queryString = params.toString(); return queryString ? `${url.toString()}?${queryString}` : url.toString(); @@ -270,8 +270,8 @@ const getReimbursementRequestTeamTypeData = ( ): string => { const url = new URL(`${financeRoutesEndpoints()}/reimbursement-request-team-type-data/${teamTypeId}`); const params = new URLSearchParams(); - if (startDate) params.set('startDate', startDate.toISOString()); - if (endDate) params.set('endDate', endDate.toISOString()); + if (startDate) params.set('startDate', new Date(startDate).toISOString()); + if (endDate) params.set('endDate', new Date(endDate).toISOString()); if (carNumber !== undefined) params.set('carNumber', carNumber.toString()); const queryString = params.toString(); return queryString ? `${url.toString()}?${queryString}` : url.toString(); @@ -279,8 +279,8 @@ const getReimbursementRequestTeamTypeData = ( const getSpendingBarTeamData = (teamId: string, startDate?: Date, endDate?: Date, carNumber?: number): string => { const url = new URL(`${financeRoutesEndpoints()}/spending-bar-team-data/${teamId}`); const params = new URLSearchParams(); - if (startDate) params.set('startDate', startDate.toISOString()); - if (endDate) params.set('endDate', endDate.toISOString()); + if (startDate) params.set('startDate', new Date(startDate).toISOString()); + if (endDate) params.set('endDate', new Date(endDate).toISOString()); if (carNumber !== undefined) params.set('carNumber', carNumber.toString()); const queryString = params.toString(); return queryString ? `${url.toString()}?${queryString}` : url.toString(); @@ -288,8 +288,8 @@ const getSpendingBarTeamData = (teamId: string, startDate?: Date, endDate?: Date const getSpendingBarTeamTypeData = (teamTypeId: string, startDate?: Date, endDate?: Date, carNumber?: number): string => { const url = new URL(`${financeRoutesEndpoints()}/spending-bar-team-type-data/${teamTypeId}`); const params = new URLSearchParams(); - if (startDate) params.set('startDate', startDate.toISOString()); - if (endDate) params.set('endDate', endDate.toISOString()); + if (startDate) params.set('startDate', new Date(startDate).toISOString()); + if (endDate) params.set('endDate', new Date(endDate).toISOString()); if (carNumber !== undefined) params.set('carNumber', carNumber.toString()); const queryString = params.toString(); return queryString ? `${url.toString()}?${queryString}` : url.toString(); @@ -297,8 +297,8 @@ const getSpendingBarTeamTypeData = (teamTypeId: string, startDate?: Date, endDat const getSpendingBarCategoryData = (startDate?: Date, endDate?: Date, carNumber?: number): string => { const url = new URL(`${financeRoutesEndpoints()}/spending-bar-category-data`); const params = new URLSearchParams(); - if (startDate) params.set('startDate', startDate.toISOString()); - if (endDate) params.set('endDate', endDate.toISOString()); + if (startDate) params.set('startDate', new Date(startDate).toISOString()); + if (endDate) params.set('endDate', new Date(endDate).toISOString()); if (carNumber !== undefined) params.set('carNumber', carNumber.toString()); const queryString = params.toString(); return queryString ? `${url.toString()}?${queryString}` : url.toString(); @@ -306,8 +306,8 @@ const getSpendingBarCategoryData = (startDate?: Date, endDate?: Date, carNumber? const getAllSpendingBarData = (startDate?: Date, endDate?: Date, carNumber?: number): string => { const url = new URL(`${financeRoutesEndpoints()}/spending-bar-data`); const params = new URLSearchParams(); - if (startDate) params.set('startDate', startDate.toISOString()); - if (endDate) params.set('endDate', endDate.toISOString()); + if (startDate) params.set('startDate', new Date(startDate).toISOString()); + if (endDate) params.set('endDate', new Date(endDate).toISOString()); if (carNumber !== undefined) params.set('carNumber', carNumber.toString()); const queryString = params.toString(); return queryString ? `${url.toString()}?${queryString}` : url.toString(); @@ -400,6 +400,7 @@ const organizationsSetFinanceDelegates = () => `${organizationsFinanceDelegates( /******************* Car Endpoints ********************/ const cars = () => `${API_URL}/cars`; +const carsCurrent = () => `${cars()}/current`; const carsCreate = () => `${cars()}/create`; /************** Recruitment Endpoints ***************/ @@ -782,6 +783,7 @@ export const apiUrls = { organizationsSetFinanceDelegates, cars, + carsCurrent, carsCreate, recruitment, diff --git a/system-tests/cypress/e2e/projects/projects-overview.cy.js b/system-tests/cypress/e2e/projects/projects-overview.cy.js index 53188cddfc..37222ee1c1 100644 --- a/system-tests/cypress/e2e/projects/projects-overview.cy.js +++ b/system-tests/cypress/e2e/projects/projects-overview.cy.js @@ -36,7 +36,7 @@ describe('Projects Overview', () => { // Fill in Project Name cy.get('[placeholder="Enter project name..."]').type(projectName); - // Car is pre-selected (Miles), keep default + // Car is pre-selected (NER-25), keep default // Select a Team // Target the Teams label (not the sidebar link) and find its sibling combobox diff --git a/system-tests/cypress/support/commands.js b/system-tests/cypress/support/commands.js index f419378d93..ead27e6a35 100644 --- a/system-tests/cypress/support/commands.js +++ b/system-tests/cypress/support/commands.js @@ -18,6 +18,9 @@ Cypress.Commands.add('login', (username = 'Thomas Emrax', redirect = '/home') => cy.contains(username).click(); cy.get(LOGIN_ICON).click(); cy.waitForLoading(); + // Set the car filter by name before the redirect so GlobalCarFilterProvider + // restores it from sessionStorage on first mount (name is stable across re-seeds) + cy.window().then((win) => win.sessionStorage.setItem('selectedCarName', 'NER-25')); cy.visit(Cypress.env('base_url') + redirect); cy.waitForLoading(); }); diff --git a/system-tests/cypress/utils/change-request.utils.cy.js b/system-tests/cypress/utils/change-request.utils.cy.js index 27f26385d7..176798670e 100644 --- a/system-tests/cypress/utils/change-request.utils.cy.js +++ b/system-tests/cypress/utils/change-request.utils.cy.js @@ -32,7 +32,7 @@ const createProposedSolution = ({ }; export const createChangeRequest = ({ - wbsTitle = '0.1.0 - Impact Attenuator', + wbsTitle = '25.1.0 - Impact Attenuator', what = 'test what', type = 'ISSUE', whys = [