diff --git a/src/backend/src/controllers/teams.controllers.ts b/src/backend/src/controllers/teams.controllers.ts index 881934896f..e12e36954b 100644 --- a/src/backend/src/controllers/teams.controllers.ts +++ b/src/backend/src/controllers/teams.controllers.ts @@ -1,5 +1,6 @@ import { NextFunction, Request, Response } from 'express'; import TeamsService from '../services/teams.services'; +import { HttpException } from '../utils/errors.utils'; export default class TeamsController { static async getAllTeams(req: Request, res: Response, next: NextFunction) { @@ -114,12 +115,14 @@ export default class TeamsController { } } - static async createTeamType(req: Request, res: Response, next: NextFunction) { + static async setTeamType(req: Request, res: Response, next: NextFunction) { try { - const { name, iconName } = req.body; + const { teamTypeId } = req.body; + const { teamId } = req.params; - const createdTeamType = await TeamsService.createTeamType(req.currentUser, name, iconName, req.organization); - return res.status(200).json(createdTeamType); + const updatedTeam = await TeamsService.setTeamType(req.currentUser, teamId, teamTypeId, req.organization); + + return res.status(200).json(updatedTeam); } catch (error: unknown) { return next(error); } @@ -146,14 +149,47 @@ export default class TeamsController { } } - static async setTeamType(req: Request, res: Response, next: NextFunction) { + static async createTeamType(req: Request, res: Response, next: NextFunction) { try { - const { teamTypeId } = req.body; - const { teamId } = req.params; + const { name, iconName, description } = req.body; - const updatedTeam = await TeamsService.setTeamType(req.currentUser, teamId, teamTypeId, req.organization); + const createdTeamType = await TeamsService.createTeamType( + req.currentUser, + name, + iconName, + description, + req.organization + ); + res.status(200).json(createdTeamType); + } catch (error: unknown) { + next(error); + } + } - return res.status(200).json(updatedTeam); + static async editTeamType(req: Request, res: Response, next: NextFunction) { + try { + const { name, iconName, description } = req.body; + + const teamType = await TeamsService.editTeamType( + req.currentUser, + req.params.teamTypeId, + name, + iconName, + description, + req.organization + ); + res.status(200).json(teamType); + } catch (error: unknown) { + next(error); + } + } + + static async setTeamTypeImage(req: Request, res: Response, next: NextFunction) { + try { + const { file } = req; + if (!file) throw new HttpException(400, 'Invalid or undefined image data'); + const teamType = await TeamsService.setTeamTypeImage(req.currentUser, req.params.teamTypeId, file, req.organization); + res.status(200).json(teamType); } catch (error: unknown) { return next(error); } diff --git a/src/backend/src/prisma/migrations/20240723190716_added_description_and_image_to_team_type/migration.sql b/src/backend/src/prisma/migrations/20240723190716_added_description_and_image_to_team_type/migration.sql new file mode 100644 index 0000000000..2118cefe95 --- /dev/null +++ b/src/backend/src/prisma/migrations/20240723190716_added_description_and_image_to_team_type/migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE "Team_Type" +ADD COLUMN "description" TEXT NOT NULL DEFAULT 'Default description', +ADD COLUMN "imageFileId" TEXT; + +ALTER TABLE "Team_Type" +ALTER COLUMN "description" DROP DEFAULT; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index 2c7863e013..29d9da3334 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -710,6 +710,8 @@ model Team_Type { iconName String designReviews Design_Review[] team Team[] + description String + imageFileId String? organizationId String organization Organization @relation(fields: [organizationId], references: [organizationId]) calendarId String? diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 5a583c2e38..5a89606169 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -257,9 +257,9 @@ const performSeed: () => Promise = async () => { * TEAMS */ /** Creating Team Types */ - const teamType1 = await TeamsService.createTeamType(batman, 'Mechanical', 'YouTubeIcon', ner); - const teamType2 = await TeamsService.createTeamType(thomasEmrax, 'Software', 'InstagramIcon', ner); - const teamType3 = await TeamsService.createTeamType(cyborg, 'Electrical', 'SettingsIcon', ner); + const teamType1 = await TeamsService.createTeamType(batman, 'Mechanical', 'YouTubeIcon', '', ner); + const teamType2 = await TeamsService.createTeamType(thomasEmrax, 'Software', 'InstagramIcon', '', ner); + const teamType3 = await TeamsService.createTeamType(cyborg, 'Electrical', 'SettingsIcon', '', ner); /** Creating Teams */ const justiceLeague: Team = await prisma.team.create(dbSeedAllTeams.justiceLeague(batman.userId, organizationId)); diff --git a/src/backend/src/routes/teams.routes.ts b/src/backend/src/routes/teams.routes.ts index bf3f237c66..c75a02f91f 100644 --- a/src/backend/src/routes/teams.routes.ts +++ b/src/backend/src/routes/teams.routes.ts @@ -2,8 +2,10 @@ import express from 'express'; import TeamsController from '../controllers/teams.controllers'; import { body } from 'express-validator'; import { nonEmptyString, validateInputs } from '../utils/validation.utils'; +import multer, { memoryStorage } from 'multer'; const teamsRouter = express.Router(); +const upload = multer({ limits: { fileSize: 30000000 }, storage: memoryStorage() }); teamsRouter.get('/', TeamsController.getAllTeams); teamsRouter.get('/:teamId', TeamsController.getSingleTeam); @@ -27,6 +29,7 @@ teamsRouter.post( validateInputs, TeamsController.editDescription ); + teamsRouter.post('/:teamId/set-head', nonEmptyString(body('userId')), validateInputs, TeamsController.setTeamHead); teamsRouter.post('/:teamId/delete', TeamsController.deleteTeam); teamsRouter.post( @@ -47,13 +50,31 @@ teamsRouter.get('/teamType/all', TeamsController.getAllTeamTypes); teamsRouter.get('/teamType/:teamTypeId/single', TeamsController.getSingleTeamType); +teamsRouter.post('/:teamId/set-team-type', nonEmptyString(body('teamTypeId')), validateInputs, TeamsController.setTeamType); + teamsRouter.post( '/teamType/create', nonEmptyString(body('name')), nonEmptyString(body('iconName')), + nonEmptyString(body('description')), validateInputs, TeamsController.createTeamType ); -teamsRouter.post('/:teamId/set-team-type', nonEmptyString(body('teamTypeId')), validateInputs, TeamsController.setTeamType); +teamsRouter.post( + '/teamType/:teamTypeId/edit', + nonEmptyString(body('name')), + nonEmptyString(body('iconName')), + nonEmptyString(body('description')), + validateInputs, + TeamsController.editTeamType +); + +teamsRouter.post( + '/teamType/:teamTypeId/set-image', + upload.single('image'), + validateInputs, + TeamsController.setTeamTypeImage +); + export default teamsRouter; diff --git a/src/backend/src/services/teams.services.ts b/src/backend/src/services/teams.services.ts index 58ae474b93..20a2578e74 100644 --- a/src/backend/src/services/teams.services.ts +++ b/src/backend/src/services/teams.services.ts @@ -13,6 +13,7 @@ import { getPrismaQueryUserIds, getUsers, userHasPermission } from '../utils/use import { isUnderWordCount } from 'shared'; import { removeUsersFromList } from '../utils/teams.utils'; import { getTeamQueryArgs } from '../prisma-query-args/teams.query-args'; +import { uploadFile } from '../utils/google-integration.utils'; import { createCalendar } from '../utils/google-integration.utils'; export default class TeamsService { @@ -387,6 +388,7 @@ export default class TeamsService { submitter: User, name: string, iconName: string, + description: string, organization: Organization ): Promise { if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) { @@ -406,6 +408,7 @@ export default class TeamsService { data: { name, iconName, + description, organizationId: organization.organizationId, calendarId: teamTypeCalendarId } @@ -442,6 +445,50 @@ export default class TeamsService { return teamTypes; } + /** + * Changes the description of the given teamType to be the new description + * @param user The user who is editing the description + * @param teamTypeId The id for the teamType that is being edited + * @param name the new name for the team + * @param iconName the new icon name for the team + * @param description the new description for the team + * @param imageFileId the new image for the team + * @param organizationId The organization the user is currently in + * @returns The team with the new description + */ + static async editTeamType( + user: User, + teamTypeId: string, + name: string, + iconName: string, + description: string, + organization: Organization + ): Promise { + if (!isUnderWordCount(description, 300)) throw new HttpException(400, 'Description must be less than 300 words'); + + if (!(await userHasPermission(user.userId, organization.organizationId, isAdmin))) + throw new AccessDeniedException('you must be an admin to edit the team types description'); + + const currentTeamType = await prisma.team_Type.findUnique({ + where: { teamTypeId } + }); + + if (!currentTeamType) { + throw new NotFoundException('Team Type', teamTypeId); + } + + const updatedTeamType = await prisma.team_Type.update({ + where: { teamTypeId }, + data: { + name, + iconName, + description + } + }); + + return updatedTeamType; + } + /** * Sets the teamType for a team * @param submitter the user who is setting the team type @@ -482,4 +529,36 @@ export default class TeamsService { return teamTransformer(updatedTeam); } + + static async setTeamTypeImage( + submitter: User, + teamTypeId: string, + image: Express.Multer.File, + organization: Organization + ) { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) { + throw new AccessDeniedAdminOnlyException('set a team types image'); + } + + const teamType = await prisma.team_Type.findUnique({ + where: { + teamTypeId + } + }); + + if (!teamType) throw new NotFoundException('Team Type', teamTypeId); + + const imageData = await uploadFile(image); + + const updatedTeamType = await prisma.team_Type.update({ + where: { + teamTypeId + }, + data: { + imageFileId: imageData.id + } + }); + + return updatedTeamType; + } } diff --git a/src/backend/src/utils/google-integration.utils.ts b/src/backend/src/utils/google-integration.utils.ts index 95492860c4..cbb8a90f60 100644 --- a/src/backend/src/utils/google-integration.utils.ts +++ b/src/backend/src/utils/google-integration.utils.ts @@ -122,15 +122,12 @@ export const uploadFile = async (fileObject: Express.Multer.File) => { const gError = error as GoogleDriveError; throw new HttpException( gError.code, - `Failed to Upload Receipt(s): ${gError.message}, ${gError.errors.reduce( - (acc: string, curr: GoogleDriveErrorListError) => { - return acc + ' ' + curr.message + ' ' + curr.reason; - }, - '' - )}` + `Failed to Upload : ${gError.message}, ${gError.errors.reduce((acc: string, curr: GoogleDriveErrorListError) => { + return acc + ' ' + curr.message + ' ' + curr.reason; + }, '')}` ); } else if (error instanceof Error) { - throw new HttpException(500, `Failed to Upload Receipt(s): ${error.message}`); + throw new HttpException(500, `Failed to Upload : ${error.message}`); } console.log('error' + error); throw error; diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index cd823bdc73..4d1c6886ce 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -384,7 +384,9 @@ export const createTestDesignReview = async () => { if (!head) throw new Error('Failed to find user'); if (!lead) throw new Error('Failed to find user'); await createTestProject(head, organization.organizationId); - const teamType = await TeamsService.createTeamType(head, 'Team1', 'Software', organization); + + const teamType = await TeamsService.createTeamType(head, 'Team1', 'Software', 'Software team', organization); + const { designReviewId } = await DesignReviewsService.createDesignReview( lead, '03/25/2027', diff --git a/src/backend/tests/unmocked/team-type.test.ts b/src/backend/tests/unmocked/team-type.test.ts index e422bf701e..7c235579d0 100644 --- a/src/backend/tests/unmocked/team-type.test.ts +++ b/src/backend/tests/unmocked/team-type.test.ts @@ -1,8 +1,23 @@ import { Organization } from '@prisma/client'; import TeamsService from '../../src/services/teams.services'; -import { AccessDeniedAdminOnlyException, HttpException, NotFoundException } from '../../src/utils/errors.utils'; +import { + AccessDeniedAdminOnlyException, + AccessDeniedException, + HttpException, + NotFoundException +} from '../../src/utils/errors.utils'; import { batmanAppAdmin, supermanAdmin, wonderwomanGuest } from '../test-data/users.test-data'; import { createTestOrganization, createTestUser, resetUsers } from '../test-utils'; +import { vi } from 'vitest'; + +vi.mock('../../src/utils/google-integration.utils', async () => { + const actual = await vi.importActual('../../src/utils/google-integration.utils'); + return { + actual, + uploadFile: vi.fn(), + createCalendar: vi.fn().mockResolvedValue('mocked-calendar-id') + }; +}); describe('Team Type Tests', () => { let orgId: string; @@ -24,6 +39,7 @@ describe('Team Type Tests', () => { await createTestUser(wonderwomanGuest, orgId), 'Team 2', 'Warning icon', + '', organization ) ).rejects.toThrow(new AccessDeniedAdminOnlyException('create a team type')); @@ -34,14 +50,17 @@ describe('Team Type Tests', () => { await createTestUser(supermanAdmin, orgId), 'teamType1', 'YouTubeIcon', + '', organization ); + await expect( async () => await TeamsService.createTeamType( await createTestUser(batmanAppAdmin, orgId), 'teamType1', 'Warning icon', + '', organization ) ).rejects.toThrow(new HttpException(400, 'Cannot create a teamType with a name that already exists')); @@ -52,31 +71,36 @@ describe('Team Type Tests', () => { await createTestUser(supermanAdmin, orgId), 'teamType3', 'YouTubeIcon', + '', organization ); expect(result).toEqual({ name: 'teamType3', iconName: 'YouTubeIcon', + description: '', + imageFileId: null, organizationId: orgId, teamTypeId: result.teamTypeId, - calendarId: null + calendarId: 'mocked-calendar-id' }); }); }); - describe('Get all team types works', () => { + describe('Get all team types', () => { it('Get all team types works', async () => { const teamType1 = await TeamsService.createTeamType( await createTestUser(supermanAdmin, orgId), 'teamType1', 'YouTubeIcon', + '', organization ); const teamType2 = await TeamsService.createTeamType( await createTestUser(batmanAppAdmin, orgId), 'teamType2', 'WarningIcon', + '', organization ); const result = await TeamsService.getAllTeamTypes(organization); @@ -90,6 +114,7 @@ describe('Team Type Tests', () => { await createTestUser(supermanAdmin, orgId), 'teamType1', 'YouTubeIcon', + '', organization ); const result = await TeamsService.getSingleTeamType(teamType1.teamTypeId, organization); @@ -103,4 +128,55 @@ describe('Team Type Tests', () => { ); }); }); + + describe('Edit team type', () => { + it('fails if user is not an admin', async () => { + await expect( + async () => + await TeamsService.editTeamType( + await createTestUser(wonderwomanGuest, orgId), + 'id', + 'new name', + 'new icon', + 'new description', + organization + ) + ).rejects.toThrow(new AccessDeniedException('you must be an admin to edit the team types description')); + }); + + it('fails if the new description is over 300 workds', async () => { + await expect( + async () => + await TeamsService.editTeamType( + await createTestUser(supermanAdmin, orgId), + 'id', + 'new name', + 'new icon', + 'a '.repeat(301), + organization + ) + ).rejects.toThrow(new HttpException(400, 'Description must be less than 300 words')); + }); + + it('succeds and updates the team type', async () => { + const teamType = await TeamsService.createTeamType( + await createTestUser(supermanAdmin, orgId), + 'teamType1', + 'YouTubeIcon', + '', + organization + ); + const updatedTeamType = await TeamsService.editTeamType( + await createTestUser(batmanAppAdmin, orgId), + teamType.teamTypeId, + 'new name', + 'new icon', + 'new description', + organization + ); + expect(updatedTeamType.name).toBe('new name'); + expect(updatedTeamType.iconName).toBe('new icon'); + expect(updatedTeamType.description).toBe('new description'); + }); + }); }); diff --git a/src/frontend/src/apis/design-reviews.api.ts b/src/frontend/src/apis/design-reviews.api.ts index 7c1f607740..269f1e9fba 100644 --- a/src/frontend/src/apis/design-reviews.api.ts +++ b/src/frontend/src/apis/design-reviews.api.ts @@ -2,7 +2,7 @@ * 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 { CreateTeamTypePayload, EditDesignReviewPayload } from '../hooks/design-reviews.hooks'; +import { EditDesignReviewPayload } from '../hooks/design-reviews.hooks'; import axios from '../utils/axios'; import { AvailabilityCreateArgs, DesignReview, DesignReviewStatus } from 'shared'; import { apiUrls } from '../utils/urls'; @@ -30,19 +30,6 @@ export const getAllDesignReviews = () => { }); }; -/** - * Gets all the team types - */ -export const getAllTeamTypes = () => { - return axios.get(apiUrls.allTeamTypes(), { - transformResponse: (data) => JSON.parse(data) - }); -}; - -export const createTeamType = async (payload: CreateTeamTypePayload) => { - return axios.post(apiUrls.teamTypesCreate(), payload); -}; - /** * Edit a design review * diff --git a/src/frontend/src/apis/team-types.api.ts b/src/frontend/src/apis/team-types.api.ts index 48507686d0..6b805f109e 100644 --- a/src/frontend/src/apis/team-types.api.ts +++ b/src/frontend/src/apis/team-types.api.ts @@ -3,11 +3,33 @@ * See the LICENSE file in the repository root folder for details. */ +import { TeamType } from 'shared'; import axios from '../utils/axios'; import { apiUrls } from '../utils/urls'; +import { CreateTeamTypePayload } from '../hooks/team-types.hooks'; + +export const getAllTeamTypes = () => { + return axios.get(apiUrls.allTeamTypes(), { + transformResponse: (data) => JSON.parse(data) + }); +}; export const setTeamType = (id: string, teamTypeId: string) => { return axios.post<{ message: string }>(apiUrls.teamsSetTeamType(id), { teamTypeId }); }; + +export const createTeamType = (payload: CreateTeamTypePayload) => { + return axios.post(apiUrls.teamTypesCreate(), payload); +}; + +export const editTeamType = (id: string, payload: CreateTeamTypePayload) => { + return axios.post(apiUrls.teamTypeEdit(id), payload); +}; + +export const setTeamTypeImage = (id: string, image: File) => { + const formData = new FormData(); + formData.append('image', image); + return axios.post(apiUrls.teamTypeSetImage(id), formData); +}; diff --git a/src/frontend/src/components/NERUploadButton.tsx b/src/frontend/src/components/NERUploadButton.tsx new file mode 100644 index 0000000000..43f0134454 --- /dev/null +++ b/src/frontend/src/components/NERUploadButton.tsx @@ -0,0 +1,80 @@ +import { Button } from '@mui/material'; +import { Box } from '@mui/system'; +import React from 'react'; +import FileUploadIcon from '@mui/icons-material/FileUpload'; + +interface NERUploadButtonProps { + dataTypeId: string; + handleFileChange: (e: React.ChangeEvent) => void; + onSubmit: (dataTypeId: string) => void; + addedImage: File | undefined; + setAddedImage: React.Dispatch>; +} + +const NERUploadButton = ({ dataTypeId, handleFileChange, onSubmit, addedImage, setAddedImage }: NERUploadButtonProps) => { + return ( + <> + + {addedImage && ( + + + + + + + + )} + + ); +}; + +export default NERUploadButton; diff --git a/src/frontend/src/hooks/design-reviews.hooks.ts b/src/frontend/src/hooks/design-reviews.hooks.ts index f33559c8d0..c801cc968c 100644 --- a/src/frontend/src/hooks/design-reviews.hooks.ts +++ b/src/frontend/src/hooks/design-reviews.hooks.ts @@ -3,16 +3,14 @@ * See the LICENSE file in the repository root folder for details. */ import { useMutation, useQuery, useQueryClient } from 'react-query'; -import { DesignReview, TeamType, WbsNumber, DesignReviewStatus, AvailabilityCreateArgs } from 'shared'; +import { DesignReview, WbsNumber, DesignReviewStatus, AvailabilityCreateArgs } from 'shared'; import { deleteDesignReview, editDesignReview, createDesignReviews, getAllDesignReviews, - getAllTeamTypes, getSingleDesignReview, markUserConfirmed, - createTeamType, setDesignReviewStatus } from '../apis/design-reviews.api'; import { useCurrentUser } from './users.hooks'; @@ -26,11 +24,6 @@ export interface CreateDesignReviewsPayload { meetingTimes: number[]; } -export interface CreateTeamTypePayload { - name: string; - iconName: string; -} - export const useCreateDesignReviews = () => { const queryClient = useQueryClient(); return useMutation( @@ -94,39 +87,6 @@ export const useEditDesignReview = (designReviewId: string) => { ); }; -/** - * Custom react hook to get all team types - * - * @returns all the team types - */ -export const useAllTeamTypes = () => { - return useQuery(['teamTypes'], async () => { - const { data } = await getAllTeamTypes(); - return data; - }); -}; - -/** - * Custom react hook to create a team type - * - * @returns the team type created - */ -export const useCreateTeamType = () => { - const queryClient = useQueryClient(); - return useMutation( - ['teamTypes', 'create'], - async (teamTypePayload) => { - const { data } = await createTeamType(teamTypePayload); - return data; - }, - { - onSuccess: () => { - queryClient.invalidateQueries(['teamTypes']); - } - } - ); -}; - /** * Custom react hook to delete a design review */ diff --git a/src/frontend/src/hooks/team-types.hooks.ts b/src/frontend/src/hooks/team-types.hooks.ts index f0ffcf05c7..e4a6e4fc7b 100644 --- a/src/frontend/src/hooks/team-types.hooks.ts +++ b/src/frontend/src/hooks/team-types.hooks.ts @@ -3,9 +3,35 @@ * See the LICENSE file in the repository root folder for details. */ -import { useQueryClient, useMutation } from 'react-query'; -import { setTeamType } from '../apis/team-types.api'; +import { useQueryClient, useMutation, useQuery } from 'react-query'; +import { createTeamType, editTeamType, getAllTeamTypes, setTeamType, setTeamTypeImage } from '../apis/team-types.api'; +import { TeamType } from 'shared'; +export interface CreateTeamTypePayload { + name: string; + iconName: string; + description: string; + image?: File; +} + +/** + * Custom react hook to get all team types + * + * @returns all the team types + */ +export const useAllTeamTypes = () => { + return useQuery(['team types'], async () => { + const { data } = await getAllTeamTypes(); + return data; + }); +}; + +/** + * Custom react hook to set the team type of a team + * + * @param teamId id of the team to set the team type + * @returns the updated team + */ export const useSetTeamType = (teamId: string) => { const queryClient = useQueryClient(); return useMutation<{ message: string }, Error, string>( @@ -21,3 +47,62 @@ export const useSetTeamType = (teamId: string) => { } ); }; + +/** + * Custom react hook to create a team type + * + * @returns the team type created + */ +export const useCreateTeamType = () => { + const queryClient = useQueryClient(); + return useMutation( + ['team types', 'create'], + async (teamTypePayload) => { + const { data } = await createTeamType(teamTypePayload); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['team types']); + } + } + ); +}; + +/** + * Custome react hook to update a team type + * + * @param teamTypeId id of the team type to edit + * @returns the updated team type + */ +export const useEditTeamType = (teamTypeId: string) => { + const queryClient = useQueryClient(); + return useMutation( + ['team types', 'edit'], + async (formData: CreateTeamTypePayload) => { + const { data } = await editTeamType(teamTypeId, formData); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['team types']); + } + } + ); +}; + +export const useSetTeamTypeImage = () => { + const queryClient = useQueryClient(); + return useMutation( + ['team types', 'set image'], + async (formData: { file: File; id: string }) => { + const { data } = await setTeamTypeImage(formData.id, formData.file); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['team types']); + } + } + ); +}; diff --git a/src/frontend/src/pages/AdminToolsPage/AdminToolsPage.tsx b/src/frontend/src/pages/AdminToolsPage/AdminToolsPage.tsx index 9cdf7d1ad1..adb4582193 100644 --- a/src/frontend/src/pages/AdminToolsPage/AdminToolsPage.tsx +++ b/src/frontend/src/pages/AdminToolsPage/AdminToolsPage.tsx @@ -43,19 +43,6 @@ const AdminToolsPage: React.FC = () => { tabs.push({ tabUrlValue: 'miscellaneous', tabName: 'Miscellaneous' }); } - const UserManagementTab = () => { - return isUserAdmin ? ( - - - - - - - ) : ( - - ); - }; - const ProjectConfigurationTab = () => { return isUserAdmin ? ( <> @@ -84,7 +71,10 @@ const AdminToolsPage: React.FC = () => { } > {tabIndex === 0 ? ( - + <> + + {isUserAdmin && } + ) : tabIndex === 1 ? ( ) : tabIndex === 2 ? ( diff --git a/src/frontend/src/pages/AdminToolsPage/TeamConfig/CreateTeamTypeFormModal.tsx b/src/frontend/src/pages/AdminToolsPage/TeamConfig/CreateTeamTypeFormModal.tsx index 0a5bfa87e2..a538f9d9a1 100644 --- a/src/frontend/src/pages/AdminToolsPage/TeamConfig/CreateTeamTypeFormModal.tsx +++ b/src/frontend/src/pages/AdminToolsPage/TeamConfig/CreateTeamTypeFormModal.tsx @@ -1,105 +1,20 @@ -import { useForm } from 'react-hook-form'; -import NERFormModal from '../../../components/NERFormModal'; -import { FormControl, FormLabel, FormHelperText, Tooltip, Typography } from '@mui/material'; -import ReactHookTextField from '../../../components/ReactHookTextField'; -import { useToast } from '../../../hooks/toasts.hooks'; +import TeamTypeFormModal from './TeamTypeFormModal'; +import ErrorPage from '../../ErrorPage'; import LoadingIndicator from '../../../components/LoadingIndicator'; -import * as yup from 'yup'; -import { yupResolver } from '@hookform/resolvers/yup'; -import { Box } from '@mui/system'; -import HelpIcon from '@mui/icons-material/Help'; -import { CreateTeamTypePayload, useCreateTeamType } from '../../../hooks/design-reviews.hooks'; -import useFormPersist from 'react-hook-form-persist'; -import { FormStorageKey } from '../../../utils/form'; +import { useCreateTeamType } from '../../../hooks/team-types.hooks'; -const schema = yup.object().shape({ - name: yup.string().required('Material Type is Required'), - iconName: yup.string().required('Icon Name is Required') -}); - -interface CreateTeamTypeModalProps { - showModal: boolean; +interface CreateTeamTypeFormModalProps { + open: boolean; handleClose: () => void; } -const CreateTeamTypeModal: React.FC = ({ showModal, handleClose }) => { - const toast = useToast(); - const { isLoading, mutateAsync } = useCreateTeamType(); - - const onSubmit = async (data: CreateTeamTypePayload) => { - try { - await mutateAsync(data); - } catch (error: unknown) { - if (error instanceof Error) { - toast.error(error.message); - } - } - handleClose(); - }; - - const { - handleSubmit, - control, - reset, - formState: { errors }, - watch, - setValue - } = useForm({ - resolver: yupResolver(schema), - defaultValues: { - name: '', - iconName: '' - } - }); - - useFormPersist(FormStorageKey.CREATE_TEAM_TYPE, { - watch, - setValue - }); +const CreateTeamTypeFormModal = ({ open, handleClose }: CreateTeamTypeFormModalProps) => { + const { isLoading, isError, error, mutateAsync } = useCreateTeamType(); + if (isError) return ; if (isLoading) return ; - const TooltipMessage = () => ( - - Click to view possible icon names. For names with multiple words, seperate them with an _. AttachMoney = attach_money - - ); - - return ( - reset({ name: '', iconName: '' })} - handleUseFormSubmit={handleSubmit} - onFormSubmit={onSubmit} - formId="new-team-type-form" - showCloseButton - > - - Team Type - - {errors.name?.message} - - - - Icon Name - } placement="right"> - - - - - - - {errors.iconName?.message} - - - ); + return ; }; -export default CreateTeamTypeModal; +export default CreateTeamTypeFormModal; diff --git a/src/frontend/src/pages/AdminToolsPage/TeamConfig/EditTeamTypeFormModal.tsx b/src/frontend/src/pages/AdminToolsPage/TeamConfig/EditTeamTypeFormModal.tsx new file mode 100644 index 0000000000..2f3493aff3 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/TeamConfig/EditTeamTypeFormModal.tsx @@ -0,0 +1,22 @@ +import { TeamType } from 'shared'; +import ErrorPage from '../../ErrorPage'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import TeamTypeFormModal from './TeamTypeFormModal'; +import { useEditTeamType } from '../../../hooks/team-types.hooks'; + +interface EditTeamTypeFormModalProps { + open: boolean; + handleClose: () => void; + teamType: TeamType; +} + +const EditTeamTypeFormModal = ({ open, handleClose, teamType }: EditTeamTypeFormModalProps) => { + const { isLoading, isError, error, mutateAsync } = useEditTeamType(teamType.teamTypeId); + + if (isError) return ; + if (isLoading) return ; + + return ; +}; + +export default EditTeamTypeFormModal; diff --git a/src/frontend/src/pages/AdminToolsPage/TeamConfig/TeamTypeFormModal.tsx b/src/frontend/src/pages/AdminToolsPage/TeamConfig/TeamTypeFormModal.tsx new file mode 100644 index 0000000000..6e7d714d49 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/TeamConfig/TeamTypeFormModal.tsx @@ -0,0 +1,121 @@ +import { useForm } from 'react-hook-form'; +import NERFormModal from '../../../components/NERFormModal'; +import { FormControl, FormLabel, FormHelperText, Tooltip, Typography, Link } from '@mui/material'; +import ReactHookTextField from '../../../components/ReactHookTextField'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { Box } from '@mui/system'; +import HelpIcon from '@mui/icons-material/Help'; +import { TeamType } from 'shared'; +import { CreateTeamTypePayload } from '../../../hooks/team-types.hooks'; +import useFormPersist from 'react-hook-form-persist'; +import { FormStorageKey } from '../../../utils/form'; +import { useEffect } from 'react'; + +interface TeamTypeFormModalProps { + open: boolean; + handleClose: () => void; + defaultValues?: TeamType; + onSubmit: (data: CreateTeamTypePayload) => Promise; +} + +const schema = yup.object().shape({ + name: yup.string().required('Material Type is Required'), + iconName: yup.string().required('Icon Name is Required'), + description: yup.string().required('Description is Required') +}); + +const TeamTypeFormModal: React.FC = ({ open, handleClose, defaultValues, onSubmit }) => { + const onFormSubmit = async (data: CreateTeamTypePayload) => { + await onSubmit(data); + handleClose(); + }; + + const { + handleSubmit, + control, + reset, + formState: { errors }, + watch, + setValue + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + name: defaultValues?.name ?? '', + iconName: defaultValues?.iconName ?? '', + description: defaultValues?.description ?? '' + } + }); + + const formStorageKey = defaultValues ? FormStorageKey.EDIT_TEAM_TYPE : FormStorageKey.CREATE_TEAM_TYPE; + + useFormPersist(formStorageKey, { + watch, + setValue + }); + + useEffect(() => { + reset({ + name: defaultValues?.name ?? '', + iconName: defaultValues?.iconName ?? '', + description: defaultValues?.description ?? '' + }); + }, [defaultValues, reset]); + + const TooltipMessage = () => ( + + Click to view possible icon names. For names with multiple words, seperate them with an _. AttachMoney = attach_money + + ); + + const handleCancel = () => { + reset({ name: '', iconName: '', description: '' }); + sessionStorage.removeItem(formStorageKey); + handleClose(); + }; + + return ( + reset({ name: '', iconName: '', description: '' })} + handleUseFormSubmit={handleSubmit} + onFormSubmit={onFormSubmit} + formId="team-type-form" + showCloseButton + > + + Team Type + + {errors.name?.message} + + + + Icon Name + } placement="right"> + + + + + + + {errors.iconName?.message} + + + + Description + + + {errors.description?.message} + + + ); +}; + +export default TeamTypeFormModal; diff --git a/src/frontend/src/pages/AdminToolsPage/TeamConfig/TeamTypeTable.tsx b/src/frontend/src/pages/AdminToolsPage/TeamConfig/TeamTypeTable.tsx index 04576eda48..50c6000a70 100644 --- a/src/frontend/src/pages/AdminToolsPage/TeamConfig/TeamTypeTable.tsx +++ b/src/frontend/src/pages/AdminToolsPage/TeamConfig/TeamTypeTable.tsx @@ -3,9 +3,14 @@ import LoadingIndicator from '../../../components/LoadingIndicator'; import ErrorPage from '../../ErrorPage'; import { NERButton } from '../../../components/NERButton'; import AdminToolTable from '../AdminToolTable'; -import CreateTeamTypeModal from './CreateTeamTypeFormModal'; -import { useAllTeamTypes } from '../../../hooks/design-reviews.hooks'; -import { useHistoryState } from '../../../hooks/misc.hooks'; +import CreateTeamTypeFormModal from './CreateTeamTypeFormModal'; +import { TeamType } from 'shared'; +import EditTeamTypeFormModal from './EditTeamTypeFormModal'; +import { useAllTeamTypes, useSetTeamTypeImage } from '../../../hooks/team-types.hooks'; +import { useEffect, useState } from 'react'; +import { useToast } from '../../../hooks/toasts.hooks'; +import NERUploadButton from '../../../components/NERUploadButton'; +import { downloadGoogleImage } from '../../../apis/finance.api'; const TeamTypeTable: React.FC = () => { const { @@ -14,7 +19,26 @@ const TeamTypeTable: React.FC = () => { isError: teamTypesIsError, error: teamTypesError } = useAllTeamTypes(); - const [createModalShow, setCreateModalShow] = useHistoryState('', false); + + const [createModalShow, setCreateModalShow] = useState(false); + const [editingTeamType, setEditingTeamType] = useState(undefined); + const [addedImages, setAddedImages] = useState<{ [key: string]: File | undefined }>({}); + const toast = useToast(); + const [imageUrls, setImageUrls] = useState<{ [key: string]: string | undefined }>({}); + + useEffect(() => { + try { + teamTypes?.forEach(async (teamType) => { + const imageBlob = await downloadGoogleImage(teamType.imageFileId ?? ''); + const url = URL.createObjectURL(imageBlob); + setImageUrls((prev) => ({ ...prev, [teamType.teamTypeId]: url })); + }); + } catch (error) { + console.error('Error fetching image urls', error); + } + }, [teamTypes]); + + const { mutateAsync: setTeamTypeImage } = useSetTeamTypeImage(); if (!teamTypes || teamTypesIsLoading) { return ; @@ -23,24 +47,97 @@ const TeamTypeTable: React.FC = () => { return ; } - const teamTypesTableRows = teamTypes.map((teamType) => ( - - {teamType.name} - - - {teamType.iconName} - - {teamType.iconName} - - - - - )); + const onSubmitTeamTypeImage = async (teamTypeId: string) => { + const addedImage = addedImages[teamTypeId]; + if (addedImage) { + await setTeamTypeImage({ file: addedImage, id: teamTypeId }); + toast.success('Image uploaded successfully!', 5000); + setAddedImages((prev) => ({ ...prev, [teamTypeId]: undefined })); + } else { + toast.error('No image selected for upload.', 5000); + } + }; + + const handleFileChange = (e: React.ChangeEvent, teamTypeId: string) => { + const file = e.target.files?.[0]; + if (file) { + if (file.size < 1000000) { + setAddedImages((prev) => ({ ...prev, [teamTypeId]: file })); + } else { + toast.error(`Error uploading ${file.name}; file must be less than 1 MB`, 5000); + } + } + }; + + const teamTypesTableRows = teamTypes.map((teamType) => { + return ( + + setEditingTeamType(teamType)} sx={{ cursor: 'pointer', border: '2px solid black' }}> + {teamType.name} + + setEditingTeamType(teamType)} + sx={{ cursor: 'pointer', border: '2px solid black', verticalAlign: 'middle' }} + > + + {teamType.iconName} + + {teamType.iconName} + + + + setEditingTeamType(teamType)} + sx={{ cursor: 'pointer', border: '2px solid black', verticalAlign: 'middle' }} + > + + + {teamType.description} + + + + + + {teamType.imageFileId && !addedImages[teamType.teamTypeId] && ( + + )} + handleFileChange(e, teamType.teamTypeId)} + onSubmit={onSubmitTeamTypeImage} + addedImage={addedImages[teamType.teamTypeId]} + setAddedImage={(newImage) => + setAddedImages((prev) => { + return { ...prev, [teamType.teamTypeId]: newImage } as { [key: string]: File | undefined }; + }) + } + /> + + + + ); + }); return ( - setCreateModalShow(false)} /> - + setCreateModalShow(false)} /> + {editingTeamType && ( + setEditingTeamType(undefined)} + teamType={editingTeamType} + /> + )} + + { const theme = useTheme(); diff --git a/src/frontend/src/pages/GanttPage/GanttChartPage.tsx b/src/frontend/src/pages/GanttPage/GanttChartPage.tsx index 578bc65c40..b3707a2824 100644 --- a/src/frontend/src/pages/GanttPage/GanttChartPage.tsx +++ b/src/frontend/src/pages/GanttPage/GanttChartPage.tsx @@ -19,10 +19,10 @@ import { SearchBar } from '../../components/SearchBar'; import GanttChartColorLegend from './GanttChartComponents/GanttChartColorLegend'; import GanttChartFiltersButton from './GanttChartComponents/GanttChartFiltersButton'; import GanttChart from './GanttChart'; -import { useAllTeamTypes } from '../../hooks/design-reviews.hooks'; import { Project, Team, TeamType, WbsElement, WorkPackage } from 'shared'; import { useAllTeams } from '../../hooks/teams.hooks'; import { useGetAllCars } from '../../hooks/cars.hooks'; +import { useAllTeamTypes } from '../../hooks/team-types.hooks'; const GanttChartPage: FC = () => { const query = useQuery(); diff --git a/src/frontend/src/pages/TeamsPage/SetTeamTypeModal.tsx b/src/frontend/src/pages/TeamsPage/SetTeamTypeModal.tsx index 40413a91b5..da4697332a 100644 --- a/src/frontend/src/pages/TeamsPage/SetTeamTypeModal.tsx +++ b/src/frontend/src/pages/TeamsPage/SetTeamTypeModal.tsx @@ -7,8 +7,7 @@ import LoadingIndicator from '../../components/LoadingIndicator'; import ErrorPage from '../ErrorPage'; import NERFormModal from '../../components/NERFormModal'; import { FormControl, FormLabel, Select, MenuItem } from '@mui/material'; -import { useAllTeamTypes } from '../../hooks/design-reviews.hooks'; -import { useSetTeamType } from '../../hooks/team-types.hooks'; +import { useAllTeamTypes, useSetTeamType } from '../../hooks/team-types.hooks'; interface SetTeamTypeInputs { teamId: string; diff --git a/src/frontend/src/tests/test-support/test-data/design-reviews.stub.ts b/src/frontend/src/tests/test-support/test-data/design-reviews.stub.ts index dd80b43bc1..d0339108dd 100644 --- a/src/frontend/src/tests/test-support/test-data/design-reviews.stub.ts +++ b/src/frontend/src/tests/test-support/test-data/design-reviews.stub.ts @@ -10,6 +10,9 @@ import { exampleWbsProject1 } from './wbs-numbers.stub'; export const teamType1: TeamType = { teamTypeId: '1', iconName: 'YouTubeIcon', + description: '', + imageFileId: null, + calendarId: null, name: 'teamType1' }; diff --git a/src/frontend/src/utils/form.ts b/src/frontend/src/utils/form.ts index 1e4ae50130..16dda0cbf7 100644 --- a/src/frontend/src/utils/form.ts +++ b/src/frontend/src/utils/form.ts @@ -89,6 +89,7 @@ export const generateUUID = () => { export enum FormStorageKey { CREATE_TEAM_TYPE = 'CREATE_TEAM_TYPE', + EDIT_TEAM_TYPE = 'EDIT_TEAM_TYPE', CREATE_MILESTONE = 'CREATE_MILESTONE', EDIT_MILESTONE = 'EDIT_MILESTONE', CREATE_FAQ = 'CREATE_FAQ', diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 94561cf1a8..72b5186174 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -88,6 +88,8 @@ const teamsSetLeads = (id: string) => `${teamsById(id)}/set-leads`; const teamTypes = () => `${teams()}/teamType`; const allTeamTypes = () => `${teamTypes()}/all`; const teamTypesCreate = () => `${teamTypes()}/create`; +const teamTypeEdit = (id: string) => `${teamTypes()}/${id}/edit`; +const teamTypeSetImage = (id: string) => `${teamTypes()}/${id}/set-image`; /**************** Description Bullet Endpoints ****************/ const descriptionBullets = () => `${API_URL}/description-bullets`; @@ -256,6 +258,8 @@ export const apiUrls = { allTeamTypes, teamsSetTeamType, teamTypesCreate, + teamTypeEdit, + teamTypeSetImage, descriptionBulletsCheck, descriptionBulletTypes, diff --git a/src/shared/src/types/design-review-types.ts b/src/shared/src/types/design-review-types.ts index aceb2ed6e8..d9db105d65 100644 --- a/src/shared/src/types/design-review-types.ts +++ b/src/shared/src/types/design-review-types.ts @@ -38,7 +38,9 @@ export interface TeamType { teamTypeId: string; name: string; iconName: string; - calendarId?: string | null; + description: string; + imageFileId: string | null; + calendarId: string | null; } export interface AvailabilityCreateArgs {