From df63b0a40da33d80f61ed3fb03e127f40a0d328f Mon Sep 17 00:00:00 2001 From: harish Date: Wed, 15 Oct 2025 15:54:12 -0400 Subject: [PATCH 01/40] backend setup --- .../src/controllers/cars.controllers.ts | 10 + src/backend/src/routes/cars.routes.ts | 2 + src/backend/src/services/car.services.ts | 22 + .../integration/cars.integration.test.ts | 940 ++++++++++++++++++ src/backend/tests/unmocked/cars.test.ts | 207 ++++ src/frontend/src/apis/cars.api.ts | 4 + src/frontend/src/hooks/cars.hooks.ts | 12 +- .../tests/test-support/test-data/cars.stub.ts | 61 ++ src/frontend/src/utils/urls.ts | 2 + 9 files changed, 1259 insertions(+), 1 deletion(-) create mode 100644 src/backend/tests/integration/cars.integration.test.ts create mode 100644 src/backend/tests/unmocked/cars.test.ts create mode 100644 src/frontend/src/tests/test-support/test-data/cars.stub.ts diff --git a/src/backend/src/controllers/cars.controllers.ts b/src/backend/src/controllers/cars.controllers.ts index aa1865355a..b50d09d4c3 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/routes/cars.routes.ts b/src/backend/src/routes/cars.routes.ts index a0e4e21c6d..5af165b126 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/services/car.services.ts b/src/backend/src/services/car.services.ts index 023507c60b..78237af201 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/tests/integration/cars.integration.test.ts b/src/backend/tests/integration/cars.integration.test.ts new file mode 100644 index 0000000000..d64fef3e2d --- /dev/null +++ b/src/backend/tests/integration/cars.integration.test.ts @@ -0,0 +1,940 @@ +/* + * 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 { Organization, User } from '@prisma/client'; +import { createTestOrganization, createTestUser, resetUsers, createTestCar } from '../test-utils'; +import { supermanAdmin, batmanAppAdmin } 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 Service Integration Tests', () => { + let org: Organization; + let adminUser: User; + let nonAdminUser: User; + + beforeEach(async () => { + org = await createTestOrganization(); + adminUser = await createTestUser(supermanAdmin, org.organizationId); + nonAdminUser = await createTestUser(batmanAppAdmin, org.organizationId); + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('getAllCars Integration', () => { + it('returns cars with proper transformation and relations', async () => { + // Create test cars with complex data + const car1 = await createTestCar(org.organizationId, adminUser.userId); + const car2 = await createTestCar(org.organizationId, adminUser.userId); + + const cars = await CarsService.getAllCars(org); + + expect(cars).toHaveLength(2); + expect(cars[0]).toHaveProperty('id'); + expect(cars[0]).toHaveProperty('name'); + expect(cars[0]).toHaveProperty('wbsNum'); + expect(cars[0]).toHaveProperty('dateCreated'); + expect(cars[0]).toHaveProperty('lead'); + expect(cars[0]).toHaveProperty('manager'); + }); + + it('handles database errors gracefully', async () => { + // Create a mock organization that doesn't exist + const fakeOrg = { organizationId: 'non-existent-org' } as Organization; + + const cars = await CarsService.getAllCars(fakeOrg); + expect(cars).toEqual([]); + }); + }); + + describe('getCurrentCar Integration', () => { + it('correctly identifies current car with database ordering', async () => { + // Create cars with specific ordering scenarios + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 5, + projectNumber: 0, + workPackageNumber: 0, + name: 'Middle Car', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 10, + projectNumber: 0, + workPackageNumber: 0, + name: 'Latest Car', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 1, + projectNumber: 0, + workPackageNumber: 0, + name: 'Old Car', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + const currentCar = await CarsService.getCurrentCar(org); + expect(currentCar).not.toBeNull(); + expect(currentCar!.wbsNum.carNumber).toBe(10); + expect(currentCar!.name).toBe('Latest Car'); + }); + + it('handles concurrent car creation scenarios', async () => { + // Simulate concurrent creation by creating multiple cars rapidly + const carPromises = Array.from({ length: 5 }, (_, index) => + CarsService.createCar(org, adminUser, `Concurrent Car ${index}`) + ); + + const createdCars = await Promise.all(carPromises); + + // Verify all cars were created with proper numbering + const carNumbers = createdCars.map(car => car.wbsNum.carNumber).sort(); + expect(carNumbers).toEqual([0, 1, 2, 3, 4]); + + // Verify getCurrentCar returns the highest numbered car + const currentCar = await CarsService.getCurrentCar(org); + expect(currentCar!.wbsNum.carNumber).toBe(4); + }); + }); + + describe('createCar Integration', () => { + it('creates car with proper database relationships', async () => { + const carName = 'Integration Test Car'; + + const createdCar = await CarsService.createCar(org, adminUser, carName); + + // Verify the car was properly created in the database + const dbCar = await prisma.car.findUnique({ + where: { carId: createdCar.id }, + include: { + wbsElement: { + include: { + lead: true, + manager: true, + organization: true + } + } + } + }); + + expect(dbCar).not.toBeNull(); + expect(dbCar!.wbsElement.name).toBe(carName); + expect(dbCar!.wbsElement.leadId).toBe(adminUser.userId); + expect(dbCar!.wbsElement.managerId).toBe(adminUser.userId); + expect(dbCar!.wbsElement.organizationId).toBe(org.organizationId); + }); + + it('maintains data integrity across transactions', async () => { + const initialCarCount = await prisma.car.count({ + where: { + wbsElement: { + organizationId: org.organizationId + } + } + }); + + try { + // This should fail due to permissions + await CarsService.createCar(org, nonAdminUser, 'Should Fail'); + } catch (error) { + expect(error).toBeInstanceOf(AccessDeniedAdminOnlyException); + } + + // Verify no car was created due to the failed transaction + const finalCarCount = await prisma.car.count({ + where: { + wbsElement: { + organizationId: org.organizationId + } + } + }); + + expect(finalCarCount).toBe(initialCarCount); + }); + + it('handles database constraints properly', async () => { + // Test with edge cases that might violate constraints + const longName = 'A'.repeat(1000); // Very long name + + // This should either succeed or fail gracefully depending on DB constraints + await expect(async () => { + await CarsService.createCar(org, adminUser, longName); + }).not.toThrow(/Unexpected error/); + }); + }); + + describe('Cross-Organization Data Isolation', () => { + it('ensures complete data isolation between organizations', async () => { + // Create cars in first org + await CarsService.createCar(org, adminUser, 'Org1 Car 1'); + await CarsService.createCar(org, adminUser, 'Org1 Car 2'); + + // Create second org with its own cars + const org2 = await createTestOrganization(); + const admin2 = await createTestUser(supermanAdmin, org2.organizationId); + await CarsService.createCar(org2, admin2, 'Org2 Car 1'); + + // Verify each org only sees its own cars + const org1Cars = await CarsService.getAllCars(org); + const org2Cars = await CarsService.getAllCars(org2); + + expect(org1Cars).toHaveLength(2); + expect(org2Cars).toHaveLength(1); + + expect(org1Cars.every(car => car.name.startsWith('Org1'))).toBe(true); + expect(org2Cars.every(car => car.name.startsWith('Org2'))).toBe(true); + + // Verify current car logic is also isolated + const org1Current = await CarsService.getCurrentCar(org); + const org2Current = await CarsService.getCurrentCar(org2); + + expect(org1Current!.name).toBe('Org1 Car 2'); + expect(org2Current!.name).toBe('Org2 Car 1'); + }); + }); + + describe('Error Handling and Edge Cases', () => { + it('handles database connection issues gracefully', async () => { + // Create a car first + await CarsService.createCar(org, adminUser, 'Test Car'); + + // This test would require mocking Prisma to simulate connection issues + // For now, we'll test that the service handles null/undefined gracefully + const result = await CarsService.getCurrentCar(org); + expect(result).toBeTruthy(); + }); + + it('maintains consistency during high-concurrency operations', async () => { + // Simulate multiple users creating cars simultaneously + const promises = Array.from({ length: 10 }, (_, i) => + CarsService.createCar(org, adminUser, `Concurrent Car ${i}`) + ); + + const results = await Promise.allSettled(promises); + const successful = results.filter(r => r.status === 'fulfilled'); + + // All cars should be created successfully + expect(successful).toHaveLength(10); + + // Verify car numbering is sequential + const cars = await CarsService.getAllCars(org); + const carNumbers = cars.map(car => car.wbsNum.carNumber).sort((a, b) => a - b); + expect(carNumbers).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + + it('handles edge cases in car naming and validation', async () => { + // Test various edge cases + const edgeCases = [ + 'Car with special chars !@#$%^&*()', + 'Car with numbers 12345', + 'Car with spaces and tabs', + 'Very long car name that exceeds typical limits but should still work fine', + '车名中文', // Non-ASCII characters + '' // Empty string + ]; + + for (const carName of edgeCases) { + try { + const car = await CarsService.createCar(org, adminUser, carName); + expect(car.name).toBe(carName); + } catch (error) { + // Some edge cases might fail due to validation, which is acceptable + console.log(`Edge case "${carName}" failed as expected:`, error); + } + } + }); + + it('properly handles organization and user validation', async () => { + // Test with malformed organization + const invalidOrg = { organizationId: '' } as Organization; + + await expect(CarsService.getCurrentCar(invalidOrg)).rejects.toThrow(); + + // Test with non-existent organization + const nonExistentOrg = { organizationId: 'non-existent' } as Organization; + const result = await CarsService.getCurrentCar(nonExistentOrg); + expect(result).toBeNull(); + }); + + it('handles transaction rollbacks properly', async () => { + const initialCount = await prisma.car.count({ + where: { wbsElement: { organizationId: org.organizationId } } + }); + + // Attempt operation that should fail + try { + await CarsService.createCar(org, nonAdminUser, 'Should Fail'); + } catch (error) { + expect(error).toBeInstanceOf(AccessDeniedAdminOnlyException); + } + + // Verify count hasn't changed + const finalCount = await prisma.car.count({ + where: { wbsElement: { organizationId: org.organizationId } } + }); + + expect(finalCount).toBe(initialCount); + }); + + it('ensures proper cleanup and resource management', async () => { + // Create multiple cars and verify they can be properly queried + const carNames = ['Car A', 'Car B', 'Car C']; + const createdCars = []; + + for (const name of carNames) { + const car = await CarsService.createCar(org, adminUser, name); + createdCars.push(car); + } + + // Verify all cars exist + const allCars = await CarsService.getAllCars(org); + expect(allCars).toHaveLength(3); + + // Verify current car is the latest + const currentCar = await CarsService.getCurrentCar(org); + expect(currentCar!.name).toBe('Car C'); + expect(currentCar!.wbsNum.carNumber).toBe(2); // 0-indexed, so third car is #2 + }); + }); +}); + +import { Organization, User } from '@prisma/client'; +import { createTestOrganization, createTestUser, resetUsers, createTestCar } from '../test-utils'; +import { supermanAdmin, batmanAppAdmin } 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 Service Integration Tests', () => { + let org: Organization; + let adminUser: User; + let nonAdminUser: User; + + beforeEach(async () => { + org = await createTestOrganization(); + adminUser = await createTestUser(supermanAdmin, org.organizationId); + nonAdminUser = await createTestUser(batmanAppAdmin, org.organizationId); + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('getAllCars Integration', () => { + it('returns cars with proper transformation and relations', async () => { + // Create test cars with complex data + const car1 = await createTestCar(org.organizationId, adminUser.userId); + const car2 = await createTestCar(org.organizationId, adminUser.userId); + + const cars = await CarsService.getAllCars(org); + + expect(cars).toHaveLength(2); + expect(cars[0]).toHaveProperty('id'); + expect(cars[0]).toHaveProperty('name'); + expect(cars[0]).toHaveProperty('wbsNum'); + expect(cars[0]).toHaveProperty('dateCreated'); + expect(cars[0]).toHaveProperty('lead'); + expect(cars[0]).toHaveProperty('manager'); + }); + + it('handles database errors gracefully', async () => { + // Create a mock organization that doesn't exist + const fakeOrg = { organizationId: 'non-existent-org' } as Organization; + + const cars = await CarsService.getAllCars(fakeOrg); + expect(cars).toEqual([]); + }); + }); + + describe('getCurrentCar Integration', () => { + it('correctly identifies current car with database ordering', async () => { + // Create cars with specific ordering scenarios + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 5, + projectNumber: 0, + workPackageNumber: 0, + name: 'Middle Car', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 10, + projectNumber: 0, + workPackageNumber: 0, + name: 'Latest Car', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + await prisma.car.create({ + data: { + wbsElement: { + create: { + carNumber: 1, + projectNumber: 0, + workPackageNumber: 0, + name: 'Old Car', + organizationId: org.organizationId, + leadId: adminUser.userId, + managerId: adminUser.userId + } + } + } + }); + + const currentCar = await CarsService.getCurrentCar(org); + expect(currentCar).not.toBeNull(); + expect(currentCar!.wbsNum.carNumber).toBe(10); + expect(currentCar!.name).toBe('Latest Car'); + }); + + it('handles concurrent car creation scenarios', async () => { + // Simulate concurrent creation by creating multiple cars rapidly + const carPromises = Array.from({ length: 5 }, (_, index) => + CarsService.createCar(org, adminUser, `Concurrent Car ${index}`) + ); + + const createdCars = await Promise.all(carPromises); + + // Verify all cars were created with proper numbering + const carNumbers = createdCars.map(car => car.wbsNum.carNumber).sort(); + expect(carNumbers).toEqual([0, 1, 2, 3, 4]); + + // Verify getCurrentCar returns the highest numbered car + const currentCar = await CarsService.getCurrentCar(org); + expect(currentCar!.wbsNum.carNumber).toBe(4); + }); + }); + + describe('createCar Integration', () => { + it('creates car with proper database relationships', async () => { + const carName = 'Integration Test Car'; + + const createdCar = await CarsService.createCar(org, adminUser, carName); + + // Verify the car was properly created in the database + const dbCar = await prisma.car.findUnique({ + where: { carId: createdCar.id }, + include: { + wbsElement: { + include: { + lead: true, + manager: true, + organization: true + } + } + } + }); + + expect(dbCar).not.toBeNull(); + expect(dbCar!.wbsElement.name).toBe(carName); + expect(dbCar!.wbsElement.leadId).toBe(adminUser.userId); + expect(dbCar!.wbsElement.managerId).toBe(adminUser.userId); + expect(dbCar!.wbsElement.organizationId).toBe(org.organizationId); + }); + + it('maintains data integrity across transactions', async () => { + const initialCarCount = await prisma.car.count({ + where: { + wbsElement: { + organizationId: org.organizationId + } + } + }); + + try { + // This should fail due to permissions + await CarsService.createCar(org, nonAdminUser, 'Should Fail'); + } catch (error) { + expect(error).toBeInstanceOf(AccessDeniedAdminOnlyException); + } + + // Verify no car was created due to the failed transaction + const finalCarCount = await prisma.car.count({ + where: { + wbsElement: { + organizationId: org.organizationId + } + } + }); + + expect(finalCarCount).toBe(initialCarCount); + }); + + it('handles database constraints properly', async () => { + // Test with edge cases that might violate constraints + const longName = 'A'.repeat(1000); // Very long name + + // This should either succeed or fail gracefully depending on DB constraints + await expect(async () => { + await CarsService.createCar(org, adminUser, longName); + }).not.toThrow(/Unexpected error/); + }); + }); + + describe('Cross-Organization Data Isolation', () => { + it('ensures complete data isolation between organizations', async () => { + // Create cars in first org + await CarsService.createCar(org, adminUser, 'Org1 Car 1'); + await CarsService.createCar(org, adminUser, 'Org1 Car 2'); + + // Create second org with its own cars + const org2 = await createTestOrganization(); + const admin2 = await createTestUser(supermanAdmin, org2.organizationId); + await CarsService.createCar(org2, admin2, 'Org2 Car 1'); + + // Verify each org only sees its own cars + const org1Cars = await CarsService.getAllCars(org); + const org2Cars = await CarsService.getAllCars(org2); + + expect(org1Cars).toHaveLength(2); + expect(org2Cars).toHaveLength(1); + + expect(org1Cars.every(car => car.name.startsWith('Org1'))).toBe(true); + expect(org2Cars.every(car => car.name.startsWith('Org2'))).toBe(true); + + // Verify current car logic is also isolated + const org1Current = await CarsService.getCurrentCar(org); + const org2Current = await CarsService.getCurrentCar(org2); + + expect(org1Current!.name).toBe('Org1 Car 2'); + expect(org2Current!.name).toBe('Org2 Car 1'); + }); + }); + + describe('Error Handling and Edge Cases', () => { + it('handles database connection issues gracefully', async () => { + // Create a car first + await CarsService.createCar(org, adminUser, 'Test Car'); + + // This test would require mocking Prisma to simulate connection issues + // For now, we'll test that the service handles null/undefined gracefully + const result = await CarsService.getCurrentCar(org); + expect(result).toBeTruthy(); + }); + + it('maintains consistency during high-concurrency operations', async () => { + // Simulate multiple users creating cars simultaneously + const promises = Array.from({ length: 10 }, (_, i) => + CarsService.createCar(org, adminUser, `Concurrent Car ${i}`) + ); + + const results = await Promise.allSettled(promises); + const successful = results.filter(r => r.status === 'fulfilled'); + + // All cars should be created successfully + expect(successful).toHaveLength(10); + + // Verify car numbering is sequential + const cars = await CarsService.getAllCars(org); + const carNumbers = cars.map(car => car.wbsNum.carNumber).sort((a, b) => a - b); + expect(carNumbers).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + + it('handles edge cases in car naming and validation', async () => { + // Test various edge cases + const edgeCases = [ + 'Car with special chars !@#$%^&*()', + 'Car with numbers 12345', + 'Car with spaces and tabs', + 'Very long car name that exceeds typical limits but should still work fine', + '车名中文', // Non-ASCII characters + '' // Empty string + ]; + + for (const carName of edgeCases) { + try { + const car = await CarsService.createCar(org, adminUser, carName); + expect(car.name).toBe(carName); + } catch (error) { + // Some edge cases might fail due to validation, which is acceptable + console.log(`Edge case "${carName}" failed as expected:`, error); + } + } + }); + + it('properly handles organization and user validation', async () => { + // Test with malformed organization + const invalidOrg = { organizationId: '' } as Organization; + + await expect(CarsService.getCurrentCar(invalidOrg)).rejects.toThrow(); + + // Test with non-existent organization + const nonExistentOrg = { organizationId: 'non-existent' } as Organization; + const result = await CarsService.getCurrentCar(nonExistentOrg); + expect(result).toBeNull(); + }); + + it('handles transaction rollbacks properly', async () => { + const initialCount = await prisma.car.count({ + where: { wbsElement: { organizationId: org.organizationId } } + }); + + // Attempt operation that should fail + try { + await CarsService.createCar(org, nonAdminUser, 'Should Fail'); + } catch (error) { + expect(error).toBeInstanceOf(AccessDeniedAdminOnlyException); + } + + // Verify count hasn't changed + const finalCount = await prisma.car.count({ + where: { wbsElement: { organizationId: org.organizationId } } + }); + + expect(finalCount).toBe(initialCount); + }); + + it('ensures proper cleanup and resource management', async () => { + // Create multiple cars and verify they can be properly queried + const carNames = ['Car A', 'Car B', 'Car C']; + const createdCars = []; + + for (const name of carNames) { + const car = await CarsService.createCar(org, adminUser, name); + createdCars.push(car); + } + + // Verify all cars exist + const allCars = await CarsService.getAllCars(org); + expect(allCars).toHaveLength(3); + + // Verify current car is the latest + const currentCar = await CarsService.getCurrentCar(org); + expect(currentCar!.name).toBe('Car C'); + expect(currentCar!.wbsNum.carNumber).toBe(2); // 0-indexed, so third car is #2 + }); + }); +}); +}); + +describe('Cars API Integration Tests', () => { + let org: Organization; + let adminUser: User; + let nonAdminUser: User; + + beforeEach(async () => { + org = await createTestOrganization(); + adminUser = await createTestUser(supermanAdmin, org.organizationId); + nonAdminUser = await createTestUser(batmanAppAdmin, org.organizationId); + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('GET /cars', () => { + it('returns empty array when no cars exist', async () => { + const response = await request(app) + .get('/cars') + .set('authorization', `Bearer ${adminUser.userId}`) + .expect(200); + + expect(response.body).toEqual([]); + }); + + it('returns all cars for organization', async () => { + // Create test cars + await createTestCar(org.organizationId, adminUser.userId); + await createTestCar(org.organizationId, adminUser.userId); + + const response = await request(app) + .get('/cars') + .set('authorization', `Bearer ${adminUser.userId}`) + .expect(200); + + expect(response.body).toHaveLength(2); + expect(response.body[0]).toHaveProperty('id'); + expect(response.body[0]).toHaveProperty('name'); + expect(response.body[0]).toHaveProperty('wbsNum'); + expect(response.body[0]).toHaveProperty('dateCreated'); + }); + + it('only returns cars for user\'s organization', async () => { + // Create car in our org + await createTestCar(org.organizationId, adminUser.userId); + + // Create car in different org + const otherOrg = await createTestOrganization(); + const otherUser = await createTestUser(supermanAdmin, otherOrg.organizationId); + await createTestCar(otherOrg.organizationId, otherUser.userId); + + const response = await request(app) + .get('/cars') + .set('authorization', `Bearer ${adminUser.userId}`) + .expect(200); + + expect(response.body).toHaveLength(1); + }); + + it('requires authentication', async () => { + await request(app) + .get('/cars') + .expect(401); + }); + }); + + describe('GET /cars/current', () => { + it('returns null when no cars exist', async () => { + const response = await request(app) + .get('/cars/current') + .set('authorization', `Bearer ${adminUser.userId}`) + .expect(200); + + expect(response.body).toBeNull(); + }); + + it('returns the only car when one exists', async () => { + const testCar = await createTestCar(org.organizationId, adminUser.userId); + + const response = await request(app) + .get('/cars/current') + .set('authorization', `Bearer ${adminUser.userId}`) + .expect(200); + + expect(response.body).not.toBeNull(); + expect(response.body.id).toBe(testCar.carId); + }); + + it('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 + } + } + } + }); + + const car3 = 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 response = await request(app) + .get('/cars/current') + .set('authorization', `Bearer ${adminUser.userId}`) + .expect(200); + + expect(response.body).not.toBeNull(); + expect(response.body.wbsNum.carNumber).toBe(3); + expect(response.body.id).toBe(car3.carId); + }); + + it('only considers cars from user\'s 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 otherOrg = await createTestOrganization(); + const otherUser = await createTestUser(supermanAdmin, 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 response = await request(app) + .get('/cars/current') + .set('authorization', `Bearer ${adminUser.userId}`) + .expect(200); + + expect(response.body).not.toBeNull(); + expect(response.body.wbsNum.carNumber).toBe(1); + expect(response.body.name).toBe('Our Car'); + }); + + it('requires authentication', async () => { + await request(app) + .get('/cars/current') + .expect(401); + }); + }); + + describe('POST /cars/create', () => { + it('successfully creates car with admin permissions', async () => { + const carData = { name: 'Test Car' }; + + const response = await request(app) + .post('/cars/create') + .set('authorization', `Bearer ${adminUser.userId}`) + .send(carData) + .expect(201); + + expect(response.body.name).toBe('Test Car'); + expect(response.body.wbsNum.carNumber).toBe(0); // First car should have car number 0 + expect(response.body.wbsNum.projectNumber).toBe(0); + expect(response.body.wbsNum.workPackageNumber).toBe(0); + }); + + it('assigns correct car number based on existing cars', async () => { + // Create first car + await request(app) + .post('/cars/create') + .set('authorization', `Bearer ${adminUser.userId}`) + .send({ name: 'Car 1' }) + .expect(201); + + // Create second car + const response = await request(app) + .post('/cars/create') + .set('authorization', `Bearer ${adminUser.userId}`) + .send({ name: 'Car 2' }) + .expect(201); + + expect(response.body.wbsNum.carNumber).toBe(1); // Should be incremented + }); + + it('denies access for non-admin user', async () => { + const carData = { name: 'Test Car' }; + + await request(app) + .post('/cars/create') + .set('authorization', `Bearer ${nonAdminUser.userId}`) + .send(carData) + .expect(400); // AccessDeniedAdminOnlyException should return 400 + }); + + it('requires car name', async () => { + await request(app) + .post('/cars/create') + .set('authorization', `Bearer ${adminUser.userId}`) + .send({}) + .expect(400); + }); + + it('requires authentication', async () => { + await request(app) + .post('/cars/create') + .send({ name: 'Test Car' }) + .expect(401); + }); + + it('car numbers are organization-specific', async () => { + // Create car in first org + const firstResponse = await request(app) + .post('/cars/create') + .set('authorization', `Bearer ${adminUser.userId}`) + .send({ name: 'First Org Car' }) + .expect(201); + + // Create different org and admin + const otherOrg = await createTestOrganization(); + const otherAdmin = await createTestUser(supermanAdmin, otherOrg.organizationId); + + // Create car in second org + const secondResponse = await request(app) + .post('/cars/create') + .set('authorization', `Bearer ${otherAdmin.userId}`) + .send({ name: 'Second Org Car' }) + .expect(201); + + // Both should start from car number 0 + expect(firstResponse.body.wbsNum.carNumber).toBe(0); + expect(secondResponse.body.wbsNum.carNumber).toBe(0); + }); + }); +}); diff --git a/src/backend/tests/unmocked/cars.test.ts b/src/backend/tests/unmocked/cars.test.ts new file mode 100644 index 0000000000..85f8d1c255 --- /dev/null +++ b/src/backend/tests/unmocked/cars.test.ts @@ -0,0 +1,207 @@ +import { Organization, User } from '@prisma/client'; +import { createTestOrganization, createTestUser, resetUsers, createTestCar } from '../test-utils'; +import { supermanAdmin, batmanAppAdmin } 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(batmanAppAdmin, 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 + await createTestCar(org.organizationId, adminUser.userId); + await createTestCar(org.organizationId, 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 createTestCar(org.organizationId, adminUser.userId); + + // Create car in different org + const otherOrg = await createTestOrganization(); + const otherUser = await createTestUser(supermanAdmin, otherOrg.organizationId); + await createTestCar(otherOrg.organizationId, 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 + } + } + } + }); + + const car2 = 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 otherOrg = await createTestOrganization(); + const otherUser = await createTestUser(supermanAdmin, 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 otherOrg = await createTestOrganization(); + const otherAdmin = await createTestUser(supermanAdmin, 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/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/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..0b39bfa80d --- /dev/null +++ b/src/frontend/src/tests/test-support/test-data/cars.stub.ts @@ -0,0 +1,61 @@ +/* + * 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 diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 78f3a99beb..f27ff2100e 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -374,6 +374,7 @@ const organizationsSetSlackSponsorshipNotificationChannelId = () => `${organizat /******************* Car Endpoints ********************/ const cars = () => `${API_URL}/cars`; +const carsCurrent = () => `${cars()}/current`; const carsCreate = () => `${cars()}/create`; /************** Recruitment Endpoints ***************/ @@ -679,6 +680,7 @@ export const apiUrls = { organizationsSetSlackSponsorshipNotificationChannelId, cars, + carsCurrent, carsCreate, recruitment, From 3acd65fbc34efd56d6e7be7147be344868ae1a36 Mon Sep 17 00:00:00 2001 From: harish Date: Thu, 16 Oct 2025 17:25:04 -0400 Subject: [PATCH 02/40] hooks --- src/frontend/src/app/AppContext.tsx | 5 +- .../src/app/AppGlobalCarFilterContext.tsx | 83 +++++++ .../components/FinanceDashboardCarFilter.tsx | 154 +++++++++++++ .../components/GlobalCarFilterDropdown.tsx | 176 +++++++++++++++ .../src/hooks/finance-car-filter.hooks.ts | 100 ++++++++ .../src/hooks/page-car-filter.hooks.ts | 94 ++++++++ .../src/layouts/Sidebar/NavPageLink.tsx | 2 +- src/frontend/src/layouts/Sidebar/Sidebar.tsx | 6 + .../hooks/GlobalCarFilterContext.test.tsx | 213 ++++++++++++++++++ .../tests/test-support/test-data/cars.stub.ts | 5 + 10 files changed, 836 insertions(+), 2 deletions(-) create mode 100644 src/frontend/src/app/AppGlobalCarFilterContext.tsx create mode 100644 src/frontend/src/components/FinanceDashboardCarFilter.tsx create mode 100644 src/frontend/src/components/GlobalCarFilterDropdown.tsx create mode 100644 src/frontend/src/hooks/finance-car-filter.hooks.ts create mode 100644 src/frontend/src/hooks/page-car-filter.hooks.ts create mode 100644 src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx diff --git a/src/frontend/src/app/AppContext.tsx b/src/frontend/src/app/AppContext.tsx index 98343cfcb2..d0b63b9e05 100644 --- a/src/frontend/src/app/AppContext.tsx +++ b/src/frontend/src/app/AppContext.tsx @@ -8,6 +8,7 @@ import AppContextQuery from './AppContextQuery'; import AppContextTheme from './AppContextTheme'; import AppContextOrganization from './AppOrganizationContext'; import { HomePageProvider } from './HomePageContext'; +import { GlobalCarFilterProvider } from './AppGlobalCarFilterContext'; const AppContext: React.FC = (props) => { return ( @@ -15,7 +16,9 @@ const AppContext: React.FC = (props) => { - {props.children} + + {props.children} + diff --git a/src/frontend/src/app/AppGlobalCarFilterContext.tsx b/src/frontend/src/app/AppGlobalCarFilterContext.tsx new file mode 100644 index 0000000000..9d7c5197ea --- /dev/null +++ b/src/frontend/src/app/AppGlobalCarFilterContext.tsx @@ -0,0 +1,83 @@ +/* + * 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, ReactNode } from 'react'; +import { Car } from 'shared'; +import { useGetCurrentCar, useGetAllCars } from '../hooks/cars.hooks'; + +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 { data: currentCar, isLoading: currentCarLoading, error: currentCarError } = useGetCurrentCar(); + const { data: allCars = [], isLoading: allCarsLoading, error: allCarsError } = useGetAllCars(); + + const isLoading = currentCarLoading || allCarsLoading; + const error = currentCarError || allCarsError; + + useEffect(() => { + if (!isLoading && allCars.length > 0) { + const savedCarId = sessionStorage.getItem('selectedCarId'); + + if (savedCarId) { + const savedCar = allCars.find((car) => car.id === savedCarId); + if (savedCar) { + setSelectedCarState(savedCar); + return; + } + } + + if (currentCar) { + setSelectedCarState(currentCar); + } else if (allCars.length > 0) { + const mostRecentCar = allCars.reduce((latest, car) => + car.wbsNum.carNumber > latest.wbsNum.carNumber ? car : latest + ); + setSelectedCarState(mostRecentCar); + } + } + }, [currentCar, allCars, isLoading]); + + const setSelectedCar = (car: Car | null) => { + setSelectedCarState(car); + + if (car) { + sessionStorage.setItem('selectedCarId', car.id); + } else { + sessionStorage.removeItem('selectedCarId'); + } + }; + + 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..ea0d99346e --- /dev/null +++ b/src/frontend/src/components/FinanceDashboardCarFilter.tsx @@ -0,0 +1,154 @@ +/* + * 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); + setSelectedCar(car || null); + } else { + setSelectedCar(null); + } + }; + + 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 && ( + + {startDate.toLocaleDateString()} - {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..843d9f48da --- /dev/null +++ b/src/frontend/src/components/GlobalCarFilterDropdown.tsx @@ -0,0 +1,176 @@ +/* + * 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, Menu, MenuItem, Chip, Tooltip, Paper, useTheme } from '@mui/material'; +import { ExpandMore as ExpandMoreIcon, DirectionsCar as CarIcon, HelpOutline as HelpIcon } from '@mui/icons-material'; +import { useGlobalCarFilter } from '../app/AppGlobalCarFilterContext'; +import LoadingIndicator from './LoadingIndicator'; + +interface GlobalCarFilterDropdownProps { + compact?: boolean; + sx?: object; +} + +const GlobalCarFilterDropdown: React.FC = ({ compact = false, sx = {} }) => { + const theme = useTheme(); + const { selectedCar, allCars, setSelectedCar, isLoading, error } = useGlobalCarFilter(); + const [anchorEl, setAnchorEl] = useState(null); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleCarSelect = (car: any) => { + setSelectedCar(car); + handleClose(); + }; + + if (isLoading) { + return ; + } + + if (error || !selectedCar) { + return ( + + + {error?.message || 'No car selected'} + + + ); + } + + const sortedCars = [...allCars].sort((a, b) => b.wbsNum.carNumber - a.wbsNum.carNumber); + + const currentCarLabel = selectedCar.wbsNum.carNumber === 0 ? selectedCar.name : `Car ${selectedCar.wbsNum.carNumber}`; + + if (compact) { + return ( + + + } + variant="outlined" + size="small" + sx={{ + borderColor: theme.palette.primary.main, + color: theme.palette.primary.main, + '& .MuiChip-deleteIcon': { + color: theme.palette.primary.main + } + }} + /> + + {sortedCars.map((car) => ( + handleCarSelect(car)}> + + + {car.wbsNum.carNumber === 0 ? car.name : `Car ${car.wbsNum.carNumber}`} + + + {car.name} + + + + ))} + + + ); + } + + return ( + + + + + + Global Car Filter + + + {selectedCar.name} + + + + + + + + + } + color="primary" + variant="outlined" + /> + + + {sortedCars.map((car) => ( + handleCarSelect(car)} + sx={{ py: 1.5 }} + > + + + {car.wbsNum.carNumber === 0 ? car.name : `Car ${car.wbsNum.carNumber}`} + + + {car.name} + + + Created: {car.dateCreated.toLocaleDateString()} + + + + ))} + + + + ); +}; + +export default GlobalCarFilterDropdown; 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..8b90abf705 --- /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 | null) => 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 | null) => { + 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/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 6fd7b11f4d..680cc06764 100644 --- a/src/frontend/src/layouts/Sidebar/Sidebar.tsx +++ b/src/frontend/src/layouts/Sidebar/Sidebar.tsx @@ -28,6 +28,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; @@ -167,12 +168,17 @@ const Sidebar = ({ drawerOpen, setDrawerOpen, moveContent, setMoveContent }: Sid {linkItems.map((linkItem) => ( handleOpenSubmenu(linkItem.name)} onSubmenuCollapse={() => handleCloseSubmenu()} /> ))} + + + + 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..cef5914714 --- /dev/null +++ b/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx @@ -0,0 +1,213 @@ +/* + * 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, 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, exampleCurrentCar } from '../test-support/test-data/cars.stub'; + +// Mock the hooks +vi.mock('../../hooks/cars.hooks'); +const mockUseGetCurrentCar = vi.mocked(carsHooks.useGetCurrentCar); +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(() => { + // Clear session storage + sessionStorage.clear(); + + // Reset mocks + vi.clearAllMocks(); + }); + + it('should initialize with current car when available', async () => { + mockUseGetCurrentCar.mockReturnValue({ + data: exampleCurrentCar, + isLoading: false, + error: null + } as any); + + mockUseGetAllCars.mockReturnValue({ + data: exampleAllCars, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + await waitFor(() => { + expect(result.current.selectedCar).toEqual(exampleCurrentCar); + }); + + expect(result.current.allCars).toEqual(exampleAllCars); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('should initialize with most recent car when no current car', async () => { + mockUseGetCurrentCar.mockReturnValue({ + data: null, + isLoading: false, + error: null + } as any); + + mockUseGetAllCars.mockReturnValue({ + data: exampleAllCars, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + await waitFor(() => { + expect(result.current.selectedCar).toEqual(exampleAllCars[2]); // Car 2025 has highest car number + }); + }); + + it('should restore car from session storage', async () => { + // Set session storage + sessionStorage.setItem('selectedCarId', exampleAllCars[0].id); + + mockUseGetCurrentCar.mockReturnValue({ + data: exampleCurrentCar, + isLoading: false, + error: null + } as any); + + 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]); // Car 2023 + }); + }); + + it('should persist car selection to session storage', async () => { + mockUseGetCurrentCar.mockReturnValue({ + data: exampleCurrentCar, + isLoading: false, + error: null + } as any); + + mockUseGetAllCars.mockReturnValue({ + data: exampleAllCars, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + await waitFor(() => { + expect(result.current.selectedCar).toBeTruthy(); + }); + + // Change selection + result.current.setSelectedCar(exampleAllCars[1]); + + expect(sessionStorage.getItem('selectedCarId')).toBe(exampleAllCars[1].id); + }); + + it('should handle loading state', () => { + mockUseGetCurrentCar.mockReturnValue({ + data: undefined, + isLoading: true, + error: null + } as any); + + 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'); + + mockUseGetCurrentCar.mockReturnValue({ + data: undefined, + isLoading: false, + error + } as any); + + mockUseGetAllCars.mockReturnValue({ + data: undefined, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + expect(result.current.error).toBe(error); + expect(result.current.isLoading).toBe(false); + }); + + it('should clear session storage when setting car to null', async () => { + mockUseGetCurrentCar.mockReturnValue({ + data: exampleCurrentCar, + isLoading: false, + error: null + } as any); + + mockUseGetAllCars.mockReturnValue({ + data: exampleAllCars, + isLoading: false, + error: null + } as any); + + const { result } = renderHook(() => useGlobalCarFilter(), { + wrapper: createWrapper() + }); + + await waitFor(() => { + expect(result.current.selectedCar).toBeTruthy(); + }); + + // Clear selection + result.current.setSelectedCar(null); + + expect(sessionStorage.getItem('selectedCarId')).toBeNull(); + expect(result.current.selectedCar).toBeNull(); + }); +}); 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 index 0b39bfa80d..db7d813004 100644 --- a/src/frontend/src/tests/test-support/test-data/cars.stub.ts +++ b/src/frontend/src/tests/test-support/test-data/cars.stub.ts @@ -59,3 +59,8 @@ export const exampleCar3: Car = { 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]; From 437803a40d0133c8665c146b95c6c595488f81d3 Mon Sep 17 00:00:00 2001 From: harish Date: Sat, 6 Dec 2025 16:28:47 -0500 Subject: [PATCH 03/40] #3629 frontend changes for CAR --- .../AdminFinanceDashboard.tsx | 162 ++--------------- .../GeneralFinanceDashboard.tsx | 169 +++--------------- 2 files changed, 37 insertions(+), 294 deletions(-) diff --git a/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx b/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx index 52fdce6039..87952a206a 100644 --- a/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { useAllTeamTypes } from '../../../hooks/team-types.hooks'; import ErrorPage from '../../ErrorPage'; import LoadingIndicator from '../../../components/LoadingIndicator'; @@ -16,12 +16,11 @@ import { ArrowDropDownIcon } from '@mui/x-date-pickers/icons'; import { ListItemIcon, Menu, MenuItem } from '@mui/material'; import PendingAdvisorModal from '../FinanceComponents/PendingAdvisorListModal'; import TotalAmountSpentModal from '../FinanceComponents/TotalAmountSpentModal'; -import { DatePicker } from '@mui/x-date-pickers'; import ListAltIcon from '@mui/icons-material/ListAlt'; import WorkIcon from '@mui/icons-material/Work'; import { isAdmin } from 'shared'; -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 AdminFinanceDashboardProps { startDate?: Date; @@ -36,9 +35,8 @@ const AdminFinanceDashboard: React.FC = ({ startDate const [tabIndex, setTabIndex] = useState(0); const [showPendingAdvisorListModal, setShowPendingAdvisorListModal] = useState(false); const [showTotalAmountSpent, setShowTotalAmountSpent] = useState(false); - const [startDateState, setStartDateState] = useState(startDate); - const [endDateState, setEndDateState] = useState(endDate); - const [carNumberState, setCarNumberState] = useState(carNumber); + + const filter = useFinanceDashboardCarFilter(startDate, endDate, carNumber); const { data: allTeamTypes, @@ -59,16 +57,8 @@ const AdminFinanceDashboard: React.FC = ({ startDate error: allPendingAdvisorListError } = useGetPendingAdvisorList(); - const { data: allCars, isLoading: allCarsIsLoading, isError: allCarsIsError, error: allCarsError } = useGetAllCars(); - - useEffect(() => { - if (carNumberState === undefined && allCars && allCars.length > 0) { - setCarNumberState(allCars[allCars.length - 1].wbsNum.carNumber); - } - }, [allCars, carNumberState]); - - if (allCarsIsError) { - return ; + if (filter.error) { + return ; } if (allTeamTypesIsError) { @@ -90,17 +80,11 @@ const AdminFinanceDashboard: React.FC = ({ startDate allReimbursementRequestsIsLoading || !allPendingAdvisorList || allPendingAdvisorListIsLoading || - !allCars || - allCarsIsLoading + filter.isLoading ) { return ; } - const carAutocompleteOptions = allCars.map((car) => ({ - label: car.name, - id: car.wbsNum.carNumber.toString() - })); - const tabs = []; tabs.push({ tabUrlValue: 'all', tabName: 'All' }); @@ -124,83 +108,13 @@ const AdminFinanceDashboard: React.FC = ({ startDate setAnchorEl(null); }; - const datePickerStyle = { - width: 150, - 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' - } - } - }; - const dateAndActionsDropdown = ( = ({ 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)} - /> - - - - - - - (startDateState ? date < startDateState : false)} - slotProps={{ - textField: { - size: 'small', - sx: datePickerStyle - }, - field: { clearable: true } - }} - onChange={(newValue: Date | null) => setEndDateState(newValue ?? undefined)} - /> - + } variant="contained" @@ -319,16 +189,16 @@ const AdminFinanceDashboard: React.FC = ({ startDate /> )} {tabIndex === 0 ? ( - + ) : tabIndex === tabs.length - 1 ? ( - + ) : ( selectedTab && ( ) )} 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 && ( )} From 936b908155c940572531f78e3e2c9e8326f78574 Mon Sep 17 00:00:00 2001 From: harish Date: Sat, 6 Dec 2025 21:40:39 -0500 Subject: [PATCH 04/40] #3629 tests pass --- .../integration/cars.integration.test.ts | 940 ------------------ src/backend/tests/unmocked/cars.test.ts | 163 ++- 2 files changed, 150 insertions(+), 953 deletions(-) delete mode 100644 src/backend/tests/integration/cars.integration.test.ts diff --git a/src/backend/tests/integration/cars.integration.test.ts b/src/backend/tests/integration/cars.integration.test.ts deleted file mode 100644 index d64fef3e2d..0000000000 --- a/src/backend/tests/integration/cars.integration.test.ts +++ /dev/null @@ -1,940 +0,0 @@ -/* - * 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 { Organization, User } from '@prisma/client'; -import { createTestOrganization, createTestUser, resetUsers, createTestCar } from '../test-utils'; -import { supermanAdmin, batmanAppAdmin } 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 Service Integration Tests', () => { - let org: Organization; - let adminUser: User; - let nonAdminUser: User; - - beforeEach(async () => { - org = await createTestOrganization(); - adminUser = await createTestUser(supermanAdmin, org.organizationId); - nonAdminUser = await createTestUser(batmanAppAdmin, org.organizationId); - }); - - afterEach(async () => { - await resetUsers(); - }); - - describe('getAllCars Integration', () => { - it('returns cars with proper transformation and relations', async () => { - // Create test cars with complex data - const car1 = await createTestCar(org.organizationId, adminUser.userId); - const car2 = await createTestCar(org.organizationId, adminUser.userId); - - const cars = await CarsService.getAllCars(org); - - expect(cars).toHaveLength(2); - expect(cars[0]).toHaveProperty('id'); - expect(cars[0]).toHaveProperty('name'); - expect(cars[0]).toHaveProperty('wbsNum'); - expect(cars[0]).toHaveProperty('dateCreated'); - expect(cars[0]).toHaveProperty('lead'); - expect(cars[0]).toHaveProperty('manager'); - }); - - it('handles database errors gracefully', async () => { - // Create a mock organization that doesn't exist - const fakeOrg = { organizationId: 'non-existent-org' } as Organization; - - const cars = await CarsService.getAllCars(fakeOrg); - expect(cars).toEqual([]); - }); - }); - - describe('getCurrentCar Integration', () => { - it('correctly identifies current car with database ordering', async () => { - // Create cars with specific ordering scenarios - await prisma.car.create({ - data: { - wbsElement: { - create: { - carNumber: 5, - projectNumber: 0, - workPackageNumber: 0, - name: 'Middle Car', - organizationId: org.organizationId, - leadId: adminUser.userId, - managerId: adminUser.userId - } - } - } - }); - - await prisma.car.create({ - data: { - wbsElement: { - create: { - carNumber: 10, - projectNumber: 0, - workPackageNumber: 0, - name: 'Latest Car', - organizationId: org.organizationId, - leadId: adminUser.userId, - managerId: adminUser.userId - } - } - } - }); - - await prisma.car.create({ - data: { - wbsElement: { - create: { - carNumber: 1, - projectNumber: 0, - workPackageNumber: 0, - name: 'Old Car', - organizationId: org.organizationId, - leadId: adminUser.userId, - managerId: adminUser.userId - } - } - } - }); - - const currentCar = await CarsService.getCurrentCar(org); - expect(currentCar).not.toBeNull(); - expect(currentCar!.wbsNum.carNumber).toBe(10); - expect(currentCar!.name).toBe('Latest Car'); - }); - - it('handles concurrent car creation scenarios', async () => { - // Simulate concurrent creation by creating multiple cars rapidly - const carPromises = Array.from({ length: 5 }, (_, index) => - CarsService.createCar(org, adminUser, `Concurrent Car ${index}`) - ); - - const createdCars = await Promise.all(carPromises); - - // Verify all cars were created with proper numbering - const carNumbers = createdCars.map(car => car.wbsNum.carNumber).sort(); - expect(carNumbers).toEqual([0, 1, 2, 3, 4]); - - // Verify getCurrentCar returns the highest numbered car - const currentCar = await CarsService.getCurrentCar(org); - expect(currentCar!.wbsNum.carNumber).toBe(4); - }); - }); - - describe('createCar Integration', () => { - it('creates car with proper database relationships', async () => { - const carName = 'Integration Test Car'; - - const createdCar = await CarsService.createCar(org, adminUser, carName); - - // Verify the car was properly created in the database - const dbCar = await prisma.car.findUnique({ - where: { carId: createdCar.id }, - include: { - wbsElement: { - include: { - lead: true, - manager: true, - organization: true - } - } - } - }); - - expect(dbCar).not.toBeNull(); - expect(dbCar!.wbsElement.name).toBe(carName); - expect(dbCar!.wbsElement.leadId).toBe(adminUser.userId); - expect(dbCar!.wbsElement.managerId).toBe(adminUser.userId); - expect(dbCar!.wbsElement.organizationId).toBe(org.organizationId); - }); - - it('maintains data integrity across transactions', async () => { - const initialCarCount = await prisma.car.count({ - where: { - wbsElement: { - organizationId: org.organizationId - } - } - }); - - try { - // This should fail due to permissions - await CarsService.createCar(org, nonAdminUser, 'Should Fail'); - } catch (error) { - expect(error).toBeInstanceOf(AccessDeniedAdminOnlyException); - } - - // Verify no car was created due to the failed transaction - const finalCarCount = await prisma.car.count({ - where: { - wbsElement: { - organizationId: org.organizationId - } - } - }); - - expect(finalCarCount).toBe(initialCarCount); - }); - - it('handles database constraints properly', async () => { - // Test with edge cases that might violate constraints - const longName = 'A'.repeat(1000); // Very long name - - // This should either succeed or fail gracefully depending on DB constraints - await expect(async () => { - await CarsService.createCar(org, adminUser, longName); - }).not.toThrow(/Unexpected error/); - }); - }); - - describe('Cross-Organization Data Isolation', () => { - it('ensures complete data isolation between organizations', async () => { - // Create cars in first org - await CarsService.createCar(org, adminUser, 'Org1 Car 1'); - await CarsService.createCar(org, adminUser, 'Org1 Car 2'); - - // Create second org with its own cars - const org2 = await createTestOrganization(); - const admin2 = await createTestUser(supermanAdmin, org2.organizationId); - await CarsService.createCar(org2, admin2, 'Org2 Car 1'); - - // Verify each org only sees its own cars - const org1Cars = await CarsService.getAllCars(org); - const org2Cars = await CarsService.getAllCars(org2); - - expect(org1Cars).toHaveLength(2); - expect(org2Cars).toHaveLength(1); - - expect(org1Cars.every(car => car.name.startsWith('Org1'))).toBe(true); - expect(org2Cars.every(car => car.name.startsWith('Org2'))).toBe(true); - - // Verify current car logic is also isolated - const org1Current = await CarsService.getCurrentCar(org); - const org2Current = await CarsService.getCurrentCar(org2); - - expect(org1Current!.name).toBe('Org1 Car 2'); - expect(org2Current!.name).toBe('Org2 Car 1'); - }); - }); - - describe('Error Handling and Edge Cases', () => { - it('handles database connection issues gracefully', async () => { - // Create a car first - await CarsService.createCar(org, adminUser, 'Test Car'); - - // This test would require mocking Prisma to simulate connection issues - // For now, we'll test that the service handles null/undefined gracefully - const result = await CarsService.getCurrentCar(org); - expect(result).toBeTruthy(); - }); - - it('maintains consistency during high-concurrency operations', async () => { - // Simulate multiple users creating cars simultaneously - const promises = Array.from({ length: 10 }, (_, i) => - CarsService.createCar(org, adminUser, `Concurrent Car ${i}`) - ); - - const results = await Promise.allSettled(promises); - const successful = results.filter(r => r.status === 'fulfilled'); - - // All cars should be created successfully - expect(successful).toHaveLength(10); - - // Verify car numbering is sequential - const cars = await CarsService.getAllCars(org); - const carNumbers = cars.map(car => car.wbsNum.carNumber).sort((a, b) => a - b); - expect(carNumbers).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); - }); - - it('handles edge cases in car naming and validation', async () => { - // Test various edge cases - const edgeCases = [ - 'Car with special chars !@#$%^&*()', - 'Car with numbers 12345', - 'Car with spaces and tabs', - 'Very long car name that exceeds typical limits but should still work fine', - '车名中文', // Non-ASCII characters - '' // Empty string - ]; - - for (const carName of edgeCases) { - try { - const car = await CarsService.createCar(org, adminUser, carName); - expect(car.name).toBe(carName); - } catch (error) { - // Some edge cases might fail due to validation, which is acceptable - console.log(`Edge case "${carName}" failed as expected:`, error); - } - } - }); - - it('properly handles organization and user validation', async () => { - // Test with malformed organization - const invalidOrg = { organizationId: '' } as Organization; - - await expect(CarsService.getCurrentCar(invalidOrg)).rejects.toThrow(); - - // Test with non-existent organization - const nonExistentOrg = { organizationId: 'non-existent' } as Organization; - const result = await CarsService.getCurrentCar(nonExistentOrg); - expect(result).toBeNull(); - }); - - it('handles transaction rollbacks properly', async () => { - const initialCount = await prisma.car.count({ - where: { wbsElement: { organizationId: org.organizationId } } - }); - - // Attempt operation that should fail - try { - await CarsService.createCar(org, nonAdminUser, 'Should Fail'); - } catch (error) { - expect(error).toBeInstanceOf(AccessDeniedAdminOnlyException); - } - - // Verify count hasn't changed - const finalCount = await prisma.car.count({ - where: { wbsElement: { organizationId: org.organizationId } } - }); - - expect(finalCount).toBe(initialCount); - }); - - it('ensures proper cleanup and resource management', async () => { - // Create multiple cars and verify they can be properly queried - const carNames = ['Car A', 'Car B', 'Car C']; - const createdCars = []; - - for (const name of carNames) { - const car = await CarsService.createCar(org, adminUser, name); - createdCars.push(car); - } - - // Verify all cars exist - const allCars = await CarsService.getAllCars(org); - expect(allCars).toHaveLength(3); - - // Verify current car is the latest - const currentCar = await CarsService.getCurrentCar(org); - expect(currentCar!.name).toBe('Car C'); - expect(currentCar!.wbsNum.carNumber).toBe(2); // 0-indexed, so third car is #2 - }); - }); -}); - -import { Organization, User } from '@prisma/client'; -import { createTestOrganization, createTestUser, resetUsers, createTestCar } from '../test-utils'; -import { supermanAdmin, batmanAppAdmin } 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 Service Integration Tests', () => { - let org: Organization; - let adminUser: User; - let nonAdminUser: User; - - beforeEach(async () => { - org = await createTestOrganization(); - adminUser = await createTestUser(supermanAdmin, org.organizationId); - nonAdminUser = await createTestUser(batmanAppAdmin, org.organizationId); - }); - - afterEach(async () => { - await resetUsers(); - }); - - describe('getAllCars Integration', () => { - it('returns cars with proper transformation and relations', async () => { - // Create test cars with complex data - const car1 = await createTestCar(org.organizationId, adminUser.userId); - const car2 = await createTestCar(org.organizationId, adminUser.userId); - - const cars = await CarsService.getAllCars(org); - - expect(cars).toHaveLength(2); - expect(cars[0]).toHaveProperty('id'); - expect(cars[0]).toHaveProperty('name'); - expect(cars[0]).toHaveProperty('wbsNum'); - expect(cars[0]).toHaveProperty('dateCreated'); - expect(cars[0]).toHaveProperty('lead'); - expect(cars[0]).toHaveProperty('manager'); - }); - - it('handles database errors gracefully', async () => { - // Create a mock organization that doesn't exist - const fakeOrg = { organizationId: 'non-existent-org' } as Organization; - - const cars = await CarsService.getAllCars(fakeOrg); - expect(cars).toEqual([]); - }); - }); - - describe('getCurrentCar Integration', () => { - it('correctly identifies current car with database ordering', async () => { - // Create cars with specific ordering scenarios - await prisma.car.create({ - data: { - wbsElement: { - create: { - carNumber: 5, - projectNumber: 0, - workPackageNumber: 0, - name: 'Middle Car', - organizationId: org.organizationId, - leadId: adminUser.userId, - managerId: adminUser.userId - } - } - } - }); - - await prisma.car.create({ - data: { - wbsElement: { - create: { - carNumber: 10, - projectNumber: 0, - workPackageNumber: 0, - name: 'Latest Car', - organizationId: org.organizationId, - leadId: adminUser.userId, - managerId: adminUser.userId - } - } - } - }); - - await prisma.car.create({ - data: { - wbsElement: { - create: { - carNumber: 1, - projectNumber: 0, - workPackageNumber: 0, - name: 'Old Car', - organizationId: org.organizationId, - leadId: adminUser.userId, - managerId: adminUser.userId - } - } - } - }); - - const currentCar = await CarsService.getCurrentCar(org); - expect(currentCar).not.toBeNull(); - expect(currentCar!.wbsNum.carNumber).toBe(10); - expect(currentCar!.name).toBe('Latest Car'); - }); - - it('handles concurrent car creation scenarios', async () => { - // Simulate concurrent creation by creating multiple cars rapidly - const carPromises = Array.from({ length: 5 }, (_, index) => - CarsService.createCar(org, adminUser, `Concurrent Car ${index}`) - ); - - const createdCars = await Promise.all(carPromises); - - // Verify all cars were created with proper numbering - const carNumbers = createdCars.map(car => car.wbsNum.carNumber).sort(); - expect(carNumbers).toEqual([0, 1, 2, 3, 4]); - - // Verify getCurrentCar returns the highest numbered car - const currentCar = await CarsService.getCurrentCar(org); - expect(currentCar!.wbsNum.carNumber).toBe(4); - }); - }); - - describe('createCar Integration', () => { - it('creates car with proper database relationships', async () => { - const carName = 'Integration Test Car'; - - const createdCar = await CarsService.createCar(org, adminUser, carName); - - // Verify the car was properly created in the database - const dbCar = await prisma.car.findUnique({ - where: { carId: createdCar.id }, - include: { - wbsElement: { - include: { - lead: true, - manager: true, - organization: true - } - } - } - }); - - expect(dbCar).not.toBeNull(); - expect(dbCar!.wbsElement.name).toBe(carName); - expect(dbCar!.wbsElement.leadId).toBe(adminUser.userId); - expect(dbCar!.wbsElement.managerId).toBe(adminUser.userId); - expect(dbCar!.wbsElement.organizationId).toBe(org.organizationId); - }); - - it('maintains data integrity across transactions', async () => { - const initialCarCount = await prisma.car.count({ - where: { - wbsElement: { - organizationId: org.organizationId - } - } - }); - - try { - // This should fail due to permissions - await CarsService.createCar(org, nonAdminUser, 'Should Fail'); - } catch (error) { - expect(error).toBeInstanceOf(AccessDeniedAdminOnlyException); - } - - // Verify no car was created due to the failed transaction - const finalCarCount = await prisma.car.count({ - where: { - wbsElement: { - organizationId: org.organizationId - } - } - }); - - expect(finalCarCount).toBe(initialCarCount); - }); - - it('handles database constraints properly', async () => { - // Test with edge cases that might violate constraints - const longName = 'A'.repeat(1000); // Very long name - - // This should either succeed or fail gracefully depending on DB constraints - await expect(async () => { - await CarsService.createCar(org, adminUser, longName); - }).not.toThrow(/Unexpected error/); - }); - }); - - describe('Cross-Organization Data Isolation', () => { - it('ensures complete data isolation between organizations', async () => { - // Create cars in first org - await CarsService.createCar(org, adminUser, 'Org1 Car 1'); - await CarsService.createCar(org, adminUser, 'Org1 Car 2'); - - // Create second org with its own cars - const org2 = await createTestOrganization(); - const admin2 = await createTestUser(supermanAdmin, org2.organizationId); - await CarsService.createCar(org2, admin2, 'Org2 Car 1'); - - // Verify each org only sees its own cars - const org1Cars = await CarsService.getAllCars(org); - const org2Cars = await CarsService.getAllCars(org2); - - expect(org1Cars).toHaveLength(2); - expect(org2Cars).toHaveLength(1); - - expect(org1Cars.every(car => car.name.startsWith('Org1'))).toBe(true); - expect(org2Cars.every(car => car.name.startsWith('Org2'))).toBe(true); - - // Verify current car logic is also isolated - const org1Current = await CarsService.getCurrentCar(org); - const org2Current = await CarsService.getCurrentCar(org2); - - expect(org1Current!.name).toBe('Org1 Car 2'); - expect(org2Current!.name).toBe('Org2 Car 1'); - }); - }); - - describe('Error Handling and Edge Cases', () => { - it('handles database connection issues gracefully', async () => { - // Create a car first - await CarsService.createCar(org, adminUser, 'Test Car'); - - // This test would require mocking Prisma to simulate connection issues - // For now, we'll test that the service handles null/undefined gracefully - const result = await CarsService.getCurrentCar(org); - expect(result).toBeTruthy(); - }); - - it('maintains consistency during high-concurrency operations', async () => { - // Simulate multiple users creating cars simultaneously - const promises = Array.from({ length: 10 }, (_, i) => - CarsService.createCar(org, adminUser, `Concurrent Car ${i}`) - ); - - const results = await Promise.allSettled(promises); - const successful = results.filter(r => r.status === 'fulfilled'); - - // All cars should be created successfully - expect(successful).toHaveLength(10); - - // Verify car numbering is sequential - const cars = await CarsService.getAllCars(org); - const carNumbers = cars.map(car => car.wbsNum.carNumber).sort((a, b) => a - b); - expect(carNumbers).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); - }); - - it('handles edge cases in car naming and validation', async () => { - // Test various edge cases - const edgeCases = [ - 'Car with special chars !@#$%^&*()', - 'Car with numbers 12345', - 'Car with spaces and tabs', - 'Very long car name that exceeds typical limits but should still work fine', - '车名中文', // Non-ASCII characters - '' // Empty string - ]; - - for (const carName of edgeCases) { - try { - const car = await CarsService.createCar(org, adminUser, carName); - expect(car.name).toBe(carName); - } catch (error) { - // Some edge cases might fail due to validation, which is acceptable - console.log(`Edge case "${carName}" failed as expected:`, error); - } - } - }); - - it('properly handles organization and user validation', async () => { - // Test with malformed organization - const invalidOrg = { organizationId: '' } as Organization; - - await expect(CarsService.getCurrentCar(invalidOrg)).rejects.toThrow(); - - // Test with non-existent organization - const nonExistentOrg = { organizationId: 'non-existent' } as Organization; - const result = await CarsService.getCurrentCar(nonExistentOrg); - expect(result).toBeNull(); - }); - - it('handles transaction rollbacks properly', async () => { - const initialCount = await prisma.car.count({ - where: { wbsElement: { organizationId: org.organizationId } } - }); - - // Attempt operation that should fail - try { - await CarsService.createCar(org, nonAdminUser, 'Should Fail'); - } catch (error) { - expect(error).toBeInstanceOf(AccessDeniedAdminOnlyException); - } - - // Verify count hasn't changed - const finalCount = await prisma.car.count({ - where: { wbsElement: { organizationId: org.organizationId } } - }); - - expect(finalCount).toBe(initialCount); - }); - - it('ensures proper cleanup and resource management', async () => { - // Create multiple cars and verify they can be properly queried - const carNames = ['Car A', 'Car B', 'Car C']; - const createdCars = []; - - for (const name of carNames) { - const car = await CarsService.createCar(org, adminUser, name); - createdCars.push(car); - } - - // Verify all cars exist - const allCars = await CarsService.getAllCars(org); - expect(allCars).toHaveLength(3); - - // Verify current car is the latest - const currentCar = await CarsService.getCurrentCar(org); - expect(currentCar!.name).toBe('Car C'); - expect(currentCar!.wbsNum.carNumber).toBe(2); // 0-indexed, so third car is #2 - }); - }); -}); -}); - -describe('Cars API Integration Tests', () => { - let org: Organization; - let adminUser: User; - let nonAdminUser: User; - - beforeEach(async () => { - org = await createTestOrganization(); - adminUser = await createTestUser(supermanAdmin, org.organizationId); - nonAdminUser = await createTestUser(batmanAppAdmin, org.organizationId); - }); - - afterEach(async () => { - await resetUsers(); - }); - - describe('GET /cars', () => { - it('returns empty array when no cars exist', async () => { - const response = await request(app) - .get('/cars') - .set('authorization', `Bearer ${adminUser.userId}`) - .expect(200); - - expect(response.body).toEqual([]); - }); - - it('returns all cars for organization', async () => { - // Create test cars - await createTestCar(org.organizationId, adminUser.userId); - await createTestCar(org.organizationId, adminUser.userId); - - const response = await request(app) - .get('/cars') - .set('authorization', `Bearer ${adminUser.userId}`) - .expect(200); - - expect(response.body).toHaveLength(2); - expect(response.body[0]).toHaveProperty('id'); - expect(response.body[0]).toHaveProperty('name'); - expect(response.body[0]).toHaveProperty('wbsNum'); - expect(response.body[0]).toHaveProperty('dateCreated'); - }); - - it('only returns cars for user\'s organization', async () => { - // Create car in our org - await createTestCar(org.organizationId, adminUser.userId); - - // Create car in different org - const otherOrg = await createTestOrganization(); - const otherUser = await createTestUser(supermanAdmin, otherOrg.organizationId); - await createTestCar(otherOrg.organizationId, otherUser.userId); - - const response = await request(app) - .get('/cars') - .set('authorization', `Bearer ${adminUser.userId}`) - .expect(200); - - expect(response.body).toHaveLength(1); - }); - - it('requires authentication', async () => { - await request(app) - .get('/cars') - .expect(401); - }); - }); - - describe('GET /cars/current', () => { - it('returns null when no cars exist', async () => { - const response = await request(app) - .get('/cars/current') - .set('authorization', `Bearer ${adminUser.userId}`) - .expect(200); - - expect(response.body).toBeNull(); - }); - - it('returns the only car when one exists', async () => { - const testCar = await createTestCar(org.organizationId, adminUser.userId); - - const response = await request(app) - .get('/cars/current') - .set('authorization', `Bearer ${adminUser.userId}`) - .expect(200); - - expect(response.body).not.toBeNull(); - expect(response.body.id).toBe(testCar.carId); - }); - - it('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 - } - } - } - }); - - const car3 = 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 response = await request(app) - .get('/cars/current') - .set('authorization', `Bearer ${adminUser.userId}`) - .expect(200); - - expect(response.body).not.toBeNull(); - expect(response.body.wbsNum.carNumber).toBe(3); - expect(response.body.id).toBe(car3.carId); - }); - - it('only considers cars from user\'s 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 otherOrg = await createTestOrganization(); - const otherUser = await createTestUser(supermanAdmin, 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 response = await request(app) - .get('/cars/current') - .set('authorization', `Bearer ${adminUser.userId}`) - .expect(200); - - expect(response.body).not.toBeNull(); - expect(response.body.wbsNum.carNumber).toBe(1); - expect(response.body.name).toBe('Our Car'); - }); - - it('requires authentication', async () => { - await request(app) - .get('/cars/current') - .expect(401); - }); - }); - - describe('POST /cars/create', () => { - it('successfully creates car with admin permissions', async () => { - const carData = { name: 'Test Car' }; - - const response = await request(app) - .post('/cars/create') - .set('authorization', `Bearer ${adminUser.userId}`) - .send(carData) - .expect(201); - - expect(response.body.name).toBe('Test Car'); - expect(response.body.wbsNum.carNumber).toBe(0); // First car should have car number 0 - expect(response.body.wbsNum.projectNumber).toBe(0); - expect(response.body.wbsNum.workPackageNumber).toBe(0); - }); - - it('assigns correct car number based on existing cars', async () => { - // Create first car - await request(app) - .post('/cars/create') - .set('authorization', `Bearer ${adminUser.userId}`) - .send({ name: 'Car 1' }) - .expect(201); - - // Create second car - const response = await request(app) - .post('/cars/create') - .set('authorization', `Bearer ${adminUser.userId}`) - .send({ name: 'Car 2' }) - .expect(201); - - expect(response.body.wbsNum.carNumber).toBe(1); // Should be incremented - }); - - it('denies access for non-admin user', async () => { - const carData = { name: 'Test Car' }; - - await request(app) - .post('/cars/create') - .set('authorization', `Bearer ${nonAdminUser.userId}`) - .send(carData) - .expect(400); // AccessDeniedAdminOnlyException should return 400 - }); - - it('requires car name', async () => { - await request(app) - .post('/cars/create') - .set('authorization', `Bearer ${adminUser.userId}`) - .send({}) - .expect(400); - }); - - it('requires authentication', async () => { - await request(app) - .post('/cars/create') - .send({ name: 'Test Car' }) - .expect(401); - }); - - it('car numbers are organization-specific', async () => { - // Create car in first org - const firstResponse = await request(app) - .post('/cars/create') - .set('authorization', `Bearer ${adminUser.userId}`) - .send({ name: 'First Org Car' }) - .expect(201); - - // Create different org and admin - const otherOrg = await createTestOrganization(); - const otherAdmin = await createTestUser(supermanAdmin, otherOrg.organizationId); - - // Create car in second org - const secondResponse = await request(app) - .post('/cars/create') - .set('authorization', `Bearer ${otherAdmin.userId}`) - .send({ name: 'Second Org Car' }) - .expect(201); - - // Both should start from car number 0 - expect(firstResponse.body.wbsNum.carNumber).toBe(0); - expect(secondResponse.body.wbsNum.carNumber).toBe(0); - }); - }); -}); diff --git a/src/backend/tests/unmocked/cars.test.ts b/src/backend/tests/unmocked/cars.test.ts index 85f8d1c255..20e9607840 100644 --- a/src/backend/tests/unmocked/cars.test.ts +++ b/src/backend/tests/unmocked/cars.test.ts @@ -1,6 +1,6 @@ import { Organization, User } from '@prisma/client'; import { createTestOrganization, createTestUser, resetUsers, createTestCar } from '../test-utils'; -import { supermanAdmin, batmanAppAdmin } from '../test-data/users.test-data'; +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'; @@ -13,7 +13,7 @@ describe('Cars Tests', () => { beforeEach(async () => { org = await createTestOrganization(); adminUser = await createTestUser(supermanAdmin, org.organizationId); - nonAdminUser = await createTestUser(batmanAppAdmin, org.organizationId); + nonAdminUser = await createTestUser(member, org.organizationId); }); afterEach(async () => { @@ -27,9 +27,38 @@ describe('Cars Tests', () => { }); test('getAllCars returns all cars for organization', async () => { - // Create test cars - await createTestCar(org.organizationId, adminUser.userId); - await createTestCar(org.organizationId, adminUser.userId); + // 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); @@ -37,12 +66,67 @@ describe('Cars Tests', () => { test('getAllCars only returns cars for specified organization', async () => { // Create car in our org - await createTestCar(org.organizationId, adminUser.userId); + 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 otherOrg = await createTestOrganization(); - const otherUser = await createTestUser(supermanAdmin, otherOrg.organizationId); - await createTestCar(otherOrg.organizationId, otherUser.userId); + 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); @@ -137,8 +221,35 @@ describe('Cars Tests', () => { }); // Create car in different org with higher car number - const otherOrg = await createTestOrganization(); - const otherUser = await createTestUser(supermanAdmin, otherOrg.organizationId); + 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: { @@ -193,8 +304,34 @@ describe('Cars Tests', () => { const firstCar = await CarsService.createCar(org, adminUser, 'First Org Car'); // Create different org and admin - const otherOrg = await createTestOrganization(); - const otherAdmin = await createTestUser(supermanAdmin, otherOrg.organizationId); + 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'); From c098546d5767f3747763d1a024c52aab8e10ede3 Mon Sep 17 00:00:00 2001 From: harish Date: Sat, 6 Dec 2025 21:51:04 -0500 Subject: [PATCH 05/40] #3811 fixing frontend tests --- src/frontend/src/app/AppGlobalCarFilterContext.tsx | 8 ++++++-- src/frontend/src/tests/app/AppContext.test.tsx | 9 +++++++++ .../src/tests/hooks/GlobalCarFilterContext.test.tsx | 6 ++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/app/AppGlobalCarFilterContext.tsx b/src/frontend/src/app/AppGlobalCarFilterContext.tsx index 9d7c5197ea..693bf8a5ca 100644 --- a/src/frontend/src/app/AppGlobalCarFilterContext.tsx +++ b/src/frontend/src/app/AppGlobalCarFilterContext.tsx @@ -23,6 +23,7 @@ interface GlobalCarFilterProviderProps { export const GlobalCarFilterProvider: React.FC = ({ children }) => { const [selectedCar, setSelectedCarState] = useState(null); + const [hasBeenManuallyCleared, setHasBeenManuallyCleared] = useState(false); const { data: currentCar, isLoading: currentCarLoading, error: currentCarError } = useGetCurrentCar(); const { data: allCars = [], isLoading: allCarsLoading, error: allCarsError } = useGetAllCars(); @@ -31,7 +32,7 @@ export const GlobalCarFilterProvider: React.FC = ( const error = currentCarError || allCarsError; useEffect(() => { - if (!isLoading && allCars.length > 0) { + if (!isLoading && allCars.length > 0 && !hasBeenManuallyCleared) { const savedCarId = sessionStorage.getItem('selectedCarId'); if (savedCarId) { @@ -51,9 +52,12 @@ export const GlobalCarFilterProvider: React.FC = ( setSelectedCarState(mostRecentCar); } } - }, [currentCar, allCars, isLoading]); + }, [currentCar, allCars, isLoading, hasBeenManuallyCleared]); const setSelectedCar = (car: Car | null) => { + if (car === null) { + setHasBeenManuallyCleared(true); + } setSelectedCarState(car); if (car) { 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/hooks/GlobalCarFilterContext.test.tsx b/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx index cef5914714..d53a8ba33c 100644 --- a/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx +++ b/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx @@ -207,7 +207,9 @@ describe('useGlobalCarFilter', () => { // Clear selection result.current.setSelectedCar(null); - expect(sessionStorage.getItem('selectedCarId')).toBeNull(); - expect(result.current.selectedCar).toBeNull(); + await waitFor(() => { + expect(sessionStorage.getItem('selectedCarId')).toBeNull(); + expect(result.current.selectedCar).toBeNull(); + }); }); }); From d2c8cffb08142b66d16d9facd0b57fe2ee6d186e Mon Sep 17 00:00:00 2001 From: harish Date: Sat, 6 Dec 2025 22:21:05 -0500 Subject: [PATCH 06/40] #3629 fixing sidebar --- .../components/FinanceDashboardCarFilter.tsx | 2 +- .../components/GlobalCarFilterDropdown.tsx | 66 +++++++++++++------ src/frontend/src/layouts/Sidebar/Sidebar.tsx | 7 +- .../AdminFinanceDashboard.tsx | 3 + src/frontend/src/utils/urls.ts | 24 +++---- 5 files changed, 65 insertions(+), 37 deletions(-) diff --git a/src/frontend/src/components/FinanceDashboardCarFilter.tsx b/src/frontend/src/components/FinanceDashboardCarFilter.tsx index ea0d99346e..097d6846bd 100644 --- a/src/frontend/src/components/FinanceDashboardCarFilter.tsx +++ b/src/frontend/src/components/FinanceDashboardCarFilter.tsx @@ -142,7 +142,7 @@ const FinanceDashboardCarFilterComponent: React.FC {startDate && endDate && ( - {startDate.toLocaleDateString()} - {endDate.toLocaleDateString()} + {new Date(startDate).toLocaleDateString()} - {new Date(endDate).toLocaleDateString()} )}
diff --git a/src/frontend/src/components/GlobalCarFilterDropdown.tsx b/src/frontend/src/components/GlobalCarFilterDropdown.tsx index 843d9f48da..6dd1300950 100644 --- a/src/frontend/src/components/GlobalCarFilterDropdown.tsx +++ b/src/frontend/src/components/GlobalCarFilterDropdown.tsx @@ -36,11 +36,21 @@ const GlobalCarFilterDropdown: React.FC = ({ compa return ; } - if (error || !selectedCar) { + if (error) { return ( - {error?.message || 'No car selected'} + {error.message} + + + ); + } + + if (allCars.length === 0) { + return ( + + + No cars available ); @@ -48,27 +58,37 @@ const GlobalCarFilterDropdown: React.FC = ({ compa const sortedCars = [...allCars].sort((a, b) => b.wbsNum.carNumber - a.wbsNum.carNumber); - const currentCarLabel = selectedCar.wbsNum.carNumber === 0 ? selectedCar.name : `Car ${selectedCar.wbsNum.carNumber}`; + const currentCarLabel = selectedCar + ? selectedCar.wbsNum.carNumber === 0 + ? selectedCar.name + : `Car ${selectedCar.wbsNum.carNumber}` + : 'Select Car'; if (compact) { return ( - - - } - variant="outlined" - size="small" - sx={{ - borderColor: theme.palette.primary.main, - color: theme.palette.primary.main, - '& .MuiChip-deleteIcon': { - color: theme.palette.primary.main - } - }} - /> + + + Working with: + + + + } + variant="outlined" + size="small" + sx={{ + borderColor: 'white', + color: 'white', + '& .MuiChip-deleteIcon': { + color: 'white' + }, + flex: 1 + }} + /> + = ({ compa }} > {sortedCars.map((car) => ( - handleCarSelect(car)}> + handleCarSelect(car)} + > {car.wbsNum.carNumber === 0 ? car.name : `Car ${car.wbsNum.carNumber}`} diff --git a/src/frontend/src/layouts/Sidebar/Sidebar.tsx b/src/frontend/src/layouts/Sidebar/Sidebar.tsx index 680cc06764..e70e776172 100644 --- a/src/frontend/src/layouts/Sidebar/Sidebar.tsx +++ b/src/frontend/src/layouts/Sidebar/Sidebar.tsx @@ -157,6 +157,10 @@ const Sidebar = ({ drawerOpen, setDrawerOpen, moveContent, setMoveContent }: Sid handleMoveContent()}>{moveContent ? : } + + + + ))} - - - diff --git a/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx b/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx index 87952a206a..cc6624b621 100644 --- a/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx @@ -129,6 +129,9 @@ const AdminFinanceDashboard: React.FC = ({ startDate variant="contained" id="project-actions-dropdown" onClick={handleClick} + sx={{ + color: 'white' + }} > Actions diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index f27ff2100e..8ca8239e78 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -245,8 +245,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(); @@ -259,8 +259,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(); @@ -268,8 +268,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(); @@ -277,8 +277,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(); @@ -286,8 +286,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(); @@ -295,8 +295,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(); From 9da7ea59ad1dffc3ef2715867aa3b86f5d28f78a Mon Sep 17 00:00:00 2001 From: harish Date: Sat, 6 Dec 2025 22:47:29 -0500 Subject: [PATCH 07/40] #3629 fixing finance dashboard --- .../AdminFinanceDashboard.tsx | 193 ++++++++++++++++-- 1 file changed, 173 insertions(+), 20 deletions(-) diff --git a/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx b/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx index cc6624b621..6b7e065713 100644 --- a/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceDashboard/AdminFinanceDashboard.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useAllTeamTypes } from '../../../hooks/team-types.hooks'; import ErrorPage from '../../ErrorPage'; import LoadingIndicator from '../../../components/LoadingIndicator'; @@ -13,14 +13,16 @@ import { useAllReimbursementRequests, useGetPendingAdvisorList } from '../../../ import { useCurrentUser } from '../../../hooks/users.hooks'; import { NERButton } from '../../../components/NERButton'; import { ArrowDropDownIcon } from '@mui/x-date-pickers/icons'; -import { ListItemIcon, Menu, MenuItem } from '@mui/material'; +import { ListItemIcon, Menu, MenuItem, Tooltip } from '@mui/material'; import PendingAdvisorModal from '../FinanceComponents/PendingAdvisorListModal'; import TotalAmountSpentModal from '../FinanceComponents/TotalAmountSpentModal'; +import { DatePicker } from '@mui/x-date-pickers'; import ListAltIcon from '@mui/icons-material/ListAlt'; import WorkIcon from '@mui/icons-material/Work'; +import { HelpOutline as HelpIcon } from '@mui/icons-material'; import { isAdmin } from 'shared'; -import FinanceDashboardCarFilter from '../../../components/FinanceDashboardCarFilter'; -import { useFinanceDashboardCarFilter } from '../../../hooks/finance-car-filter.hooks'; +import { useGetAllCars } from '../../../hooks/cars.hooks'; +import NERAutocomplete from '../../../components/NERAutocomplete'; interface AdminFinanceDashboardProps { startDate?: Date; @@ -35,8 +37,9 @@ const AdminFinanceDashboard: React.FC = ({ startDate const [tabIndex, setTabIndex] = useState(0); const [showPendingAdvisorListModal, setShowPendingAdvisorListModal] = useState(false); const [showTotalAmountSpent, setShowTotalAmountSpent] = useState(false); - - const filter = useFinanceDashboardCarFilter(startDate, endDate, carNumber); + const [startDateState, setStartDateState] = useState(startDate); + const [endDateState, setEndDateState] = useState(endDate); + const [carNumberState, setCarNumberState] = useState(carNumber); const { data: allTeamTypes, @@ -57,8 +60,16 @@ const AdminFinanceDashboard: React.FC = ({ startDate error: allPendingAdvisorListError } = useGetPendingAdvisorList(); - if (filter.error) { - return ; + const { data: allCars, isLoading: allCarsIsLoading, isError: allCarsIsError, error: allCarsError } = useGetAllCars(); + + useEffect(() => { + if (carNumberState === undefined && allCars && allCars.length > 0) { + setCarNumberState(allCars[allCars.length - 1].wbsNum.carNumber); + } + }, [allCars, carNumberState]); + + if (allCarsIsError) { + return ; } if (allTeamTypesIsError) { @@ -80,11 +91,17 @@ const AdminFinanceDashboard: React.FC = ({ startDate allReimbursementRequestsIsLoading || !allPendingAdvisorList || allPendingAdvisorListIsLoading || - filter.isLoading + !allCars || + allCarsIsLoading ) { return ; } + const carAutocompleteOptions = allCars.map((car) => ({ + label: car.name, + id: car.wbsNum.carNumber.toString() + })); + const tabs = []; tabs.push({ tabUrlValue: 'all', tabName: 'All' }); @@ -108,13 +125,83 @@ const AdminFinanceDashboard: React.FC = ({ startDate setAnchorEl(null); }; + const datePickerStyle = { + width: 150, + 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' + } + } + }; + const dateAndActionsDropdown = ( = ({ 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)} + /> + + + + + + + - + + + + (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={{ - color: 'white' - }} + sx={{ flexShrink: 0 }} > Actions @@ -192,16 +345,16 @@ const AdminFinanceDashboard: React.FC = ({ startDate /> )} {tabIndex === 0 ? ( - + ) : tabIndex === tabs.length - 1 ? ( - + ) : ( selectedTab && ( ) )} From 7b68b505c08b93bfdcc5a0c6b38038c80a3199c5 Mon Sep 17 00:00:00 2001 From: harish Date: Sat, 6 Dec 2025 22:50:51 -0500 Subject: [PATCH 08/40] 3629 fixing dropdown sidebar UI --- .../components/GlobalCarFilterDropdown.tsx | 128 ++++++++++-------- 1 file changed, 71 insertions(+), 57 deletions(-) diff --git a/src/frontend/src/components/GlobalCarFilterDropdown.tsx b/src/frontend/src/components/GlobalCarFilterDropdown.tsx index 6dd1300950..d87774ddff 100644 --- a/src/frontend/src/components/GlobalCarFilterDropdown.tsx +++ b/src/frontend/src/components/GlobalCarFilterDropdown.tsx @@ -4,8 +4,8 @@ */ import React, { useState } from 'react'; -import { Box, Typography, Menu, MenuItem, Chip, Tooltip, Paper, useTheme } from '@mui/material'; -import { ExpandMore as ExpandMoreIcon, DirectionsCar as CarIcon, HelpOutline as HelpIcon } from '@mui/icons-material'; +import { Box, Typography, Chip, Collapse, IconButton } from '@mui/material'; +import { ExpandMore as ExpandMoreIcon, DirectionsCar as CarIcon } from '@mui/icons-material'; import { useGlobalCarFilter } from '../app/AppGlobalCarFilterContext'; import LoadingIndicator from './LoadingIndicator'; @@ -15,21 +15,16 @@ interface GlobalCarFilterDropdownProps { } const GlobalCarFilterDropdown: React.FC = ({ compact = false, sx = {} }) => { - const theme = useTheme(); const { selectedCar, allCars, setSelectedCar, isLoading, error } = useGlobalCarFilter(); - const [anchorEl, setAnchorEl] = useState(null); + const [expanded, setExpanded] = useState(false); - const handleClick = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - - const handleClose = () => { - setAnchorEl(null); + const handleToggle = () => { + setExpanded(!expanded); }; const handleCarSelect = (car: any) => { setSelectedCar(car); - handleClose(); + setExpanded(false); }; if (isLoading) { @@ -67,56 +62,75 @@ const GlobalCarFilterDropdown: React.FC = ({ compa if (compact) { return ( - - Working with: - - + - } - variant="outlined" - size="small" + + + Working with: + + + {currentCarLabel} + + + + + + + + - - - {sortedCars.map((car) => ( - handleCarSelect(car)} - > - - - {car.wbsNum.carNumber === 0 ? car.name : `Car ${car.wbsNum.carNumber}`} - - - {car.name} - - - - ))} - + > + {sortedCars.map((car) => { + const carLabel = car.wbsNum.carNumber === 0 ? car.name : `Car ${car.wbsNum.carNumber}`; + 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' + }} + /> + ); + })} + + ); } From 41af04965d5df7ecf979d758e3a9f312a495537c Mon Sep 17 00:00:00 2001 From: harish Date: Sat, 6 Dec 2025 22:55:01 -0500 Subject: [PATCH 09/40] #3629 linting --- .../components/GlobalCarFilterDropdown.tsx | 80 +++---------------- 1 file changed, 10 insertions(+), 70 deletions(-) diff --git a/src/frontend/src/components/GlobalCarFilterDropdown.tsx b/src/frontend/src/components/GlobalCarFilterDropdown.tsx index d87774ddff..8e20df612c 100644 --- a/src/frontend/src/components/GlobalCarFilterDropdown.tsx +++ b/src/frontend/src/components/GlobalCarFilterDropdown.tsx @@ -135,79 +135,19 @@ const GlobalCarFilterDropdown: React.FC = ({ compa ); } + // Non-compact mode (not used in current implementation) return ( - + + + Working with: + - - - - Global Car Filter - - - {selectedCar.name} - - - - - - - - - } - color="primary" - variant="outlined" - /> - - - {sortedCars.map((car) => ( - handleCarSelect(car)} - sx={{ py: 1.5 }} - > - - - {car.wbsNum.carNumber === 0 ? car.name : `Car ${car.wbsNum.carNumber}`} - - - {car.name} - - - Created: {car.dateCreated.toLocaleDateString()} - - - - ))} - + + + {currentCarLabel} + - + ); }; From 564fdd8c76ab65366888449ea6a7c3cc8e7146bf Mon Sep 17 00:00:00 2001 From: harish Date: Sat, 6 Dec 2025 22:57:38 -0500 Subject: [PATCH 10/40] #3629 linting --- src/backend/tests/unmocked/cars.test.ts | 2 +- src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/tests/unmocked/cars.test.ts b/src/backend/tests/unmocked/cars.test.ts index 20e9607840..0f9d90c689 100644 --- a/src/backend/tests/unmocked/cars.test.ts +++ b/src/backend/tests/unmocked/cars.test.ts @@ -181,7 +181,7 @@ describe('Cars Tests', () => { } }); - const car2 = await prisma.car.create({ + await prisma.car.create({ data: { wbsElement: { create: { diff --git a/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx b/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx index d53a8ba33c..11fc26acfd 100644 --- a/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx +++ b/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx @@ -209,7 +209,7 @@ describe('useGlobalCarFilter', () => { await waitFor(() => { expect(sessionStorage.getItem('selectedCarId')).toBeNull(); - expect(result.current.selectedCar).toBeNull(); }); + expect(result.current.selectedCar).toBeNull(); }); }); From 5d677edbddb0e4941d9584037fa2ccb50c16c40e Mon Sep 17 00:00:00 2001 From: harish Date: Sat, 6 Dec 2025 23:26:24 -0500 Subject: [PATCH 11/40] filtering for other data --- src/backend/src/prisma/seed.ts | 34 +++++++++++++++++++ .../components/GlobalCarFilterDropdown.tsx | 8 ++--- .../AdminFinanceDashboard.tsx | 14 ++++++-- .../ProjectGanttChartPage.tsx | 21 +++++++++++- .../pages/ProjectsPage/ProjectsOverview.tsx | 18 ++++++++-- .../src/pages/ProjectsPage/ProjectsTable.tsx | 8 ++++- 6 files changed, 90 insertions(+), 13 deletions(-) diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 95f880bf30..0d3a9bd831 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -293,6 +293,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 + } + }); + /** * Make an initial change request for car 1 using the wbs of the genesis project */ diff --git a/src/frontend/src/components/GlobalCarFilterDropdown.tsx b/src/frontend/src/components/GlobalCarFilterDropdown.tsx index 8e20df612c..069174fa29 100644 --- a/src/frontend/src/components/GlobalCarFilterDropdown.tsx +++ b/src/frontend/src/components/GlobalCarFilterDropdown.tsx @@ -53,11 +53,7 @@ const GlobalCarFilterDropdown: React.FC = ({ compa const sortedCars = [...allCars].sort((a, b) => b.wbsNum.carNumber - a.wbsNum.carNumber); - const currentCarLabel = selectedCar - ? selectedCar.wbsNum.carNumber === 0 - ? selectedCar.name - : `Car ${selectedCar.wbsNum.carNumber}` - : 'Select Car'; + const currentCarLabel = selectedCar ? selectedCar.name : 'Select Car'; if (compact) { return ( @@ -107,7 +103,7 @@ const GlobalCarFilterDropdown: React.FC = ({ compa }} > {sortedCars.map((car) => { - const carLabel = car.wbsNum.carNumber === 0 ? car.name : `Car ${car.wbsNum.carNumber}`; + const carLabel = car.name; const isSelected = selectedCar ? car.id === selectedCar.id : false; return ( = ({ startDate, endDate, carNumber }) => { const user = useCurrentUser(); + const { selectedCar } = useGlobalCarFilter(); const [anchorEl, setAnchorEl] = useState(null); const [tabIndex, setTabIndex] = useState(0); @@ -62,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 ; diff --git a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx index fe48f3f9b4..a168530061 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx @@ -55,6 +55,7 @@ import { v4 as uuidv4 } from 'uuid'; import { projectWbsPipe } from '../../../utils/pipes'; import { projectGanttTransformer } from '../../../apis/transformers/projects.transformers'; import { useCurrentUser } from '../../../hooks/users.hooks'; +import { useGlobalCarFilter } from '../../../app/AppGlobalCarFilterContext'; const getElementId = (element: WbsElementPreview | Task) => { return (element as WbsElementPreview).id ?? (element as Task).taskId; @@ -63,6 +64,7 @@ const getElementId = (element: WbsElementPreview | Task) => { const ProjectGanttChartPage: FC = () => { const history = useHistory(); const toast = useToast(); + const { selectedCar } = useGlobalCarFilter(); const { isLoading: projectsIsLoading, @@ -111,6 +113,12 @@ const ProjectGanttChartPage: FC = () => { let allProjects: ProjectGantt[] = JSON.parse(JSON.stringify(projects.concat(addedProjects))).map( projectGanttTransformer ); + + // Filter by selected car from global filter + if (selectedCar) { + allProjects = allProjects.filter((project) => project.wbsNum.carNumber === selectedCar.wbsNum.carNumber); + } + allProjects = allProjects.map((project) => { const editedProject = editedProjects.find((proj) => proj.id === project.id); return editedProject ? editedProject : project; @@ -131,7 +139,18 @@ const ProjectGanttChartPage: FC = () => { if (projects && teams) { requestRefresh(projects, teams, editedProjects, addedProjects, filters, searchText); } - }, [teams, projects, addedProjects, setAllProjects, setCollections, editedProjects, filters, searchText, history]); + }, [ + teams, + projects, + addedProjects, + setAllProjects, + setCollections, + editedProjects, + filters, + searchText, + history, + selectedCar + ]); const handleSetGanttFilters = (newFilters: GanttFilters) => { setFilters(newFilters); diff --git a/src/frontend/src/pages/ProjectsPage/ProjectsOverview.tsx b/src/frontend/src/pages/ProjectsPage/ProjectsOverview.tsx index 2539a7d3d8..9cc762df7d 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,18 +50,28 @@ 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; + const carFilteredTeams = selectedCar + ? teamsProjects.filter((project) => project.wbsNum.carNumber === selectedCar.wbsNum.carNumber) + : teamsProjects; + const carFilteredLeading = selectedCar + ? leadingProjects.filter((project) => project.wbsNum.carNumber === selectedCar.wbsNum.carNumber) + : leadingProjects; + // Keeps only favorite team/leading projects (even when completed) or incomplete projects - const filteredTeamsProjects = teamsProjects.filter( + const filteredTeamsProjects = carFilteredTeams.filter( (project) => project.status !== WbsElementStatus.Complete || favoriteProjectsSet.has(project.id) ); - const filteredLeadingProjects = leadingProjects.filter( + const filteredLeadingProjects = carFilteredLeading.filter( (project) => project.status !== WbsElementStatus.Complete || favoriteProjectsSet.has(project.id) ); return ( { const { isLoading, data, error } = useAllProjects(); + const { selectedCar } = useGlobalCarFilter(); + + const filteredData = + selectedCar && data ? data.filter((project) => project.wbsNum.carNumber === selectedCar.wbsNum.carNumber) : data; + if (!localStorage.getItem('projectsTableRowCount')) localStorage.setItem('projectsTableRowCount', '30'); const [pageSize, setPageSize] = useState(localStorage.getItem('projectsTableRowCount')); const [windowSize, setWindowSize] = useState(window.innerWidth); @@ -180,7 +186,7 @@ const ProjectsTable: React.FC = () => { error={error} rows={ // flatten some complex data to allow MUI to sort/filter yet preserve the original data being available to the front-end - data?.map((v) => ({ + filteredData?.map((v) => ({ ...v, carNumber: v.wbsNum.carNumber, lead: fullNamePipe(v.lead), From 8b007f88d157d215e26adc59a5246f3e228f2621 Mon Sep 17 00:00:00 2001 From: Madalynn Nenninger Date: Thu, 12 Feb 2026 17:38:56 -0500 Subject: [PATCH 12/40] #3950 getAllProjects --- src/backend/src/controllers/projects.controllers.ts | 3 ++- src/backend/src/services/projects.services.ts | 4 ++-- src/frontend/src/apis/projects.api.ts | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/backend/src/controllers/projects.controllers.ts b/src/backend/src/controllers/projects.controllers.ts index 438b02005b..d060d2c862 100644 --- a/src/backend/src/controllers/projects.controllers.ts +++ b/src/backend/src/controllers/projects.controllers.ts @@ -25,7 +25,8 @@ export default class ProjectsController { static async getAllProjects(req: Request, res: Response, next: NextFunction) { try { - const projects: ProjectPreview[] = await ProjectsService.getAllProjects(req.organization); + const { carId } = req.query; + const projects: ProjectPreview[] = await ProjectsService.getAllProjects(req.organization, carId as string | undefined); res.status(200).json(projects); } catch (error: unknown) { next(error); diff --git a/src/backend/src/services/projects.services.ts b/src/backend/src/services/projects.services.ts index 44cda2e7cc..109ceab2e4 100644 --- a/src/backend/src/services/projects.services.ts +++ b/src/backend/src/services/projects.services.ts @@ -63,9 +63,9 @@ export default class ProjectsService { * @param organization the organization the user is in * @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 }) }, ...getProjectPreviewQueryArgs(organization.organizationId) }); diff --git a/src/frontend/src/apis/projects.api.ts b/src/frontend/src/apis/projects.api.ts index e5d3ce62a4..5de3623ac2 100644 --- a/src/frontend/src/apis/projects.api.ts +++ b/src/frontend/src/apis/projects.api.ts @@ -40,6 +40,7 @@ export const getAllProjectsGantt = () => { */ export const getAllProjects = () => { return axios.get(apiUrls.allProjectPreviews(), { + params: { carId } transformResponse: (data) => JSON.parse(data).map(projectPreviewTransformer) }); }; From 4c24a7ac6577a23fe3eb325d2246d965548e7334 Mon Sep 17 00:00:00 2001 From: Madalynn Nenninger Date: Thu, 12 Feb 2026 17:44:39 -0500 Subject: [PATCH 13/40] #3950 fixed api function --- src/frontend/src/apis/projects.api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/apis/projects.api.ts b/src/frontend/src/apis/projects.api.ts index 5de3623ac2..27a737a5c0 100644 --- a/src/frontend/src/apis/projects.api.ts +++ b/src/frontend/src/apis/projects.api.ts @@ -38,9 +38,9 @@ export const getAllProjectsGantt = () => { /** * Fetches all projects with preview querry args */ -export const getAllProjects = () => { +export const getAllProjects = (carId?: string) => { return axios.get(apiUrls.allProjectPreviews(), { - params: { carId } + params: { carId }, transformResponse: (data) => JSON.parse(data).map(projectPreviewTransformer) }); }; From 356617a4c66bb2557ddbd8de98eca43dc0f848fd Mon Sep 17 00:00:00 2001 From: patriots1 Date: Fri, 13 Feb 2026 00:47:28 -0500 Subject: [PATCH 14/40] #3948 update work-packages.services.ts include optional car param for getAllWorkPackages and getManyWorkPackages --- .../src/services/work-packages.services.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/backend/src/services/work-packages.services.ts b/src/backend/src/services/work-packages.services.ts index 9faf820fc5..b6e6920bd7 100644 --- a/src/backend/src/services/work-packages.services.ts +++ b/src/backend/src/services/work-packages.services.ts @@ -46,12 +46,14 @@ 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 car 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; + car?: number; }, organization: Organization ): Promise { @@ -60,7 +62,7 @@ export default class WorkPackagesService { ...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,6 +72,11 @@ export default class WorkPackagesService { return passes; }); + const outputWorkPackages = + query.car !== undefined + ? filteredWorkPackages.filter((wp) => wp.wbsNum.carNumber === query.car) + : filteredWorkPackages; + outputWorkPackages.sort((wpA, wpB) => wpA.endDate.getTime() - wpB.endDate.getTime()); return outputWorkPackages; @@ -116,10 +123,11 @@ 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 car 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, car?: number): Promise { wbsNums.forEach((wbsNum) => { if (!isWorkPackageWbs(wbsNum)) { throw new HttpException( @@ -129,7 +137,9 @@ export default class WorkPackagesService { } }); - const workPackagePromises = wbsNums.map(async (wbsNum) => { + const filteredWorkPackages = car !== undefined ? wbsNums.filter((wbsNum) => car === wbsNum.carNumber) : wbsNums; + + const workPackagePromises = filteredWorkPackages.map(async (wbsNum) => { return WorkPackagesService.getSingleWorkPackage(wbsNum, organization); }); From 05959a9abc8334d1f01b75d29f6cf9ebf023b7b2 Mon Sep 17 00:00:00 2001 From: Madalynn Nenninger Date: Mon, 16 Feb 2026 15:34:51 -0500 Subject: [PATCH 15/40] #3950 modified endpoints to include optional car id --- .../src/controllers/projects.controllers.ts | 27 ++++++++++++++++--- src/backend/src/services/projects.services.ts | 19 +++++++------ src/frontend/src/apis/projects.api.ts | 9 ++++--- 3 files changed, 40 insertions(+), 15 deletions(-) diff --git a/src/backend/src/controllers/projects.controllers.ts b/src/backend/src/controllers/projects.controllers.ts index d060d2c862..c3a587cf84 100644 --- a/src/backend/src/controllers/projects.controllers.ts +++ b/src/backend/src/controllers/projects.controllers.ts @@ -16,7 +16,11 @@ 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 { carId } = req.query; + const projects: ProjectGantt[] = await ProjectsService.getAllProjectsGantt( + req.organization, + carId as string | undefined + ); res.status(200).json(projects); } catch (error: unknown) { next(error); @@ -35,7 +39,12 @@ 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 { carId } = req.query; + const projects: ProjectOverview[] = await ProjectsService.getUsersTeamsProjects( + req.currentUser, + req.organization, + carId as string | undefined + ); res.status(200).json(projects); } catch (error: unknown) { next(error); @@ -44,7 +53,12 @@ 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 { carId } = req.query; + const projects: ProjectOverview[] = await ProjectsService.getUsersLeadingProjects( + req.currentUser, + req.organization, + carId as string | undefined + ); res.status(200).json(projects); } catch (error: unknown) { next(error); @@ -54,7 +68,12 @@ 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 { carId } = req.query; + const projects: Project[] = await ProjectsService.getTeamsProjects( + req.organization, + teamId, + carId as string | undefined + ); res.status(200).json(projects); } catch (error: unknown) { next(error); diff --git a/src/backend/src/services/projects.services.ts b/src/backend/src/services/projects.services.ts index 109ceab2e4..11c60bdfce 100644 --- a/src/backend/src/services/projects.services.ts +++ b/src/backend/src/services/projects.services.ts @@ -49,9 +49,9 @@ export default class ProjectsService { * @param organization the organization the user is currently in * @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) }); @@ -78,14 +78,15 @@ export default class ProjectsService { * @param organization the oranization the user is in * @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) }); @@ -99,7 +100,7 @@ export default class ProjectsService { * @param organization the organization the user is in * @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: { @@ -128,7 +129,8 @@ export default class ProjectsService { } ] } - } + }, + ...(carId && { carId }) }, ...getProjectOverviewQueryArgs(organization.organizationId) }); @@ -142,7 +144,7 @@ export default class ProjectsService { * @param teamId * @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: { @@ -153,7 +155,8 @@ export default class ProjectsService { some: { teamId } - } + }, + ...(carId && { carId }) }, ...getProjectQueryArgs(organization.organizationId) }); diff --git a/src/frontend/src/apis/projects.api.ts b/src/frontend/src/apis/projects.api.ts index 27a737a5c0..b9b2650ee3 100644 --- a/src/frontend/src/apis/projects.api.ts +++ b/src/frontend/src/apis/projects.api.ts @@ -29,8 +29,9 @@ import { CreateSingleProjectPayload, EditSingleProjectPayload } from '../utils/t /** * Fetches all projects with querry args needed for Gantt chart */ -export const getAllProjectsGantt = () => { +export const getAllProjectsGantt = (carId?: string) => { return axios.get(apiUrls.allProjectsGantt(), { + params: { carId }, transformResponse: (data) => JSON.parse(data).map(projectGanttTransformer) }); }; @@ -48,8 +49,9 @@ export const getAllProjects = (carId?: string) => { /** * Fetches all the projects that are on the users teams */ -export const getUsersTeamsProjects = () => { +export const getUsersTeamsProjects = (carId?: string) => { return axios.get(apiUrls.usersTeamsProjects(), { + params: { carId }, transformResponse: (data) => JSON.parse(data).map(projectOverviewTransformer) }); }; @@ -57,8 +59,9 @@ export const getUsersTeamsProjects = () => { /** * Fetches all projects that the user is the manager or lead of. */ -export const getUsersLeadingProjects = () => { +export const getUsersLeadingProjects = (carId?: string) => { return axios.get(apiUrls.usersLeadingProjects(), { + params: { carId }, transformResponse: (data) => JSON.parse(data).map(projectOverviewTransformer) }); }; From 14d52b601b2d61e602130e7606b20e7d5848fb6e Mon Sep 17 00:00:00 2001 From: Madalynn Nenninger Date: Mon, 16 Feb 2026 16:37:34 -0500 Subject: [PATCH 16/40] #3950 docs for car filtering --- src/backend/src/services/projects.services.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/backend/src/services/projects.services.ts b/src/backend/src/services/projects.services.ts index 11c60bdfce..b6cfd74db9 100644 --- a/src/backend/src/services/projects.services.ts +++ b/src/backend/src/services/projects.services.ts @@ -47,6 +47,7 @@ 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, carId?: string): Promise { @@ -61,6 +62,7 @@ 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, carId?: string): Promise { @@ -76,6 +78,7 @@ 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, carId?: string): Promise { @@ -98,6 +101,7 @@ 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, carId?: string): Promise { @@ -142,6 +146,7 @@ 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, carId?: string): Promise { From 5d21f7cc8e9493a1ec5fe2d1a3c871ec1cdd935e Mon Sep 17 00:00:00 2001 From: Madalynn Nenninger Date: Tue, 24 Feb 2026 19:34:56 -0500 Subject: [PATCH 17/40] #3950 udpated endpoint to use middleware to get car info --- src/backend/custom.d.ts | 3 ++- .../src/controllers/projects.controllers.ts | 22 +++++-------------- src/frontend/src/apis/projects.api.ts | 12 ++++------ 3 files changed, 11 insertions(+), 26 deletions(-) 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/src/controllers/projects.controllers.ts b/src/backend/src/controllers/projects.controllers.ts index c3a587cf84..4dd0938397 100644 --- a/src/backend/src/controllers/projects.controllers.ts +++ b/src/backend/src/controllers/projects.controllers.ts @@ -16,11 +16,7 @@ import BillOfMaterialsService from '../services/boms.services.js'; export default class ProjectsController { static async getAllProjectsGantt(req: Request, res: Response, next: NextFunction) { try { - const { carId } = req.query; - const projects: ProjectGantt[] = await ProjectsService.getAllProjectsGantt( - req.organization, - carId as string | undefined - ); + const projects: ProjectGantt[] = await ProjectsService.getAllProjectsGantt(req.organization, req.currentCar?.carId); res.status(200).json(projects); } catch (error: unknown) { next(error); @@ -29,8 +25,7 @@ export default class ProjectsController { static async getAllProjects(req: Request, res: Response, next: NextFunction) { try { - const { carId } = req.query; - const projects: ProjectPreview[] = await ProjectsService.getAllProjects(req.organization, carId as string | undefined); + const projects: ProjectPreview[] = await ProjectsService.getAllProjects(req.organization, req.currentCar?.carId); res.status(200).json(projects); } catch (error: unknown) { next(error); @@ -39,11 +34,10 @@ export default class ProjectsController { static async getUsersTeamsProjects(req: Request, res: Response, next: NextFunction) { try { - const { carId } = req.query; const projects: ProjectOverview[] = await ProjectsService.getUsersTeamsProjects( req.currentUser, req.organization, - carId as string | undefined + req.currentCar?.carId ); res.status(200).json(projects); } catch (error: unknown) { @@ -53,11 +47,10 @@ export default class ProjectsController { static async getUsersLeadingProjects(req: Request, res: Response, next: NextFunction) { try { - const { carId } = req.query; const projects: ProjectOverview[] = await ProjectsService.getUsersLeadingProjects( req.currentUser, req.organization, - carId as string | undefined + req.currentCar?.carId ); res.status(200).json(projects); } catch (error: unknown) { @@ -68,12 +61,7 @@ export default class ProjectsController { static async getTeamsProjects(req: Request, res: Response, next: NextFunction) { try { const { teamId } = req.params as Record; - const { carId } = req.query; - const projects: Project[] = await ProjectsService.getTeamsProjects( - req.organization, - teamId, - carId as string | undefined - ); + 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/frontend/src/apis/projects.api.ts b/src/frontend/src/apis/projects.api.ts index b9b2650ee3..e5d3ce62a4 100644 --- a/src/frontend/src/apis/projects.api.ts +++ b/src/frontend/src/apis/projects.api.ts @@ -29,9 +29,8 @@ import { CreateSingleProjectPayload, EditSingleProjectPayload } from '../utils/t /** * Fetches all projects with querry args needed for Gantt chart */ -export const getAllProjectsGantt = (carId?: string) => { +export const getAllProjectsGantt = () => { return axios.get(apiUrls.allProjectsGantt(), { - params: { carId }, transformResponse: (data) => JSON.parse(data).map(projectGanttTransformer) }); }; @@ -39,9 +38,8 @@ export const getAllProjectsGantt = (carId?: string) => { /** * Fetches all projects with preview querry args */ -export const getAllProjects = (carId?: string) => { +export const getAllProjects = () => { return axios.get(apiUrls.allProjectPreviews(), { - params: { carId }, transformResponse: (data) => JSON.parse(data).map(projectPreviewTransformer) }); }; @@ -49,9 +47,8 @@ export const getAllProjects = (carId?: string) => { /** * Fetches all the projects that are on the users teams */ -export const getUsersTeamsProjects = (carId?: string) => { +export const getUsersTeamsProjects = () => { return axios.get(apiUrls.usersTeamsProjects(), { - params: { carId }, transformResponse: (data) => JSON.parse(data).map(projectOverviewTransformer) }); }; @@ -59,9 +56,8 @@ export const getUsersTeamsProjects = (carId?: string) => { /** * Fetches all projects that the user is the manager or lead of. */ -export const getUsersLeadingProjects = (carId?: string) => { +export const getUsersLeadingProjects = () => { return axios.get(apiUrls.usersLeadingProjects(), { - params: { carId }, transformResponse: (data) => JSON.parse(data).map(projectOverviewTransformer) }); }; From 00255a93ad5df1c264b14d2489eea6b495f29d15 Mon Sep 17 00:00:00 2001 From: patriots1 Date: Sat, 28 Feb 2026 11:45:42 -0500 Subject: [PATCH 18/40] #3948 update work-packages.controllers.ts enable support of optional car param with getAllWorkPackages and getManyWorkPackages --- .../src/controllers/work-packages.controllers.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/backend/src/controllers/work-packages.controllers.ts b/src/backend/src/controllers/work-packages.controllers.ts index 9b64d5537d..f68da18ab8 100644 --- a/src/backend/src/controllers/work-packages.controllers.ts +++ b/src/backend/src/controllers/work-packages.controllers.ts @@ -8,8 +8,16 @@ export default class WorkPackagesController { static async getAllWorkPackages(req: Request, res: Response, next: NextFunction) { try { const { query } = req; - - const outputWorkPackages: WorkPackage[] = await WorkPackagesService.getAllWorkPackages(query, req.organization); + // Parse car parameter from string to number if provided + const queryWithParsedCar = { + ...query, + car: query.car ? parseInt(query.car as string, 10) : undefined + }; + + const outputWorkPackages: WorkPackage[] = await WorkPackagesService.getAllWorkPackages( + queryWithParsedCar, + req.organization + ); res.status(200).json(outputWorkPackages); } catch (error: unknown) { @@ -32,9 +40,9 @@ export default class WorkPackagesController { static async getManyWorkPackages(req: Request, res: Response, next: NextFunction) { try { - const { wbsNums } = req.body; + const { wbsNums, car } = req.body; - const workPackages: WorkPackage[] = await WorkPackagesService.getManyWorkPackages(wbsNums, req.organization); + const workPackages: WorkPackage[] = await WorkPackagesService.getManyWorkPackages(wbsNums, req.organization, car); res.status(200).json(workPackages); } catch (error: unknown) { next(error); From 931b46e1e763113123fab1dcc3ae0a7a8a67a7c1 Mon Sep 17 00:00:00 2001 From: patriots1 Date: Sun, 1 Mar 2026 14:05:26 -0500 Subject: [PATCH 19/40] #3948 refactor work-packages-services.ts add optional car param to more methods --- .../src/services/work-packages.services.ts | 53 ++++++++++++------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/src/backend/src/services/work-packages.services.ts b/src/backend/src/services/work-packages.services.ts index b6e6920bd7..70de49d56a 100644 --- a/src/backend/src/services/work-packages.services.ts +++ b/src/backend/src/services/work-packages.services.ts @@ -46,19 +46,22 @@ 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 car the car number to filter by (only returns work packages from this car when provided) + * @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; - car?: number; + 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 && { carId: query.carId }) + }, ...getWorkPackageQueryArgs(organization.organizationId) }); @@ -72,14 +75,9 @@ export default class WorkPackagesService { return passes; }); - const outputWorkPackages = - query.car !== undefined - ? filteredWorkPackages.filter((wp) => wp.wbsNum.carNumber === query.car) - : filteredWorkPackages; - - outputWorkPackages.sort((wpA, wpB) => wpA.endDate.getTime() - wpB.endDate.getTime()); + filteredWorkPackages.sort((wpA, wpB) => wpA.endDate.getTime() - wpB.endDate.getTime()); - return outputWorkPackages; + return filteredWorkPackages; } /** @@ -123,11 +121,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 car optional car number to filter work packages by + * @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, car?: number): Promise { + static async getManyWorkPackages( + wbsNums: WbsNumber[], + organization: Organization, + carId?: string + ): Promise { wbsNums.forEach((wbsNum) => { if (!isWorkPackageWbs(wbsNum)) { throw new HttpException( @@ -137,7 +139,8 @@ export default class WorkPackagesService { } }); - const filteredWorkPackages = car !== undefined ? wbsNums.filter((wbsNum) => car === wbsNum.carNumber) : wbsNums; + const filteredWorkPackages = + carId !== undefined ? wbsNums.filter((wbsNum) => parseInt(carId) === wbsNum.carNumber) : wbsNums; const workPackagePromises = filteredWorkPackages.map(async (wbsNum) => { return WorkPackagesService.getSingleWorkPackage(wbsNum, organization); @@ -511,9 +514,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 @@ -542,7 +550,13 @@ export default class WorkPackagesService { const blockingWorkPackages = await getBlockingWorkPackages(workPackage); - return blockingWorkPackages.map(workPackageTransformer); + const transformedPackages = blockingWorkPackages.map(workPackageTransformer); + + if (carId) { + return transformedPackages.filter((wp) => wp.wbsNum.carNumber === parseInt(carId)); + } + + return transformedPackages; } /** @@ -586,12 +600,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 @@ -626,7 +642,8 @@ export default class WorkPackagesService { ...selectionArgs, dateDeleted: null, organizationId: organization.organizationId, - status: { not: WBS_Element_Status.COMPLETE } + status: { not: WBS_Element_Status.COMPLETE }, + ...(carId && { carNumber: parseInt(carId) }) } }, select: { From 6cbafec03ad8f6f2e420814ee029e92afc350dc0 Mon Sep 17 00:00:00 2001 From: patriots1 Date: Sun, 1 Mar 2026 14:17:06 -0500 Subject: [PATCH 20/40] #3948 refactor work-packages.controllers.ts make sure optional car param is used --- .../controllers/work-packages.controllers.ts | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/backend/src/controllers/work-packages.controllers.ts b/src/backend/src/controllers/work-packages.controllers.ts index f68da18ab8..353a8cf9c8 100644 --- a/src/backend/src/controllers/work-packages.controllers.ts +++ b/src/backend/src/controllers/work-packages.controllers.ts @@ -8,16 +8,8 @@ export default class WorkPackagesController { static async getAllWorkPackages(req: Request, res: Response, next: NextFunction) { try { const { query } = req; - // Parse car parameter from string to number if provided - const queryWithParsedCar = { - ...query, - car: query.car ? parseInt(query.car as string, 10) : undefined - }; - - const outputWorkPackages: WorkPackage[] = await WorkPackagesService.getAllWorkPackages( - queryWithParsedCar, - req.organization - ); + + const outputWorkPackages: WorkPackage[] = await WorkPackagesService.getAllWorkPackages(query, req.organization); res.status(200).json(outputWorkPackages); } catch (error: unknown) { @@ -40,9 +32,9 @@ export default class WorkPackagesController { static async getManyWorkPackages(req: Request, res: Response, next: NextFunction) { try { - const { wbsNums, car } = req.body; + const { wbsNums, carId } = req.body; - const workPackages: WorkPackage[] = await WorkPackagesService.getManyWorkPackages(wbsNums, req.organization, car); + const workPackages: WorkPackage[] = await WorkPackagesService.getManyWorkPackages(wbsNums, req.organization, carId); res.status(200).json(workPackages); } catch (error: unknown) { next(error); @@ -120,10 +112,12 @@ export default class WorkPackagesController { static async getBlockingWorkPackages(req: Request, res: Response, next: NextFunction) { try { const wbsNum = validateWBS(req.params.wbsNum as string); + const carId = req.query.carId as string | undefined; const blockingWorkPackages: WorkPackage[] = await WorkPackagesService.getBlockingWorkPackages( wbsNum, - req.organization + req.organization, + carId ); res.status(200).json(blockingWorkPackages); @@ -146,11 +140,13 @@ export default class WorkPackagesController { static async getHomePageWorkPackages(req: Request, res: Response, next: NextFunction) { try { const { selection } = req.params as Record; + const carId = req.query.carId as string | undefined; const workPackages: WorkPackagePreview[] = await WorkPackagesService.getHomePageWorkPackages( req.currentUser, req.organization, - selection as WorkPackageSelection + selection as WorkPackageSelection, + carId ); res.status(200).json(workPackages); From 1e215952d474a5c7899ba5e33427c93a362cb953 Mon Sep 17 00:00:00 2001 From: patriots1 Date: Sun, 1 Mar 2026 14:48:17 -0500 Subject: [PATCH 21/40] #3948 update work-packages.routes.ts include checks for optional car param --- .../src/routes/work-packages.routes.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/backend/src/routes/work-packages.routes.ts b/src/backend/src/routes/work-packages.routes.ts index dbb2f2abdf..29916ecab6 100644 --- a/src/backend/src/routes/work-packages.routes.ts +++ b/src/backend/src/routes/work-packages.routes.ts @@ -1,5 +1,5 @@ import express from 'express'; -import { body, param } from 'express-validator'; +import { body, param, query } from 'express-validator'; import WorkPackagesController from '../controllers/work-packages.controllers.js'; import { blockedByValidators, @@ -13,13 +13,20 @@ import { import { WorkPackageSelection } from 'shared'; const workPackagesRouter = express.Router(); -workPackagesRouter.get('/', WorkPackagesController.getAllWorkPackages); +workPackagesRouter.get( + '/', + query('carId').optional().isNumeric(), + validateInputs, + WorkPackagesController.getAllWorkPackages +); + workPackagesRouter.post( '/get-many', body('wbsNums').isArray(), intMinZero(body('wbsNums.*.carNumber')), intMinZero(body('wbsNums.*.projectNumber')), intMinZero(body('wbsNums.*.workPackageNumber')), + body('carId').optional().isNumeric(), validateInputs, WorkPackagesController.getManyWorkPackages ); @@ -56,7 +63,14 @@ workPackagesRouter.post( WorkPackagesController.editWorkPackage ); workPackagesRouter.delete('/:wbsNum/delete', WorkPackagesController.deleteWorkPackage); -workPackagesRouter.get('/:wbsNum/blocking', WorkPackagesController.getBlockingWorkPackages); + +workPackagesRouter.get( + '/:wbsNum/blocking', + query('carId').optional().isNumeric(), + validateInputs, + WorkPackagesController.getBlockingWorkPackages +); + workPackagesRouter.post( '/slack-upcoming-deadlines', isDate(body('deadline')), @@ -67,6 +81,7 @@ workPackagesRouter.post( workPackagesRouter.get( '/home-page/:selection', param('selection').isIn(Object.values(WorkPackageSelection)), + query('carId').optional().isNumeric(), validateInputs, WorkPackagesController.getHomePageWorkPackages ); From 14754fb78d16f9f7e0b19d5ed76173f1817e608b Mon Sep 17 00:00:00 2001 From: "sayegh.st@northeastern.edu" Date: Fri, 6 Mar 2026 09:24:44 -0500 Subject: [PATCH 22/40] connecting carId with frontend --- src/backend/index.ts | 4 ++++ src/backend/src/utils/car.utils.ts | 26 ++++++++++++++++++++++++++ src/frontend/src/utils/axios.ts | 2 ++ 3 files changed, 32 insertions(+) create mode 100644 src/backend/src/utils/car.utils.ts diff --git a/src/backend/index.ts b/src/backend/index.ts index f7449ff405..e13d3700ea 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'; @@ -81,6 +82,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/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/frontend/src/utils/axios.ts b/src/frontend/src/utils/axios.ts index 85eb74d43a..30fb72ba93 100644 --- a/src/frontend/src/utils/axios.ts +++ b/src/frontend/src/utils/axios.ts @@ -37,6 +37,8 @@ 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 ?? ''; + const carId = sessionStorage.getItem('selectedCarId'); + request.headers!['carId'] = carId ?? ''; return request; }, (error) => { From db22b4329920c89e99a863889b358db9c37ab2cf Mon Sep 17 00:00:00 2001 From: patriots1 Date: Sat, 7 Mar 2026 18:29:16 -0500 Subject: [PATCH 23/40] #3948 update controller and service update controller for middleware, fix bug due to type change in service --- .../controllers/work-packages.controllers.ts | 23 +++++++---- .../src/services/work-packages.services.ts | 40 +++++++++++-------- 2 files changed, 40 insertions(+), 23 deletions(-) diff --git a/src/backend/src/controllers/work-packages.controllers.ts b/src/backend/src/controllers/work-packages.controllers.ts index 353a8cf9c8..49989c780a 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) { @@ -32,9 +39,13 @@ export default class WorkPackagesController { static async getManyWorkPackages(req: Request, res: Response, next: NextFunction) { try { - const { wbsNums, carId } = req.body; + const { wbsNums } = req.body; - const workPackages: WorkPackage[] = await WorkPackagesService.getManyWorkPackages(wbsNums, req.organization, carId); + const workPackages: WorkPackage[] = await WorkPackagesService.getManyWorkPackages( + wbsNums, + req.organization, + req.currentCar?.carId + ); res.status(200).json(workPackages); } catch (error: unknown) { next(error); @@ -112,12 +123,11 @@ export default class WorkPackagesController { static async getBlockingWorkPackages(req: Request, res: Response, next: NextFunction) { try { const wbsNum = validateWBS(req.params.wbsNum as string); - const carId = req.query.carId as string | undefined; const blockingWorkPackages: WorkPackage[] = await WorkPackagesService.getBlockingWorkPackages( wbsNum, req.organization, - carId + req.currentCar?.carId ); res.status(200).json(blockingWorkPackages); @@ -140,13 +150,12 @@ export default class WorkPackagesController { static async getHomePageWorkPackages(req: Request, res: Response, next: NextFunction) { try { const { selection } = req.params as Record; - const carId = req.query.carId as string | undefined; const workPackages: WorkPackagePreview[] = await WorkPackagesService.getHomePageWorkPackages( req.currentUser, req.organization, selection as WorkPackageSelection, - carId + req.currentCar?.carId ); res.status(200).json(workPackages); diff --git a/src/backend/src/services/work-packages.services.ts b/src/backend/src/services/work-packages.services.ts index 70de49d56a..eca55115ec 100644 --- a/src/backend/src/services/work-packages.services.ts +++ b/src/backend/src/services/work-packages.services.ts @@ -60,7 +60,7 @@ export default class WorkPackagesService { const workPackages = await prisma.work_Package.findMany({ where: { wbsElement: { dateDeleted: null, organizationId: organization.organizationId }, - ...(query.carId && { carId: query.carId }) + ...(query.carId && { project: { carId: query.carId } }) }, ...getWorkPackageQueryArgs(organization.organizationId) }); @@ -139,15 +139,25 @@ export default class WorkPackagesService { } }); - const filteredWorkPackages = - carId !== undefined ? wbsNums.filter((wbsNum) => parseInt(carId) === wbsNum.carNumber) : wbsNums; + const whereConditions = wbsNums.map((wbsNum) => ({ + wbsElement: { + carNumber: wbsNum.carNumber, + projectNumber: wbsNum.projectNumber, + workPackageNumber: wbsNum.workPackageNumber, + organizationId: organization.organizationId, + dateDeleted: null + }, + ...(carId && { project: { carId } }) + })); - const workPackagePromises = filteredWorkPackages.map(async (wbsNum) => { - return WorkPackagesService.getSingleWorkPackage(wbsNum, organization); + 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); } /** @@ -550,13 +560,11 @@ export default class WorkPackagesService { const blockingWorkPackages = await getBlockingWorkPackages(workPackage); - const transformedPackages = blockingWorkPackages.map(workPackageTransformer); + const filteredWorkPackages = carId + ? blockingWorkPackages.filter((wp) => wp.project.carId === carId) + : blockingWorkPackages; - if (carId) { - return transformedPackages.filter((wp) => wp.wbsNum.carNumber === parseInt(carId)); - } - - return transformedPackages; + return filteredWorkPackages.map(workPackageTransformer); } /** @@ -642,9 +650,9 @@ export default class WorkPackagesService { ...selectionArgs, dateDeleted: null, organizationId: organization.organizationId, - status: { not: WBS_Element_Status.COMPLETE }, - ...(carId && { carNumber: parseInt(carId) }) - } + status: { not: WBS_Element_Status.COMPLETE } + }, + ...(carId && { project: { carId } }) }, select: { project: { select: { projectId: true, wbsElement: { select: { name: true } } } }, From 18b63f428d1aa690cffa61af05cf08d8ce7ebe64 Mon Sep 17 00:00:00 2001 From: Chris Pyle Date: Wed, 11 Mar 2026 11:31:42 -0400 Subject: [PATCH 24/40] remove carid from route validation --- .../controllers/work-packages.controllers.ts | 3 ++- .../src/routes/work-packages.routes.ts | 23 +++---------------- .../src/services/work-packages.services.ts | 7 ++++-- 3 files changed, 10 insertions(+), 23 deletions(-) diff --git a/src/backend/src/controllers/work-packages.controllers.ts b/src/backend/src/controllers/work-packages.controllers.ts index 813200d4db..9aaec541c2 100644 --- a/src/backend/src/controllers/work-packages.controllers.ts +++ b/src/backend/src/controllers/work-packages.controllers.ts @@ -31,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); diff --git a/src/backend/src/routes/work-packages.routes.ts b/src/backend/src/routes/work-packages.routes.ts index f7bb65f527..03063b43cc 100644 --- a/src/backend/src/routes/work-packages.routes.ts +++ b/src/backend/src/routes/work-packages.routes.ts @@ -13,26 +13,15 @@ import { import { WorkPackageSelection, WbsElementStatus } from 'shared'; const workPackagesRouter = express.Router(); -workPackagesRouter.get( - '/', - query('carId').optional().isNumeric(), - validateInputs, - WorkPackagesController.getAllWorkPackages -); +workPackagesRouter.get('/', WorkPackagesController.getAllWorkPackages); -workPackagesRouter.get( - '/all-preview', - query('status').optional().isIn(Object.values(WbsElementStatus)), - validateInputs, - WorkPackagesController.getAllWorkPackagesPreview -); +workPackagesRouter.get('/all-preview', WorkPackagesController.getAllWorkPackagesPreview); workPackagesRouter.post( '/get-many', body('wbsNums').isArray(), intMinZero(body('wbsNums.*.carNumber')), intMinZero(body('wbsNums.*.projectNumber')), intMinZero(body('wbsNums.*.workPackageNumber')), - body('carId').optional().isNumeric(), validateInputs, WorkPackagesController.getManyWorkPackages ); @@ -70,12 +59,7 @@ workPackagesRouter.post( ); workPackagesRouter.delete('/:wbsNum/delete', WorkPackagesController.deleteWorkPackage); -workPackagesRouter.get( - '/:wbsNum/blocking', - query('carId').optional().isNumeric(), - validateInputs, - WorkPackagesController.getBlockingWorkPackages -); +workPackagesRouter.get('/:wbsNum/blocking', WorkPackagesController.getBlockingWorkPackages); workPackagesRouter.post( '/slack-upcoming-deadlines', @@ -87,7 +71,6 @@ workPackagesRouter.post( workPackagesRouter.get( '/home-page/:selection', param('selection').isIn(Object.values(WorkPackageSelection)), - query('carId').optional().isNumeric(), validateInputs, WorkPackagesController.getHomePageWorkPackages ); diff --git a/src/backend/src/services/work-packages.services.ts b/src/backend/src/services/work-packages.services.ts index 11c6d457d1..1c1334ab0a 100644 --- a/src/backend/src/services/work-packages.services.ts +++ b/src/backend/src/services/work-packages.services.ts @@ -85,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() From 025d867eca4872cfcb7c0ff78e44777e8a5ce7ae Mon Sep 17 00:00:00 2001 From: Chris Pyle Date: Wed, 11 Mar 2026 11:35:24 -0400 Subject: [PATCH 25/40] added back status query route validation --- src/backend/src/routes/work-packages.routes.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/backend/src/routes/work-packages.routes.ts b/src/backend/src/routes/work-packages.routes.ts index 03063b43cc..b3aa8ac529 100644 --- a/src/backend/src/routes/work-packages.routes.ts +++ b/src/backend/src/routes/work-packages.routes.ts @@ -14,8 +14,12 @@ import { WorkPackageSelection, WbsElementStatus } from 'shared'; const workPackagesRouter = express.Router(); workPackagesRouter.get('/', WorkPackagesController.getAllWorkPackages); - -workPackagesRouter.get('/all-preview', WorkPackagesController.getAllWorkPackagesPreview); +workPackagesRouter.get( + '/all-preview', + query('status').optional().isIn(Object.values(WbsElementStatus)), + validateInputs, + WorkPackagesController.getAllWorkPackagesPreview +); workPackagesRouter.post( '/get-many', body('wbsNums').isArray(), From 9ea3dd9fdf5bad91604d7c1d4c64a0b9dad9bd8b Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Thu, 19 Mar 2026 09:58:21 -0400 Subject: [PATCH 26/40] refactor: modify queries to include selectedCar key, remove redundant frontend filtering --- src/frontend/src/hooks/projects.hooks.ts | 16 ++++++++++----- src/frontend/src/hooks/work-packages.hooks.ts | 10 +++++++--- .../ProjectGanttChartPage.tsx | 20 +------------------ .../pages/ProjectsPage/ProjectsOverview.tsx | 10 ++-------- .../src/pages/ProjectsPage/ProjectsTable.tsx | 7 +------ 5 files changed, 22 insertions(+), 41 deletions(-) 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/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx index 5d0caebd41..c957d99691 100644 --- a/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx +++ b/src/frontend/src/pages/GanttPage/ProjectGanttChart/ProjectGanttChartPage.tsx @@ -55,7 +55,6 @@ import { v4 as uuidv4 } from 'uuid'; import { projectWbsPipe } from '../../../utils/pipes'; import { projectGanttTransformer } from '../../../apis/transformers/projects.transformers'; import { useCurrentUser } from '../../../hooks/users.hooks'; -import { useGlobalCarFilter } from '../../../app/AppGlobalCarFilterContext'; const getElementId = (element: WbsElementPreview | Task) => { return (element as WbsElementPreview).id ?? (element as Task).taskId; @@ -64,7 +63,6 @@ const getElementId = (element: WbsElementPreview | Task) => { const ProjectGanttChartPage: FC = () => { const history = useHistory(); const toast = useToast(); - const { selectedCar } = useGlobalCarFilter(); const { isLoading: projectsIsLoading, @@ -114,11 +112,6 @@ const ProjectGanttChartPage: FC = () => { projectGanttTransformer ); - // Filter by selected car from global filter - if (selectedCar) { - allProjects = allProjects.filter((project) => project.wbsNum.carNumber === selectedCar.wbsNum.carNumber); - } - allProjects = allProjects.map((project) => { const editedProject = editedProjects.find((proj) => proj.id === project.id); return editedProject ? editedProject : project; @@ -139,18 +132,7 @@ const ProjectGanttChartPage: FC = () => { if (projects && teams) { requestRefresh(projects, teams, editedProjects, addedProjects, filters, searchText); } - }, [ - teams, - projects, - addedProjects, - setAllProjects, - setCollections, - editedProjects, - filters, - searchText, - history, - selectedCar - ]); + }, [teams, projects, addedProjects, setAllProjects, setCollections, editedProjects, filters, searchText, history]); const handleSetGanttFilters = (newFilters: GanttFilters) => { setFilters(newFilters); diff --git a/src/frontend/src/pages/ProjectsPage/ProjectsOverview.tsx b/src/frontend/src/pages/ProjectsPage/ProjectsOverview.tsx index 9cc762df7d..35e7b4e071 100644 --- a/src/frontend/src/pages/ProjectsPage/ProjectsOverview.tsx +++ b/src/frontend/src/pages/ProjectsPage/ProjectsOverview.tsx @@ -53,18 +53,12 @@ const ProjectsOverview: React.FC = () => { const carFilteredFavorites = selectedCar ? favoriteProjects.filter((project) => project.wbsNum.carNumber === selectedCar.wbsNum.carNumber) : favoriteProjects; - const carFilteredTeams = selectedCar - ? teamsProjects.filter((project) => project.wbsNum.carNumber === selectedCar.wbsNum.carNumber) - : teamsProjects; - const carFilteredLeading = selectedCar - ? leadingProjects.filter((project) => project.wbsNum.carNumber === selectedCar.wbsNum.carNumber) - : leadingProjects; // Keeps only favorite team/leading projects (even when completed) or incomplete projects - const filteredTeamsProjects = carFilteredTeams.filter( + const filteredTeamsProjects = teamsProjects.filter( (project) => project.status !== WbsElementStatus.Complete || favoriteProjectsSet.has(project.id) ); - const filteredLeadingProjects = carFilteredLeading.filter( + const filteredLeadingProjects = leadingProjects.filter( (project) => project.status !== WbsElementStatus.Complete || favoriteProjectsSet.has(project.id) ); diff --git a/src/frontend/src/pages/ProjectsPage/ProjectsTable.tsx b/src/frontend/src/pages/ProjectsPage/ProjectsTable.tsx index 4b425e90a4..88d2dbcde4 100644 --- a/src/frontend/src/pages/ProjectsPage/ProjectsTable.tsx +++ b/src/frontend/src/pages/ProjectsPage/ProjectsTable.tsx @@ -14,17 +14,12 @@ import { routes } from '../../utils/routes'; import { GridColDefStyle } from '../../utils/tables'; import TableCustomToolbar from '../../components/TableCustomToolbar'; import { getProjectTeamsName } from '../ProjectDetailPage/ProjectViewContainer/ProjectDetails'; -import { useGlobalCarFilter } from '../../app/AppGlobalCarFilterContext'; /** * Table of all projects. */ const ProjectsTable: React.FC = () => { const { isLoading, data, error } = useAllProjects(); - const { selectedCar } = useGlobalCarFilter(); - - const filteredData = - selectedCar && data ? data.filter((project) => project.wbsNum.carNumber === selectedCar.wbsNum.carNumber) : data; if (!localStorage.getItem('projectsTableRowCount')) localStorage.setItem('projectsTableRowCount', '30'); const [pageSize, setPageSize] = useState(localStorage.getItem('projectsTableRowCount')); @@ -186,7 +181,7 @@ const ProjectsTable: React.FC = () => { error={error} rows={ // flatten some complex data to allow MUI to sort/filter yet preserve the original data being available to the front-end - filteredData?.map((v) => ({ + data?.map((v) => ({ ...v, carNumber: v.wbsNum.carNumber, lead: fullNamePipe(v.lead), From 85de9c172eed62842e0aa41df12d9df3b705d832 Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Thu, 19 Mar 2026 10:15:15 -0400 Subject: [PATCH 27/40] test: fix wrapper to include global car filter --- src/frontend/src/tests/hooks/Projects.hooks.test.tsx | 9 ++++++++- src/frontend/src/tests/hooks/WorkPackages.hooks.test.tsx | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/tests/hooks/Projects.hooks.test.tsx b/src/frontend/src/tests/hooks/Projects.hooks.test.tsx index c35e2b989c..400369ae42 100644 --- a/src/frontend/src/tests/hooks/Projects.hooks.test.tsx +++ b/src/frontend/src/tests/hooks/Projects.hooks.test.tsx @@ -6,13 +6,20 @@ 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'; +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + vi.mock('../../apis/projects.api'); describe('project hooks', () => { diff --git a/src/frontend/src/tests/hooks/WorkPackages.hooks.test.tsx b/src/frontend/src/tests/hooks/WorkPackages.hooks.test.tsx index 3b4ce0a8d5..696428d778 100644 --- a/src/frontend/src/tests/hooks/WorkPackages.hooks.test.tsx +++ b/src/frontend/src/tests/hooks/WorkPackages.hooks.test.tsx @@ -6,13 +6,20 @@ 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'; +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + vi.mock('../../apis/work-packages.api'); describe('work package hooks', () => { From 5c245071be0b326b8fb451ed588fe41dc1a4a586 Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Fri, 20 Mar 2026 21:42:08 -0400 Subject: [PATCH 28/40] feat: add car filtering to change requests endpoints and overview --- .../change-requests.controllers.ts | 14 +++++++--- .../src/services/change-requests.services.ts | 26 +++++++++++++------ .../src/hooks/change-requests.hooks.ts | 13 +++++++--- .../ChangeRequestsPage/ChangeRequestsView.tsx | 4 ++- 4 files changed, 40 insertions(+), 17 deletions(-) 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/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index 27202c9c16..16cea1b594 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: { 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: { 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,10 @@ 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: { project: { carId } } }] : [])); + } const changeRequests = await prisma.change_Request.findMany({ where: { @@ -208,13 +217,14 @@ 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: { project: { carId } } }] : [])]; const changeRequests = await prisma.change_Request.findMany({ where: { 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/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 ( Date: Fri, 20 Mar 2026 23:02:35 -0400 Subject: [PATCH 29/40] fix: correct first load car filtering and simplify initialization logic --- .../src/app/AppGlobalCarFilterContext.tsx | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/src/frontend/src/app/AppGlobalCarFilterContext.tsx b/src/frontend/src/app/AppGlobalCarFilterContext.tsx index 693bf8a5ca..79bfabb1e1 100644 --- a/src/frontend/src/app/AppGlobalCarFilterContext.tsx +++ b/src/frontend/src/app/AppGlobalCarFilterContext.tsx @@ -3,14 +3,14 @@ * See the LICENSE file in the repository root folder for details. */ -import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import React, { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react'; import { Car } from 'shared'; import { useGetCurrentCar, useGetAllCars } from '../hooks/cars.hooks'; interface GlobalCarFilterContextType { selectedCar: Car | null; allCars: Car[]; - setSelectedCar: (car: Car | null) => void; + setSelectedCar: (car: Car) => void; isLoading: boolean; error: Error | null; } @@ -23,7 +23,7 @@ interface GlobalCarFilterProviderProps { export const GlobalCarFilterProvider: React.FC = ({ children }) => { const [selectedCar, setSelectedCarState] = useState(null); - const [hasBeenManuallyCleared, setHasBeenManuallyCleared] = useState(false); + const hasInitialized = useRef(false); const { data: currentCar, isLoading: currentCarLoading, error: currentCarError } = useGetCurrentCar(); const { data: allCars = [], isLoading: allCarsLoading, error: allCarsError } = useGetAllCars(); @@ -32,39 +32,32 @@ export const GlobalCarFilterProvider: React.FC = ( const error = currentCarError || allCarsError; useEffect(() => { - if (!isLoading && allCars.length > 0 && !hasBeenManuallyCleared) { - const savedCarId = sessionStorage.getItem('selectedCarId'); + if (!isLoading && allCars.length > 0 && !hasInitialized.current) { + hasInitialized.current = true; + const savedCarId = sessionStorage.getItem('selectedCarId'); if (savedCarId) { const savedCar = allCars.find((car) => car.id === savedCarId); if (savedCar) { - setSelectedCarState(savedCar); + setSelectedCar(savedCar); return; } } if (currentCar) { - setSelectedCarState(currentCar); - } else if (allCars.length > 0) { + setSelectedCar(currentCar); + } else { const mostRecentCar = allCars.reduce((latest, car) => car.wbsNum.carNumber > latest.wbsNum.carNumber ? car : latest ); - setSelectedCarState(mostRecentCar); + setSelectedCar(mostRecentCar); } } - }, [currentCar, allCars, isLoading, hasBeenManuallyCleared]); + }, [currentCar, allCars, isLoading]); - const setSelectedCar = (car: Car | null) => { - if (car === null) { - setHasBeenManuallyCleared(true); - } + const setSelectedCar = (car: Car) => { setSelectedCarState(car); - - if (car) { - sessionStorage.setItem('selectedCarId', car.id); - } else { - sessionStorage.removeItem('selectedCarId'); - } + sessionStorage.setItem('selectedCarId', car.id); }; const value: GlobalCarFilterContextType = { From 1dfd631420e44aa01d8df6d1eb23e0ffd6faa80f Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Sat, 21 Mar 2026 11:23:53 -0400 Subject: [PATCH 30/40] fix: resolve failing tests and type errors from global car filter --- .../src/components/FinanceDashboardCarFilter.tsx | 4 +--- src/frontend/src/hooks/finance-car-filter.hooks.ts | 4 ++-- src/frontend/src/tests/app/AppContextQuery.test.tsx | 4 ++++ .../src/tests/hooks/ChangeRequests.hooks.test.tsx | 3 +++ .../src/tests/hooks/GlobalCarFilterContext.test.tsx | 10 +++++----- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/frontend/src/components/FinanceDashboardCarFilter.tsx b/src/frontend/src/components/FinanceDashboardCarFilter.tsx index 097d6846bd..a0cde006bd 100644 --- a/src/frontend/src/components/FinanceDashboardCarFilter.tsx +++ b/src/frontend/src/components/FinanceDashboardCarFilter.tsx @@ -36,9 +36,7 @@ const FinanceDashboardCarFilterComponent: React.FC { if (newValue) { const car = allCars.find((c) => c.id === newValue.id); - setSelectedCar(car || null); - } else { - setSelectedCar(null); + if (car) setSelectedCar(car); } }; diff --git a/src/frontend/src/hooks/finance-car-filter.hooks.ts b/src/frontend/src/hooks/finance-car-filter.hooks.ts index 8b90abf705..ed7d69fe1f 100644 --- a/src/frontend/src/hooks/finance-car-filter.hooks.ts +++ b/src/frontend/src/hooks/finance-car-filter.hooks.ts @@ -13,7 +13,7 @@ export interface FinanceDashboardCarFilter { startDate: Date | undefined; endDate: Date | undefined; carNumber: number | undefined; - setSelectedCar: (car: Car | null) => void; + setSelectedCar: (car: Car) => void; setStartDate: (date: Date | undefined) => void; setEndDate: (date: Date | undefined) => void; isLoading: boolean; @@ -62,7 +62,7 @@ export const useFinanceDashboardCarFilter = ( } }, [selectedCar, allCars]); - const setSelectedCar = (car: Car | null) => { + const setSelectedCar = (car: Car) => { setGlobalSelectedCar(car); }; 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 index 11fc26acfd..b5a5042512 100644 --- a/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx +++ b/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx @@ -183,7 +183,7 @@ describe('useGlobalCarFilter', () => { expect(result.current.isLoading).toBe(false); }); - it('should clear session storage when setting car to null', async () => { + it('should update session storage when switching cars', async () => { mockUseGetCurrentCar.mockReturnValue({ data: exampleCurrentCar, isLoading: false, @@ -204,12 +204,12 @@ describe('useGlobalCarFilter', () => { expect(result.current.selectedCar).toBeTruthy(); }); - // Clear selection - result.current.setSelectedCar(null); + // Switch to a different car + result.current.setSelectedCar(exampleAllCars[0]); + expect(sessionStorage.getItem('selectedCarId')).toBe(exampleAllCars[0].id); await waitFor(() => { - expect(sessionStorage.getItem('selectedCarId')).toBeNull(); + expect(result.current.selectedCar).toEqual(exampleAllCars[0]); }); - expect(result.current.selectedCar).toBeNull(); }); }); From 21577b3639c2a964c6602b144d16e70ab6876b43 Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Sat, 21 Mar 2026 11:35:44 -0400 Subject: [PATCH 31/40] fix: resolve failing tests for projects + work packages --- src/frontend/src/tests/hooks/Projects.hooks.test.tsx | 8 ++++++++ src/frontend/src/tests/hooks/WorkPackages.hooks.test.tsx | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/src/frontend/src/tests/hooks/Projects.hooks.test.tsx b/src/frontend/src/tests/hooks/Projects.hooks.test.tsx index 400369ae42..cbd97bf150 100644 --- a/src/frontend/src/tests/hooks/Projects.hooks.test.tsx +++ b/src/frontend/src/tests/hooks/Projects.hooks.test.tsx @@ -13,6 +13,8 @@ import { exampleAllProjects, exampleProject1 } from '../test-support/test-data/p 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 }) => ( @@ -21,6 +23,12 @@ const wrapper = ({ children }: { children: React.ReactNode }) => ( ); 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 696428d778..8d497cc2e0 100644 --- a/src/frontend/src/tests/hooks/WorkPackages.hooks.test.tsx +++ b/src/frontend/src/tests/hooks/WorkPackages.hooks.test.tsx @@ -13,6 +13,8 @@ import { exampleAllWorkPackages, exampleResearchWorkPackage } from '../test-supp 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 }) => ( @@ -21,6 +23,12 @@ const wrapper = ({ children }: { children: React.ReactNode }) => ( ); 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 () => { From 33b1f13d3ea0517d40797a6700880f63ac4b0893 Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Sat, 21 Mar 2026 11:49:55 -0400 Subject: [PATCH 32/40] fix: set Fergus car filter in cy.login for e2e tests --- system-tests/cypress/support/commands.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/system-tests/cypress/support/commands.js b/system-tests/cypress/support/commands.js index f419378d93..0ee4aec996 100644 --- a/system-tests/cypress/support/commands.js +++ b/system-tests/cypress/support/commands.js @@ -18,6 +18,16 @@ Cypress.Commands.add('login', (username = 'Thomas Emrax', redirect = '/home') => cy.contains(username).click(); cy.get(LOGIN_ICON).click(); cy.waitForLoading(); + + // set the car filter to Fergus (carNumber 0) where all seed data lives, + // so GlobalCarFilterProvider grabs it from sessionStorage on first mount + cy.request(Cypress.env('base_url') + '/cars').then((response) => { + const fergus = response.body.find((car) => car.wbsNum.carNumber === 0); + if (fergus) { + cy.window().then((win) => win.sessionStorage.setItem('selectedCarId', fergus.id)); + } + }); + cy.visit(Cypress.env('base_url') + redirect); cy.waitForLoading(); }); From 5ad72df88d85621b169717df2889027f72a51b37 Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Sat, 21 Mar 2026 12:09:33 -0400 Subject: [PATCH 33/40] fix: update Fergus car filter in e2e login to use backend cars endpoint --- system-tests/cypress.config.js | 3 ++- system-tests/cypress/support/commands.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/system-tests/cypress.config.js b/system-tests/cypress.config.js index cbe2689ab4..d349927eaf 100644 --- a/system-tests/cypress.config.js +++ b/system-tests/cypress.config.js @@ -11,6 +11,7 @@ module.exports = { defaultCommandTimeout: 10000 }, env: { - base_url: 'http://localhost:3000' + base_url: 'http://localhost:3000', + backend_url: 'http://localhost:3001' } }; diff --git a/system-tests/cypress/support/commands.js b/system-tests/cypress/support/commands.js index 0ee4aec996..34f72e2018 100644 --- a/system-tests/cypress/support/commands.js +++ b/system-tests/cypress/support/commands.js @@ -21,7 +21,7 @@ Cypress.Commands.add('login', (username = 'Thomas Emrax', redirect = '/home') => // set the car filter to Fergus (carNumber 0) where all seed data lives, // so GlobalCarFilterProvider grabs it from sessionStorage on first mount - cy.request(Cypress.env('base_url') + '/cars').then((response) => { + cy.request(Cypress.env('backend_url') + '/cars').then((response) => { const fergus = response.body.find((car) => car.wbsNum.carNumber === 0); if (fergus) { cy.window().then((win) => win.sessionStorage.setItem('selectedCarId', fergus.id)); From 1d9b9ae623d7994daa8228ae8967a499f8ad92e1 Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Sat, 21 Mar 2026 13:43:55 -0400 Subject: [PATCH 34/40] fix: move seed data to NER-25 and fix change request car filtering for work packages --- .../seed-data/reimbursement-requests.seed.ts | 74 +++++++++---------- src/backend/src/prisma/seed.ts | 32 ++++---- .../src/services/change-requests.services.ts | 17 ++++- system-tests/cypress.config.js | 3 +- .../e2e/projects/projects-overview.cy.js | 2 +- system-tests/cypress/support/commands.js | 10 --- .../cypress/utils/change-request.utils.cy.js | 2 +- 7 files changed, 69 insertions(+), 71 deletions(-) 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 7ba828ce38..7cbce0a3fb 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 94c6faa411..9a993c5099 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -336,11 +336,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, @@ -608,7 +608,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], @@ -636,7 +636,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], @@ -664,7 +664,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], @@ -692,7 +692,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], @@ -724,7 +724,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], @@ -752,7 +752,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], @@ -780,7 +780,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], @@ -808,7 +808,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], @@ -836,7 +836,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], @@ -2319,7 +2319,7 @@ const performSeed: () => Promise = async () => { '1', thomasEmrax, { - carNumber: 0, + carNumber: car25.wbsElement.carNumber, projectNumber: 1, workPackageNumber: 0 }, @@ -2333,7 +2333,7 @@ const performSeed: () => Promise = async () => { 'Resistor', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', { - carNumber: 0, + carNumber: car25.wbsElement.carNumber, projectNumber: 1, workPackageNumber: 0 }, @@ -2356,7 +2356,7 @@ const performSeed: () => Promise = async () => { 'Resistor', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', { - carNumber: 0, + carNumber: car25.wbsElement.carNumber, projectNumber: 1, workPackageNumber: 0 }, @@ -2379,7 +2379,7 @@ const performSeed: () => Promise = async () => { 'Resistor', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', { - carNumber: 0, + carNumber: car25.wbsElement.carNumber, projectNumber: 1, workPackageNumber: 0 }, @@ -2406,7 +2406,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/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index 16cea1b594..961efb25de 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -93,7 +93,7 @@ export default class ChangeRequestsService { where: { dateDeleted: null, organizationId: organization.organizationId, - ...(carId && { wbsElement: { project: { carId } } }) + ...(carId && { wbsElement: { OR: [{ project: { carId } }, { workPackage: { project: { carId } } }] } }) }, ...getManyChangeRequestQueryArgs(organization.organizationId) }); @@ -153,7 +153,7 @@ export default class ChangeRequestsService { { NOT: [{ scopeChangeRequest: null }, { submitterId: user.userId }] }, - ...(carId ? [{ wbsElement: { project: { carId } } }] : []) + ...(carId ? [{ wbsElement: { OR: [{ project: { carId } }, { workPackage: { project: { carId } } }] } }] : []) ], organizationId: organization.organizationId, OR: queryOr @@ -191,7 +191,11 @@ export default class ChangeRequestsService { if (wbsnum) queryAnd.push({ wbsElementId: (await validateWbsElement(wbsnum, organization)).wbsElementId }); else { queryAnd.push({ submitterId: user.userId }); - queryAnd.push(...(carId ? [{ wbsElement: { project: { carId } } }] : [])); + queryAnd.push( + ...(carId + ? [{ wbsElement: { OR: [{ project: { carId } }, { workPackage: { project: { carId } } }] } }] + : []) + ); } const changeRequests = await prisma.change_Request.findMany({ @@ -224,7 +228,12 @@ export default class ChangeRequestsService { 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 }, ...(carId ? [{ wbsElement: { project: { carId } } }] : [])]; + : [ + { submitterId: user.userId }, + ...(carId + ? [{ wbsElement: { OR: [{ project: { carId } }, { workPackage: { project: { carId } } }] } }] + : []) + ]; const changeRequests = await prisma.change_Request.findMany({ where: { diff --git a/system-tests/cypress.config.js b/system-tests/cypress.config.js index d349927eaf..cbe2689ab4 100644 --- a/system-tests/cypress.config.js +++ b/system-tests/cypress.config.js @@ -11,7 +11,6 @@ module.exports = { defaultCommandTimeout: 10000 }, env: { - base_url: 'http://localhost:3000', - backend_url: 'http://localhost:3001' + base_url: 'http://localhost:3000' } }; 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 34f72e2018..f419378d93 100644 --- a/system-tests/cypress/support/commands.js +++ b/system-tests/cypress/support/commands.js @@ -18,16 +18,6 @@ Cypress.Commands.add('login', (username = 'Thomas Emrax', redirect = '/home') => cy.contains(username).click(); cy.get(LOGIN_ICON).click(); cy.waitForLoading(); - - // set the car filter to Fergus (carNumber 0) where all seed data lives, - // so GlobalCarFilterProvider grabs it from sessionStorage on first mount - cy.request(Cypress.env('backend_url') + '/cars').then((response) => { - const fergus = response.body.find((car) => car.wbsNum.carNumber === 0); - if (fergus) { - cy.window().then((win) => win.sessionStorage.setItem('selectedCarId', fergus.id)); - } - }); - 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 = [ From 941ddd6228c6ea875fdee11fcf6749b10469346d Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Sat, 21 Mar 2026 13:48:00 -0400 Subject: [PATCH 35/40] style: fix styling issue --- src/backend/src/services/change-requests.services.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index 961efb25de..b62b3cc17f 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -192,9 +192,7 @@ export default class ChangeRequestsService { else { queryAnd.push({ submitterId: user.userId }); queryAnd.push( - ...(carId - ? [{ wbsElement: { OR: [{ project: { carId } }, { workPackage: { project: { carId } } }] } }] - : []) + ...(carId ? [{ wbsElement: { OR: [{ project: { carId } }, { workPackage: { project: { carId } } }] } }] : []) ); } @@ -230,9 +228,7 @@ export default class ChangeRequestsService { ? [{ wbsElementId: (await validateWbsElement(wbsnum, organization)).wbsElementId }] : [ { submitterId: user.userId }, - ...(carId - ? [{ wbsElement: { OR: [{ project: { carId } }, { workPackage: { project: { carId } } }] } }] - : []) + ...(carId ? [{ wbsElement: { OR: [{ project: { carId } }, { workPackage: { project: { carId } } }] } }] : []) ]; const changeRequests = await prisma.change_Request.findMany({ From b1894f979ea683a4c9baa48bde9249c8c30aa57b Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Sat, 21 Mar 2026 13:57:20 -0400 Subject: [PATCH 36/40] fix: default new project car to globally selected car --- .../ProjectDetailPage/ProjectForm/ProjectFormDetails.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectFormDetails.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectFormDetails.tsx index 4aa5f88d2b..76e89e1dcf 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectFormDetails.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectFormDetails.tsx @@ -9,6 +9,7 @@ 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'; @@ -42,6 +43,7 @@ const ProjectFormDetails: React.FC = ({ setCarNumber }) => { const { data: cars, isLoading, isError, error } = useGetAllCars(); + const { selectedCar } = useGlobalCarFilter(); if (isLoading || !cars) { return ; @@ -76,7 +78,7 @@ const ProjectFormDetails: React.FC = ({ ( { - setCarNumber(e.target.value as number); - onChange(e); - }} - > - { - // reverse to show most recent cars first - cars.toReversed().map((car) => ( - - {car.name} - - )) - } - - )} - > - - - - - - - - + + + + + )} From 8ace462567adf60fdd9ef6fcb036a189760c2a0d Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Sat, 21 Mar 2026 22:09:31 -0400 Subject: [PATCH 38/40] feat: fix stale car filter and add all cars selection option --- src/frontend/src/app/AppAuthenticated.tsx | 11 +- src/frontend/src/app/AppContext.tsx | 5 +- .../src/app/AppGlobalCarFilterContext.tsx | 40 +++--- .../components/GlobalCarFilterDropdown.tsx | 26 ++-- .../hooks/GlobalCarFilterContext.test.tsx | 118 ++++++++---------- src/frontend/src/utils/axios.ts | 11 +- 6 files changed, 108 insertions(+), 103 deletions(-) diff --git a/src/frontend/src/app/AppAuthenticated.tsx b/src/frontend/src/app/AppAuthenticated.tsx index 0094ddba8c..0bcb49e517 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,7 +69,9 @@ const AppAuthenticated: React.FC = ({ userId, userRole }) return ; } - return userSettingsData.slackId || isGuest(userRole) ? ( + return ( + + {userSettingsData.slackId || isGuest(userRole) ? ( { <> @@ -136,8 +139,10 @@ const AppAuthenticated: React.FC = ({ userId, userRole }) - ) : ( - + ) : ( + + )} + ); }; diff --git a/src/frontend/src/app/AppContext.tsx b/src/frontend/src/app/AppContext.tsx index d0b63b9e05..98343cfcb2 100644 --- a/src/frontend/src/app/AppContext.tsx +++ b/src/frontend/src/app/AppContext.tsx @@ -8,7 +8,6 @@ import AppContextQuery from './AppContextQuery'; import AppContextTheme from './AppContextTheme'; import AppContextOrganization from './AppOrganizationContext'; import { HomePageProvider } from './HomePageContext'; -import { GlobalCarFilterProvider } from './AppGlobalCarFilterContext'; const AppContext: React.FC = (props) => { return ( @@ -16,9 +15,7 @@ const AppContext: React.FC = (props) => { - - {props.children} - + {props.children} diff --git a/src/frontend/src/app/AppGlobalCarFilterContext.tsx b/src/frontend/src/app/AppGlobalCarFilterContext.tsx index 79bfabb1e1..d724e61c64 100644 --- a/src/frontend/src/app/AppGlobalCarFilterContext.tsx +++ b/src/frontend/src/app/AppGlobalCarFilterContext.tsx @@ -5,12 +5,13 @@ import React, { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react'; import { Car } from 'shared'; -import { useGetCurrentCar, useGetAllCars } from '../hooks/cars.hooks'; +import { useGetAllCars } from '../hooks/cars.hooks'; +import { setCurrentCarId } from '../utils/axios'; interface GlobalCarFilterContextType { selectedCar: Car | null; allCars: Car[]; - setSelectedCar: (car: Car) => void; + setSelectedCar: (car: Car | null) => void; isLoading: boolean; error: Error | null; } @@ -25,39 +26,34 @@ export const GlobalCarFilterProvider: React.FC = ( const [selectedCar, setSelectedCarState] = useState(null); const hasInitialized = useRef(false); - const { data: currentCar, isLoading: currentCarLoading, error: currentCarError } = useGetCurrentCar(); - const { data: allCars = [], isLoading: allCarsLoading, error: allCarsError } = useGetAllCars(); - - const isLoading = currentCarLoading || allCarsLoading; - const error = currentCarError || allCarsError; + const { data: allCars = [], isLoading, error } = useGetAllCars(); useEffect(() => { - if (!isLoading && allCars.length > 0 && !hasInitialized.current) { + if (!isLoading && !hasInitialized.current) { hasInitialized.current = true; - const savedCarId = sessionStorage.getItem('selectedCarId'); - if (savedCarId) { - const savedCar = allCars.find((car) => car.id === savedCarId); + const savedCarName = sessionStorage.getItem('selectedCarName'); + if (savedCarName) { + const savedCar = allCars.find((car) => car.name === savedCarName); if (savedCar) { setSelectedCar(savedCar); return; } } - if (currentCar) { - setSelectedCar(currentCar); - } else { - const mostRecentCar = allCars.reduce((latest, car) => - car.wbsNum.carNumber > latest.wbsNum.carNumber ? car : latest - ); - setSelectedCar(mostRecentCar); - } + // Default to null (all cars) + setSelectedCarState(null); } - }, [currentCar, allCars, isLoading]); + }, [allCars, isLoading]); - const setSelectedCar = (car: Car) => { + const setSelectedCar = (car: Car | null) => { setSelectedCarState(car); - sessionStorage.setItem('selectedCarId', car.id); + setCurrentCarId(car ? car.id : null); + if (car) { + sessionStorage.setItem('selectedCarName', car.name); + } else { + sessionStorage.removeItem('selectedCarName'); + } }; const value: GlobalCarFilterContextType = { diff --git a/src/frontend/src/components/GlobalCarFilterDropdown.tsx b/src/frontend/src/components/GlobalCarFilterDropdown.tsx index 069174fa29..bc0e0b7494 100644 --- a/src/frontend/src/components/GlobalCarFilterDropdown.tsx +++ b/src/frontend/src/components/GlobalCarFilterDropdown.tsx @@ -6,6 +6,7 @@ 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'; @@ -22,7 +23,7 @@ const GlobalCarFilterDropdown: React.FC = ({ compa setExpanded(!expanded); }; - const handleCarSelect = (car: any) => { + const handleCarSelect = (car: Car | null) => { setSelectedCar(car); setExpanded(false); }; @@ -53,7 +54,7 @@ const GlobalCarFilterDropdown: React.FC = ({ compa const sortedCars = [...allCars].sort((a, b) => b.wbsNum.carNumber - a.wbsNum.carNumber); - const currentCarLabel = selectedCar ? selectedCar.name : 'Select Car'; + const currentCarLabel = selectedCar ? selectedCar.name : 'All Cars'; if (compact) { return ( @@ -102,13 +103,26 @@ const GlobalCarFilterDropdown: React.FC = ({ compa } }} > + 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 carLabel = car.name; const isSelected = selectedCar ? car.id === selectedCar.id : false; return ( handleCarSelect(car)} variant="outlined" sx={{ @@ -117,9 +131,7 @@ const GlobalCarFilterDropdown: React.FC = ({ compa backgroundColor: 'transparent', fontWeight: isSelected ? 'bold' : 'normal', borderWidth: isSelected ? 2 : 1, - '&:hover': { - backgroundColor: 'rgba(255,255,255,0.1)' - }, + '&:hover': { backgroundColor: 'rgba(255,255,255,0.1)' }, whiteSpace: 'nowrap' }} /> diff --git a/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx b/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx index b5a5042512..01bf025936 100644 --- a/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx +++ b/src/frontend/src/tests/hooks/GlobalCarFilterContext.test.tsx @@ -3,15 +3,14 @@ * See the LICENSE file in the repository root folder for details. */ -import { renderHook, waitFor } from '@testing-library/react'; +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, exampleCurrentCar } from '../test-support/test-data/cars.stub'; +import { exampleAllCars } from '../test-support/test-data/cars.stub'; // Mock the hooks vi.mock('../../hooks/cars.hooks'); -const mockUseGetCurrentCar = vi.mocked(carsHooks.useGetCurrentCar); const mockUseGetAllCars = vi.mocked(carsHooks.useGetAllCars); // Create wrapper with providers @@ -32,20 +31,11 @@ const createWrapper = () => { describe('useGlobalCarFilter', () => { beforeEach(() => { - // Clear session storage sessionStorage.clear(); - - // Reset mocks vi.clearAllMocks(); }); - it('should initialize with current car when available', async () => { - mockUseGetCurrentCar.mockReturnValue({ - data: exampleCurrentCar, - isLoading: false, - error: null - } as any); - + it('should initialize with null when no saved car name in session storage', async () => { mockUseGetAllCars.mockReturnValue({ data: exampleAllCars, isLoading: false, @@ -57,20 +47,15 @@ describe('useGlobalCarFilter', () => { }); await waitFor(() => { - expect(result.current.selectedCar).toEqual(exampleCurrentCar); + expect(result.current.isLoading).toBe(false); }); + expect(result.current.selectedCar).toBeNull(); expect(result.current.allCars).toEqual(exampleAllCars); - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBeNull(); }); - it('should initialize with most recent car when no current car', async () => { - mockUseGetCurrentCar.mockReturnValue({ - data: null, - isLoading: false, - error: null - } as any); + it('should restore car from session storage by name', async () => { + sessionStorage.setItem('selectedCarName', exampleAllCars[0].name); mockUseGetAllCars.mockReturnValue({ data: exampleAllCars, @@ -83,20 +68,31 @@ describe('useGlobalCarFilter', () => { }); await waitFor(() => { - expect(result.current.selectedCar).toEqual(exampleAllCars[2]); // Car 2025 has highest car number + expect(result.current.selectedCar).toEqual(exampleAllCars[0]); }); }); - it('should restore car from session storage', async () => { - // Set session storage - sessionStorage.setItem('selectedCarId', exampleAllCars[0].id); + it('should default to null when saved car name does not match any car', async () => { + sessionStorage.setItem('selectedCarName', 'NER-Nonexistent'); - mockUseGetCurrentCar.mockReturnValue({ - data: exampleCurrentCar, + 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, @@ -108,16 +104,19 @@ describe('useGlobalCarFilter', () => { }); await waitFor(() => { - expect(result.current.selectedCar).toEqual(exampleAllCars[0]); // Car 2023 + 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 persist car selection to session storage', async () => { - mockUseGetCurrentCar.mockReturnValue({ - data: exampleCurrentCar, - isLoading: false, - error: null - } as any); + it('should clear session storage when selecting null (all cars)', async () => { + sessionStorage.setItem('selectedCarName', exampleAllCars[0].name); mockUseGetAllCars.mockReturnValue({ data: exampleAllCars, @@ -130,22 +129,18 @@ describe('useGlobalCarFilter', () => { }); await waitFor(() => { - expect(result.current.selectedCar).toBeTruthy(); + expect(result.current.selectedCar).toEqual(exampleAllCars[0]); }); - // Change selection - result.current.setSelectedCar(exampleAllCars[1]); + act(() => { + result.current.setSelectedCar(null); + }); - expect(sessionStorage.getItem('selectedCarId')).toBe(exampleAllCars[1].id); + expect(sessionStorage.getItem('selectedCarName')).toBeNull(); + expect(result.current.selectedCar).toBeNull(); }); it('should handle loading state', () => { - mockUseGetCurrentCar.mockReturnValue({ - data: undefined, - isLoading: true, - error: null - } as any); - mockUseGetAllCars.mockReturnValue({ data: undefined, isLoading: true, @@ -163,16 +158,10 @@ describe('useGlobalCarFilter', () => { it('should handle error state', () => { const error = new Error('Failed to load cars'); - mockUseGetCurrentCar.mockReturnValue({ - data: undefined, - isLoading: false, - error - } as any); - mockUseGetAllCars.mockReturnValue({ data: undefined, isLoading: false, - error: null + error } as any); const { result } = renderHook(() => useGlobalCarFilter(), { @@ -183,13 +172,7 @@ describe('useGlobalCarFilter', () => { expect(result.current.isLoading).toBe(false); }); - it('should update session storage when switching cars', async () => { - mockUseGetCurrentCar.mockReturnValue({ - data: exampleCurrentCar, - isLoading: false, - error: null - } as any); - + it('should update session storage when switching between cars', async () => { mockUseGetAllCars.mockReturnValue({ data: exampleAllCars, isLoading: false, @@ -201,15 +184,20 @@ describe('useGlobalCarFilter', () => { }); await waitFor(() => { - expect(result.current.selectedCar).toBeTruthy(); + expect(result.current.isLoading).toBe(false); }); - // Switch to a different car - result.current.setSelectedCar(exampleAllCars[0]); + act(() => { + result.current.setSelectedCar(exampleAllCars[0]); + }); - expect(sessionStorage.getItem('selectedCarId')).toBe(exampleAllCars[0].id); - await waitFor(() => { - expect(result.current.selectedCar).toEqual(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/utils/axios.ts b/src/frontend/src/utils/axios.ts index 30fb72ba93..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,8 +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 ?? ''; - const carId = sessionStorage.getItem('selectedCarId'); - request.headers!['carId'] = carId ?? ''; + if (currentCarId) request.headers!['carId'] = currentCarId; return request; }, (error) => { From 1faa0567e62cfa5fced2307b6e885bcbaa83a97e Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Sat, 21 Mar 2026 22:32:15 -0400 Subject: [PATCH 39/40] feat: show car selector in project form when global filter is 'all cars' --- src/frontend/src/app/AppAuthenticated.tsx | 134 +++++++++--------- .../ProjectForm/ProjectFormDetails.tsx | 29 +++- 2 files changed, 93 insertions(+), 70 deletions(-) diff --git a/src/frontend/src/app/AppAuthenticated.tsx b/src/frontend/src/app/AppAuthenticated.tsx index 0bcb49e517..4e1f1b56e3 100644 --- a/src/frontend/src/app/AppAuthenticated.tsx +++ b/src/frontend/src/app/AppAuthenticated.tsx @@ -72,73 +72,73 @@ const AppAuthenticated: React.FC = ({ userId, userRole }) 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" - > - - - - - } - - - - - - - - - - - - - - - - - - - - - - - - + + { + <> + { + 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/pages/ProjectDetailPage/ProjectForm/ProjectFormDetails.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectFormDetails.tsx index 028b626365..482225eb64 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectFormDetails.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectForm/ProjectFormDetails.tsx @@ -1,10 +1,10 @@ import { Project, User } from 'shared'; -import { Box, FormControl, FormLabel, Grid, 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'; import { ProjectFormInput } from './ProjectForm'; -import { Control, FieldErrorsImpl } from 'react-hook-form'; +import { Control, Controller, FieldErrorsImpl } from 'react-hook-form'; import { AttachMoney } from '@mui/icons-material'; import TeamDropdown from '../../../components/TeamsDropdown'; import ChangeRequestDropdown from '../../../components/ChangeRequestDropdown'; @@ -38,12 +38,14 @@ const ProjectFormDetails: React.FC = ({ setLeadId, setManagerId }) => { - const { isLoading: carFilterIsLoading } = useGlobalCarFilter(); + const { selectedCar, allCars, isLoading: carFilterIsLoading } = useGlobalCarFilter(); if (carFilterIsLoading) { return ; } + const sortedCars = [...allCars].sort((a, b) => b.wbsNum.carNumber - a.wbsNum.carNumber); + return ( @@ -61,6 +63,27 @@ const ProjectFormDetails: React.FC = ({ /> + {!project && !selectedCar && ( + + + Car + ( + + {sortedCars.map((car) => ( + + {car.name} + + ))} + + )} + /> + {errors.carNumber?.message} + + + )} {!project && ( From 99837c49c45d61b047c93afd181c0f15bc183757 Mon Sep 17 00:00:00 2001 From: Raphael Bessin Date: Sat, 21 Mar 2026 22:50:00 -0400 Subject: [PATCH 40/40] fix: mock car filter in unit tests and restore it in e2e login --- .../src/tests/layouts/Sidebar/Sidebar.test.tsx | 10 ++++++++++ .../ChangeRequestDetailsView.test.tsx | 9 +++++++++ src/frontend/src/tests/pages/HomePage/Home.test.tsx | 10 ++++++++++ system-tests/cypress/support/commands.js | 3 +++ 4 files changed, 32 insertions(+) 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/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(); });