diff --git a/src/backend/src/controllers/organizations.controllers.ts b/src/backend/src/controllers/organizations.controllers.ts index 966691d9bb..b2e6875b3e 100644 --- a/src/backend/src/controllers/organizations.controllers.ts +++ b/src/backend/src/controllers/organizations.controllers.ts @@ -22,28 +22,6 @@ export default class OrganizationsController { } } - static async setImages(req: Request, res: Response, next: NextFunction) { - try { - const { applyInterestImage = [], exploreAsGuestImage = [] } = req.files as { - applyInterestImage?: Express.Multer.File[]; - exploreAsGuestImage?: Express.Multer.File[]; - }; - - const applyInterestFile = applyInterestImage[0] || null; - const exploreAsGuestFile = exploreAsGuestImage[0] || null; - - const newImages = await OrganizationsService.setImages( - applyInterestFile, - exploreAsGuestFile, - req.currentUser, - req.organization - ); - - res.status(200).json(newImages); - } catch (error: unknown) { - next(error); - } - } static async getAllUsefulLinks(req: Request, res: Response, next: NextFunction) { try { const links = await OrganizationsService.getAllUsefulLinks(req.organization.organizationId); @@ -97,15 +75,6 @@ export default class OrganizationsController { } } - static async getOrganizationImages(req: Request, res: Response, next: NextFunction) { - try { - const images = await OrganizationsService.getOrganizationImages(req.organization.organizationId); - res.status(200).json(images); - } catch (error: unknown) { - next(error); - } - } - static async setOrganizationFeaturedProjects(req: Request, res: Response, next: NextFunction) { try { const { projectIds } = req.body; @@ -142,6 +111,20 @@ export default class OrganizationsController { } } + static async setPlatformLogoImage(req: Request, res: Response, next: NextFunction) { + try { + if (!req.file) { + throw new HttpException(400, 'Invalid or undefined image data'); + } + + const updatedOrg = await OrganizationsService.setPlatformLogoImage(req.file, req.currentUser, req.organization); + + res.status(200).json(updatedOrg); + } catch (error: unknown) { + next(error); + } + } + static async setNewMemberImage(req: Request, res: Response, next: NextFunction) { try { if (!req.file) { @@ -181,6 +164,20 @@ export default class OrganizationsController { } } + static async setPlatformDescription(req: Request, res: Response, next: NextFunction) { + try { + const updatedOrg = await OrganizationsService.setPlatformDescription( + req.body.platformDescription, + req.currentUser, + req.organization + ); + + res.status(200).json(updatedOrg); + } catch (error: unknown) { + next(error); + } + } + static async getOrganizationFeaturedProjects(req: Request, res: Response, next: NextFunction) { try { const featuredProjects = await OrganizationsService.getOrganizationFeaturedProjects(req.organization.organizationId); diff --git a/src/backend/src/controllers/projects.controllers.ts b/src/backend/src/controllers/projects.controllers.ts index 19ed20b10f..dfe392a357 100644 --- a/src/backend/src/controllers/projects.controllers.ts +++ b/src/backend/src/controllers/projects.controllers.ts @@ -166,9 +166,16 @@ export default class ProjectsController { static async createLinkType(req: Request, res: Response, next: NextFunction) { try { - const { name, iconName, required } = req.body; + const { name, iconName, required, isOnGuestHomePage } = req.body; - const newLinkType = await ProjectsService.createLinkType(req.currentUser, name, iconName, required, req.organization); + const newLinkType = await ProjectsService.createLinkType( + req.currentUser, + name, + iconName, + required, + req.organization, + isOnGuestHomePage + ); res.status(200).json(newLinkType); } catch (error: unknown) { next(error); @@ -453,13 +460,14 @@ export default class ProjectsController { static async editLinkType(req: Request, res: Response, next: NextFunction) { try { const { linkTypeName } = req.params as Record; - const { name: newName, iconName, required } = req.body; + const { name: newName, iconName, required, isOnGuestHomePage } = req.body; const linkTypeUpdated = await ProjectsService.editLinkType( linkTypeName, iconName, required, req.currentUser, req.organization, + isOnGuestHomePage, newName ); res.status(200).json(linkTypeUpdated); diff --git a/src/backend/src/prisma/migrations/20260305012944_guest_home_page/migration.sql b/src/backend/src/prisma/migrations/20260305012944_guest_home_page/migration.sql new file mode 100644 index 0000000000..65871fd476 --- /dev/null +++ b/src/backend/src/prisma/migrations/20260305012944_guest_home_page/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - You are about to drop the column `applyInterestImageId` on the `Organization` table. All the data in the column will be lost. + - You are about to drop the column `exploreAsGuestImageId` on the `Organization` table. All the data in the column will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Sponsor" DROP CONSTRAINT "Sponsor_sponsorTierId_fkey"; + +-- AlterTable +ALTER TABLE "Link_Type" ADD COLUMN "isOnGuestHomePage" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "Organization" DROP COLUMN "applyInterestImageId", +DROP COLUMN "exploreAsGuestImageId", +ADD COLUMN "platformDescription" TEXT NOT NULL DEFAULT '', +ADD COLUMN "platformLogoImageId" TEXT; + +-- AlterTable +ALTER TABLE "Sponsor" ALTER COLUMN "valueTypes" DROP DEFAULT; + +-- AddForeignKey +ALTER TABLE "Sponsor" ADD CONSTRAINT "Sponsor_sponsorTierId_fkey" FOREIGN KEY ("sponsorTierId") REFERENCES "Sponsor_Tier"("sponsorTierId") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index 02e0f4de86..19fd8b1783 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -583,16 +583,17 @@ model Work_Package { } model Link_Type { - id String @id @default(uuid()) - name String - dateCreated DateTime @default(now()) - iconName String - required Boolean - creatorId String - creator User @relation(name: "linkTypeCreator", fields: [creatorId], references: [userId]) - links Link[] @relation(name: "linkTypes") - organizationId String - organization Organization @relation(fields: [organizationId], references: [organizationId]) + id String @id @default(uuid()) + name String + dateCreated DateTime @default(now()) + iconName String + required Boolean + creatorId String + creator User @relation(name: "linkTypeCreator", fields: [creatorId], references: [userId]) + links Link[] @relation(name: "linkTypes") + organizationId String + organization Organization @relation(fields: [organizationId], references: [organizationId]) + isOnGuestHomePage Boolean @default(false) @@unique([name, organizationId], name: "uniqueLinkType") @@index([organizationId]) @@ -1379,8 +1380,6 @@ model Organization { advisor User? @relation(name: "advisor", fields: [advisorId], references: [userId]) advisorId String? description String @default("") - applyInterestImageId String? - exploreAsGuestImageId String? newMemberImageId String? logoImageId String? slackWorkspaceId String? @@ -1389,6 +1388,8 @@ model Organization { partReviewSampleImageId String? partReviewGuideLink String? sponsorshipNotificationsSlackChannelId String? + platformDescription String @default("") + platformLogoImageId String? // Relation references wbsElements WBS_Element[] diff --git a/src/backend/src/prisma/seed-data/users.seed.ts b/src/backend/src/prisma/seed-data/users.seed.ts index dbb31d55ed..5c7e31c84d 100644 --- a/src/backend/src/prisma/seed-data/users.seed.ts +++ b/src/backend/src/prisma/seed-data/users.seed.ts @@ -62,6 +62,20 @@ const joeBlow: Prisma.UserCreateInput = { } }; +const guestUser: Prisma.UserCreateInput = { + firstName: 'Guest', + lastName: 'User', + googleAuthId: 'guest-google-id', + email: 'guest@husky.neu.edu', + emailId: 'guest', + userSettings: { + create: { + defaultTheme: Theme.DARK, + slackId: SLACK_ID ? SLACK_ID : 'guest' + } + } +}; + const wonderwoman: Prisma.UserCreateInput = { firstName: 'Diana', lastName: 'Prince', @@ -994,6 +1008,7 @@ export const dbSeedAllUsers = { thomasEmrax, joeShmoe, joeBlow, + guestUser, wonderwoman, flash, aquaman, diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 1912713555..edcd7223b2 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -125,10 +125,11 @@ const performSeed: () => Promise = async () => { userCreatedId: thomasEmrax.userId, description: 'Northeastern Electric Racing is a student-run organization at Northeastern University building all-electric formula-style race cars from scratch to compete in Forumla Hybrid + Electric Formula SAE (FSAE).', - applyInterestImageId: '1_iak6ord4JP9TcR1sOYopyEs6EjTKQpw', - exploreAsGuestImageId: '1wRes7V_bMm9W7_3JCIDXYkMUiy6B3wRI', applicationLink: - 'https://docs.google.com/forms/d/e/1FAIpQLSeCvG7GqmZm_gmSZiahbVTW9ZFpEWG0YfGQbkSB_whhHzxXpA/closedform' + 'https://docs.google.com/forms/d/e/1FAIpQLSeCvG7GqmZm_gmSZiahbVTW9ZFpEWG0YfGQbkSB_whhHzxXpA/closedform', + platformDescription: + 'Finishline is a Project Management Dashboard developed by the Software Team at Northeastern Electric Racing.', + platformLogoImageId: '1auQO3GYydZOo1-vCn0D2iyCfaxaVFssx' } }); @@ -264,6 +265,7 @@ const performSeed: () => Promise = async () => { const regina = await createUser(dbSeedAllUsers.regina, RoleEnum.MEMBER, organizationId); const patrick = await createUser(dbSeedAllUsers.patrick, RoleEnum.MEMBER, organizationId); const spongebob = await createUser(dbSeedAllUsers.spongebob, RoleEnum.MEMBER, organizationId); + await createUser(dbSeedAllUsers.guestUser, RoleEnum.GUEST, organizationId); await UsersService.updateUserRole(cyborg.userId, thomasEmrax, 'APP_ADMIN', ner); @@ -367,21 +369,15 @@ const performSeed: () => Promise = async () => { const mechanical = await TeamsService.createTeamType( batman, 'Mechanical', - 'YouTubeIcon', + 'Construction', 'This is the mechanical team', ner ); - const software = await TeamsService.createTeamType( - thomasEmrax, - 'Software', - 'InstagramIcon', - 'This is the software team', - ner - ); + const software = await TeamsService.createTeamType(thomasEmrax, 'Software', 'Code', 'This is the software team', ner); const electrical = await TeamsService.createTeamType( cyborg, 'Electrical', - 'SettingsIcon', + 'ElectricBolt', 'This is the electrical team', ner ); @@ -551,15 +547,22 @@ const performSeed: () => Promise = async () => { ); /** Link Types */ - const confluenceLinkType = await ProjectsService.createLinkType(batman, 'Confluence', 'description', true, ner); + const confluenceLinkType = await ProjectsService.createLinkType(batman, 'Confluence', 'description', true, ner, false); - const bomLinkType = await ProjectsService.createLinkType(batman, 'Bill of Materials', 'bar_chart', true, ner); + const bomLinkType = await ProjectsService.createLinkType(batman, 'Bill of Materials', 'bar_chart', true, ner, false); - const mainWebsiteLinkType = await ProjectsService.createLinkType(batman, 'NER Website', 'bar_chart', true, ner); + const mainWebsiteLinkType = await ProjectsService.createLinkType(batman, 'NER Website', 'bar_chart', true, ner, false); - const instagramWebsiteLinkType = await ProjectsService.createLinkType(batman, 'NER Instagram', 'bar_chart', true, ner); + const instagramWebsiteLinkType = await ProjectsService.createLinkType( + batman, + 'NER Instagram', + 'bar_chart', + true, + ner, + false + ); - await ProjectsService.createLinkType(batman, 'Google Drive', 'folder', true, ner); + await ProjectsService.createLinkType(batman, 'Google Drive', 'folder', true, ner, false); /** * Projects diff --git a/src/backend/src/routes/organizations.routes.ts b/src/backend/src/routes/organizations.routes.ts index ebc6d9f869..1b77829f59 100644 --- a/src/backend/src/routes/organizations.routes.ts +++ b/src/backend/src/routes/organizations.routes.ts @@ -11,14 +11,6 @@ const upload = multer({ limits: { fileSize: MAX_FILE_SIZE }, storage: memoryStor organizationRouter.get('/current', OrganizationsController.getCurrentOrganization); organizationRouter.post('/useful-links/set', ...linkValidators, validateInputs, OrganizationsController.setUsefulLinks); organizationRouter.get('/useful-links', OrganizationsController.getAllUsefulLinks); -organizationRouter.post( - '/images/update', - upload.fields([ - { name: 'applyInterestImage', maxCount: 1 }, - { name: 'exploreAsGuestImage', maxCount: 1 } - ]), - OrganizationsController.setImages -); organizationRouter.post( '/application-link/update', @@ -51,6 +43,13 @@ organizationRouter.post( ); organizationRouter.post('/logo/update', upload.single('logo'), OrganizationsController.setLogoImage); organizationRouter.get('/logo', OrganizationsController.getOrganizationLogoImage); + +organizationRouter.post( + '/platform-logo/update', + upload.single('platformLogo'), + OrganizationsController.setPlatformLogoImage +); + organizationRouter.post( '/new-member-image/update', upload.single('newMemberImage'), @@ -63,6 +62,12 @@ organizationRouter.post( validateInputs, OrganizationsController.setOrganizationDescription ); +organizationRouter.post( + '/platform-description/set', + nonEmptyString(body('platformDescription')), + validateInputs, + OrganizationsController.setPlatformDescription +); organizationRouter.get('/featured-projects', OrganizationsController.getOrganizationFeaturedProjects); organizationRouter.post( '/workspaceId/set', diff --git a/src/backend/src/services/organizations.services.ts b/src/backend/src/services/organizations.services.ts index 1a6620b88a..5891e76072 100644 --- a/src/backend/src/services/organizations.services.ts +++ b/src/backend/src/services/organizations.services.ts @@ -104,37 +104,6 @@ export default class OrganizationsService { return newLinks; } - /** - * sets an organizations images - * @param submitter the user who is setting the images - * @param organizationId the organization which the images will be set up - * @param images the images which are being set - */ - static async setImages( - applyInterestImage: Express.Multer.File | null, - exploreAsGuestImage: Express.Multer.File | null, - submitter: User, - organization: Organization - ) { - if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) { - throw new AccessDeniedAdminOnlyException('update images'); - } - - const applyInterestImageData = applyInterestImage ? await uploadFile(applyInterestImage) : null; - const exploreAsGuestImageData = exploreAsGuestImage ? await uploadFile(exploreAsGuestImage) : null; - const updateData = { - ...(applyInterestImageData && { applyInterestImageId: applyInterestImageData.id }), - ...(exploreAsGuestImageData && { exploreAsGuestImageId: exploreAsGuestImageData.id }) - }; - - const newImages = await prisma.organization.update({ - where: { organizationId: organization.organizationId }, - data: updateData - }); - - return newImages; - } - /** Gets all the useful links for an organization @param organizationId the organization to get the links for @@ -255,26 +224,6 @@ export default class OrganizationsService { return updatedOrganization; } - /** - * Gets all organization Images for the given organization Id - * @param organizationId organization Id of the milestone - * @returns all the milestones from the given organization - */ - static async getOrganizationImages(organizationId: string) { - const organization = await prisma.organization.findUnique({ - where: { organizationId } - }); - - if (!organization) { - throw new NotFoundException('Organization', organizationId); - } - - return { - applyInterestImage: organization.applyInterestImageId, - exploreAsGuestImage: organization.exploreAsGuestImageId - }; - } - /** * Updates the featured projects of an organization * @param projectIds project ids of featured projects @@ -429,6 +378,23 @@ export default class OrganizationsService { return updatedOrg; } + /** + * Sets the platform description of a given organization. + * @param platformDescription the new platform description + * @param submitter the user making the change + * @param organization the organization whose platform description is changing + * @throws if the user is not an admin + */ + static async setPlatformDescription(platformDescription: string, submitter: User, organization: Organization) { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) { + throw new AccessDeniedAdminOnlyException('set platform description'); + } + return prisma.organization.update({ + where: { organizationId: organization.organizationId }, + data: { platformDescription } + }); + } + /** * Gets the featured projects for the given organization Id * @param organizationId the organization to get the projects for @@ -596,4 +562,31 @@ export default class OrganizationsService { return updatedOrg.financeDelegates.map(userTransformer); } + + /** + * sets an organizations platform image + * @param submitter the user who is setting the images + * @param organizationId the organization which the images will be set up + * @param images the images which are being set + */ + static async setPlatformLogoImage(platformLogoImage: Express.Multer.File, submitter: User, organization: Organization) { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) { + throw new AccessDeniedAdminOnlyException('update platform logo'); + } + + const platformLogoImageData = await uploadFile(platformLogoImage); + + if (!platformLogoImageData?.id || !platformLogoImageData?.name) { + throw new HttpException(500, 'Platform logo upload failed'); + } + + const newImages = await prisma.organization.update({ + where: { organizationId: organization.organizationId }, + data: { + platformLogoImageId: platformLogoImageData.id + } + }); + + return newImages; + } } diff --git a/src/backend/src/services/projects.services.ts b/src/backend/src/services/projects.services.ts index 7e4967bfd2..f0684d5885 100644 --- a/src/backend/src/services/projects.services.ts +++ b/src/backend/src/services/projects.services.ts @@ -584,7 +584,8 @@ export default class ProjectsService { name: string, iconName: string, required: boolean, - organization: Organization + organization: Organization, + isOnGuestHomePage: boolean ): Promise { if (!(await userHasPermission(user.userId, organization.organizationId, isAdmin))) throw new AccessDeniedException('Only admins can create link types'); @@ -601,7 +602,8 @@ export default class ProjectsService { creatorId: user.userId, iconName, required, - organizationId: organization.organizationId + organizationId: organization.organizationId, + isOnGuestHomePage } }); @@ -623,6 +625,7 @@ export default class ProjectsService { required: boolean, submitter: User, organization: Organization, + isOnGuestHomePage: boolean, newName?: string ): Promise { if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) @@ -660,7 +663,8 @@ export default class ProjectsService { data: { name: newName && newName ? newName : linkName, iconName, - required + required, + isOnGuestHomePage } }); return linkTypeUpdated; diff --git a/src/backend/src/transformers/organizationTransformer.ts b/src/backend/src/transformers/organizationTransformer.ts index 89ddc1981c..08b5e62492 100644 --- a/src/backend/src/transformers/organizationTransformer.ts +++ b/src/backend/src/transformers/organizationTransformer.ts @@ -5,8 +5,8 @@ export const organizationTransformer = (organization: Organization): Organizatio return { ...organization, applicationLink: organization.applicationLink ?? undefined, - applyInterestImageId: organization.applyInterestImageId ?? undefined, - exploreAsGuestImageId: organization.exploreAsGuestImageId ?? undefined, - newMemberImageId: organization.newMemberImageId ?? undefined + newMemberImageId: organization.newMemberImageId ?? undefined, + platformDescription: organization.platformDescription, + platformLogoImageId: organization.platformLogoImageId ?? undefined }; }; diff --git a/src/backend/tests/unit/organization.test.ts b/src/backend/tests/unit/organization.test.ts index 3bdec303a0..9fca867b26 100644 --- a/src/backend/tests/unit/organization.test.ts +++ b/src/backend/tests/unit/organization.test.ts @@ -42,46 +42,6 @@ describe('Organization Tests', () => { }); }); - describe('Set Images', () => { - const file1 = { originalname: 'image1.png' } as Express.Multer.File; - const file2 = { originalname: 'image2.png' } as Express.Multer.File; - const file3 = { originalname: 'image3.png' } as Express.Multer.File; - it('Fails if user is not an admin', async () => { - await expect( - OrganizationsService.setImages(file1, file2, await createTestUser(wonderwomanGuest, orgId), organization) - ).rejects.toThrow(new AccessDeniedAdminOnlyException('update images')); - }); - - it('Succeeds and updates all the images', async () => { - const testBatman = await createTestUser(batmanAppAdmin, orgId); - (uploadFile as Mock).mockImplementation((file) => { - return Promise.resolve({ name: `${file.originalname}`, id: `uploaded-${file.originalname}` }); - }); - - await OrganizationsService.setImages(file1, file2, testBatman, organization); - - const oldOrganization = await prisma.organization.findUnique({ - where: { - organizationId: orgId - } - }); - - expect(oldOrganization).not.toBeNull(); - expect(oldOrganization?.applyInterestImageId).toBe('uploaded-image1.png'); - expect(oldOrganization?.exploreAsGuestImageId).toBe('uploaded-image2.png'); - - await OrganizationsService.setImages(file1, file3, testBatman, organization); - - const updatedOrganization = await prisma.organization.findUnique({ - where: { - organizationId: orgId - } - }); - - expect(updatedOrganization?.exploreAsGuestImageId).toBe('uploaded-image3.png'); - }); - }); - describe('Set Useful Links', () => { it('Fails if user is not an admin', async () => { await expect( @@ -204,30 +164,6 @@ describe('Organization Tests', () => { }); }); - describe('Get Organization Images', () => { - it('Fails if an organization does not exist', async () => { - await expect(async () => await OrganizationsService.getOrganizationImages('1')).rejects.toThrow( - new NotFoundException('Organization', '1') - ); - }); - - it('Succeeds and gets all the images', async () => { - const testBatman = await createTestUser(batmanAppAdmin, orgId); - await createTestLinkType(testBatman, orgId); - await OrganizationsService.setImages( - { originalname: 'image1.png' } as Express.Multer.File, - { originalname: 'image2.png' } as Express.Multer.File, - testBatman, - organization - ); - const images = await OrganizationsService.getOrganizationImages(orgId); - - expect(images).not.toBeNull(); - expect(images.applyInterestImage).toBe('uploaded-image1.png'); - expect(images.exploreAsGuestImage).toBe('uploaded-image2.png'); - }); - }); - describe('Set Logo', () => { const file1 = { originalname: 'image1.png' } as Express.Multer.File; const file2 = { originalname: 'image2.png' } as Express.Multer.File; @@ -359,4 +295,43 @@ describe('Organization Tests', () => { expect(updatedOrganization.partReviewGuideLink).toBe('newlink'); }); }); + + describe('Set Organization Platform Logo', () => { + const file1 = { originalname: 'image1.png' } as Express.Multer.File; + const file2 = { originalname: 'image2.png' } as Express.Multer.File; + const file3 = { originalname: 'image3.png' } as Express.Multer.File; + it('Fails if user is not an admin', async () => { + await expect( + OrganizationsService.setPlatformLogoImage(file1, await createTestUser(wonderwomanGuest, orgId), organization) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('update platform logo')); + }); + + it('Succeeds and updates all the images', async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + (uploadFile as Mock).mockImplementation((file) => { + return Promise.resolve({ name: `${file.originalname}`, id: `uploaded-${file.originalname}` }); + }); + + await OrganizationsService.setPlatformLogoImage(file2, testBatman, organization); + + const oldOrganization = await prisma.organization.findUnique({ + where: { + organizationId: orgId + } + }); + + expect(oldOrganization).not.toBeNull(); + expect(oldOrganization?.platformLogoImageId).toBe('uploaded-image2.png'); + + await OrganizationsService.setPlatformLogoImage(file3, testBatman, organization); + + const updatedOrganization = await prisma.organization.findUnique({ + where: { + organizationId: orgId + } + }); + + expect(updatedOrganization?.platformLogoImageId).toBe('uploaded-image3.png'); + }); + }); }); diff --git a/src/backend/tests/unmocked/organization.test.ts b/src/backend/tests/unmocked/organization.test.ts index 22598ca555..4712a97137 100644 --- a/src/backend/tests/unmocked/organization.test.ts +++ b/src/backend/tests/unmocked/organization.test.ts @@ -4,8 +4,7 @@ import { batmanAppAdmin, flashAdmin, supermanAdmin, wonderwomanGuest } from '../ import { createTestLinkType, createTestOrganization, createTestUser, resetUsers } from '../test-utils.js'; import prisma from '../../src/prisma/prisma.js'; import { testLink1 } from '../test-data/organizations.test-data.js'; -import { uploadFile } from '../../src/utils/google-integration.utils.js'; -import { Mock, vi } from 'vitest'; +import { vi } from 'vitest'; import OrganizationsService from '../../src/services/organizations.services.js'; import { Organization } from '@prisma/client'; @@ -42,46 +41,6 @@ describe('Organization Tests', () => { }); }); - describe('Set Images', () => { - const file1 = { originalname: 'image1.png' } as Express.Multer.File; - const file2 = { originalname: 'image2.png' } as Express.Multer.File; - const file3 = { originalname: 'image3.png' } as Express.Multer.File; - it('Fails if user is not an admin', async () => { - await expect( - OrganizationsService.setImages(file1, file2, await createTestUser(wonderwomanGuest, orgId), organization) - ).rejects.toThrow(new AccessDeniedAdminOnlyException('update images')); - }); - - it('Succeeds and updates all the images', async () => { - const testBatman = await createTestUser(batmanAppAdmin, orgId); - (uploadFile as Mock).mockImplementation((file) => { - return Promise.resolve({ id: `uploaded-${file.originalname}` }); - }); - - await OrganizationsService.setImages(file1, file2, testBatman, organization); - - const oldOrganization = await prisma.organization.findUnique({ - where: { - organizationId: orgId - } - }); - - expect(oldOrganization).not.toBeNull(); - expect(oldOrganization?.applyInterestImageId).toBe('uploaded-image1.png'); - expect(oldOrganization?.exploreAsGuestImageId).toBe('uploaded-image2.png'); - - await OrganizationsService.setImages(file1, file3, testBatman, organization); - - const updatedOrganization = await prisma.organization.findUnique({ - where: { - organizationId: orgId - } - }); - - expect(updatedOrganization?.exploreAsGuestImageId).toBe('uploaded-image3.png'); - }); - }); - describe('Set Useful Links', () => { it('Fails if user is not an admin', async () => { await expect( diff --git a/src/frontend/src/apis/organizations.api.ts b/src/frontend/src/apis/organizations.api.ts index 6ba806add1..d858ee080e 100644 --- a/src/frontend/src/apis/organizations.api.ts +++ b/src/frontend/src/apis/organizations.api.ts @@ -42,6 +42,12 @@ export const setOrganizationDescription = async (description: string) => { }); }; +export const setPlatformDescription = async (platformDescription: string) => { + return axios.post(apiUrls.organizationsSetPlatformDescription(), { + platformDescription + }); +}; + export const getOrganizationLogo = async () => { return axios.get(apiUrls.organizationsLogoImage(), { transformResponse: (data) => JSON.parse(data) @@ -66,6 +72,12 @@ export const getOrganizationNewMemberImage = async () => { }); }; +export const setOrganizationPlatformLogoImage = async (file: File) => { + const formData = new FormData(); + formData.append('platformLogo', file); + return axios.post(apiUrls.organizationsSetPlatformLogoImage(), formData); +}; + export const setOrganizationFeaturedProjects = async (featuredProjectIds: string[]) => { return axios.post(apiUrls.organizationsSetFeaturedProjects(), { projectIds: featuredProjectIds @@ -93,15 +105,6 @@ export const downloadGoogleImage = async (fileId: string): Promise => { return imageBlob; }; -export const setOrganizationImages = (images: File[]) => { - const formData = new FormData(); - - formData.append('applyInterestImage', images[0]); - formData.append('exploreAsGuestImage', images[1]); - - return axios.post<{ message: string }>(apiUrls.organizationsSetImages(), formData, {}); -}; - /** * Sets the contacts for an organization * @param contacts all the contact information that is being set diff --git a/src/frontend/src/app/AppAuthenticated.tsx b/src/frontend/src/app/AppAuthenticated.tsx index 8ab368c60c..0094ddba8c 100644 --- a/src/frontend/src/app/AppAuthenticated.tsx +++ b/src/frontend/src/app/AppAuthenticated.tsx @@ -70,7 +70,7 @@ const AppAuthenticated: React.FC = ({ userId, userRole }) return userSettingsData.slackId || isGuest(userRole) ? ( - {!onGuestHomePage && ( + { <> { @@ -108,12 +108,12 @@ const AppAuthenticated: React.FC = ({ userId, userRole }) setMoveContent={setMoveContent} /> - )} + } - + diff --git a/src/frontend/src/hooks/organizations.hooks.ts b/src/frontend/src/hooks/organizations.hooks.ts index cff6cd0f45..70ccb3a069 100644 --- a/src/frontend/src/hooks/organizations.hooks.ts +++ b/src/frontend/src/hooks/organizations.hooks.ts @@ -6,6 +6,7 @@ import { getFeaturedProjects, getCurrentOrganization, setOrganizationDescription, + setPlatformDescription, setOrganizationFeaturedProjects, setOrganizationWorkspaceId, setOrganizationLogo, @@ -13,14 +14,14 @@ import { updateApplicationLink, setOnboardingText, updateOrganizationContacts, - setOrganizationImages, getPartReviewGuideLink, setPartReviewGuideLink, setSlackSponsorshipNotificationSlackChannelId, getFinanceDelegates, setFinanceDelegates, setOrganizationNewMemberImage, - getOrganizationNewMemberImage + getOrganizationNewMemberImage, + setOrganizationPlatformLogoImage } from '../apis/organizations.api'; import { downloadGoogleImage } from '../apis/organizations.api'; @@ -66,22 +67,6 @@ export const useProvideOrganization = (): OrganizationProvider => { }; }; -export const useSetOrganizationImages = () => { - const queryClient = useQueryClient(); - - return useMutation( - async (images: File[]) => { - const { data } = await setOrganizationImages(images); - return data; - }, - { - onSuccess: () => { - queryClient.invalidateQueries(['organizations']); - } - } - ); -}; - export const useFeaturedProjects = () => { return useQuery(['organizations', 'featured-projects'], async () => { const { data } = await getFeaturedProjects(); @@ -164,6 +149,22 @@ export const useSetOrganizationDescription = () => { ); }; +export const useSetPlatformDescription = () => { + const queryClient = useQueryClient(); + return useMutation( + ['organizations', 'platform-description'], + async (platformDescription: string) => { + const { data } = await setPlatformDescription(platformDescription); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['organizations']); + } + } + ); +}; + export const useSetFeaturedProjects = () => { const queryClient = useQueryClient(); return useMutation( @@ -235,6 +236,15 @@ export const useSetOrganizationNewMemberImage = () => { }); }; +export const useSetOrganizationPlatformLogoImage = () => { + const queryClient = useQueryClient(); + return useMutation(['organizations', 'platform-logo'], async (file: File) => { + const { data } = await setOrganizationPlatformLogoImage(file); + queryClient.invalidateQueries(['organizations']); + return data; + }); +}; + /* * Custom React Hook to fetch confluence guide for current * organization in backend diff --git a/src/frontend/src/layouts/Sidebar/Sidebar.tsx b/src/frontend/src/layouts/Sidebar/Sidebar.tsx index 6fd7b11f4d..93e731e345 100644 --- a/src/frontend/src/layouts/Sidebar/Sidebar.tsx +++ b/src/frontend/src/layouts/Sidebar/Sidebar.tsx @@ -9,6 +9,10 @@ import styles from '../../stylesheets/layouts/sidebar/sidebar.module.css'; import { Typography, Box, IconButton, Divider } from '@mui/material'; import HomeIcon from '@mui/icons-material/Home'; import AlignHorizontalLeftIcon from '@mui/icons-material/AlignHorizontalLeft'; +import RateReviewIcon from '@mui/icons-material/RateReview'; +import DashboardIcon from '@mui/icons-material/Dashboard'; +// To be uncommented after guest sponsors page is developed +// import VolunteerActivismIcon from '@mui/icons-material/VolunteerActivism'; import FolderIcon from '@mui/icons-material/Folder'; import SyncAltIcon from '@mui/icons-material/SyncAlt'; import GroupIcon from '@mui/icons-material/Group'; @@ -21,7 +25,12 @@ import NavUserMenu from '../PageTitle/NavUserMenu'; import DrawerHeader from '../../components/DrawerHeader'; import { Cached, ChevronLeft, ChevronRight } from '@mui/icons-material'; import { useHomePageContext } from '../../app/HomePageContext'; +// once divisions developed, import TeamType from shared import { isGuest } from 'shared'; +// To be uncommented after divisions page is developed +// import * as MuiIcons from '@mui/icons-material'; +// import { useAllTeamTypes } from '../../hooks/team-types.hooks'; +// import ErrorPage from '../../pages/ErrorPage'; import BarChartIcon from '@mui/icons-material/BarChart'; import { useCurrentUser } from '../../hooks/users.hooks'; import QueryStatsIcon from '@mui/icons-material/QueryStats'; @@ -40,29 +49,70 @@ const Sidebar = ({ drawerOpen, setDrawerOpen, moveContent, setMoveContent }: Sid const [openSubmenu, setOpenSubmenu] = useState(null); const { onPNMHomePage, onOnboardingHomePage } = useHomePageContext(); const user = useCurrentUser(); + const { onGuestHomePage } = useHomePageContext(); + // const { isError: teamsError, error: teamsErrorMsg, data: teams } = useAllTeamTypes(); + // To be uncommented once guest divisions pages are developed + // const allTeams: LinkItem[] = (teams ?? []).map((team: TeamType) => { + // const IconComponent = MuiIcons[(team.iconName in MuiIcons ? team.iconName : 'Circle') as keyof typeof MuiIcons]; + // return { + // name: team.name, + // icon: , + // route: routes.TEAMS + '/' + team.teamTypeId + // }; + // }); + + // if (teamsError) return ; const memberLinkItems: LinkItem[] = [ { name: 'Home', icon: , - route: routes.HOME + route: onGuestHomePage ? routes.HOME_GUEST : routes.HOME }, - { + !onGuestHomePage && { name: 'Gantt', icon: , route: routes.GANTT }, - { - name: 'Projects', - icon: , - route: routes.PROJECTS - }, - { + !onGuestHomePage + ? { + name: 'Projects', + icon: , + route: routes.PROJECTS + } + : { + name: 'Project Management', + icon: , + route: routes.PROJECTS, + subItems: [ + { + name: 'Gantt', + icon: , + route: routes.GANTT + }, + { + name: 'Projects', + icon: , + route: routes.PROJECTS + }, + { + name: 'Change Requests', + icon: , + route: routes.CHANGE_REQUESTS + }, + { + name: 'Design Review', + icon: , + route: routes.CALENDAR + } + ] + }, + !onGuestHomePage && { name: 'Change Requests', icon: , route: routes.CHANGE_REQUESTS }, - { + !onGuestHomePage && { name: 'Finance', icon: , route: routes.FINANCE, @@ -84,29 +134,49 @@ const Sidebar = ({ drawerOpen, setDrawerOpen, moveContent, setMoveContent }: Sid } ] }, - { + + // Teams tab here to be replaced with below code once guest divisions is developed + !onGuestHomePage && { name: 'Teams', icon: , route: routes.TEAMS }, - { + // !onGuestHomePage + // ? { + // name: 'Teams', + // icon: , + // route: routes.TEAMS + // } + // : { + // name: 'Divisions', + // icon: , + // route: routes.TEAMS, + // subItems: allTeams + // }, + !onGuestHomePage && { name: 'Calendar', icon: , route: routes.CALENDAR }, - { + !onGuestHomePage && { name: 'Retrospective', icon: , route: routes.RETROSPECTIVE }, + // To be uncommented once guest mode sponsors page is developed + // onGuestHomePage && { + // name: 'Sponsors', + // icon: , + // route: routes.RETROSPECTIVE + // }, { name: 'Info', icon: , route: routes.INFO } - ]; + ].filter(Boolean) as LinkItem[]; - if (!isGuest(user.role)) { + if (!isGuest(user.role) && !onGuestHomePage) { memberLinkItems.splice(6, 0, { name: 'Statistics', icon: , diff --git a/src/frontend/src/pages/AdminToolsPage/EditGuestView/GuestViewConfig.tsx b/src/frontend/src/pages/AdminToolsPage/EditGuestView/GuestViewConfig.tsx index 419bb2548c..bc7a24613f 100644 --- a/src/frontend/src/pages/AdminToolsPage/EditGuestView/GuestViewConfig.tsx +++ b/src/frontend/src/pages/AdminToolsPage/EditGuestView/GuestViewConfig.tsx @@ -1,19 +1,182 @@ -import { Stack, Grid } from '@mui/material'; +import { Box, FormControl, Grid, Stack, Typography } from '@mui/material'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; import EditDescription from './EditDescription'; import EditFeaturedProjects from './EditFeaturedProjects'; import EditLogo from './EditLogo'; +import { + useCurrentOrganization, + useSetOrganizationPlatformLogoImage, + useSetPlatformDescription +} from '../../../hooks/organizations.hooks'; +import { useGetImageUrl } from '../../../hooks/onboarding.hook'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import NERUploadButton from '../../../components/NERUploadButton'; +import NERSuccessButton from '../../../components/NERSuccessButton'; +import ReactHookTextField from '../../../components/ReactHookTextField'; +import { useToast } from '../../../hooks/toasts.hooks'; +import { MAX_FILE_SIZE } from 'shared'; +import UsefulLinksTable from '../OnboardingConfig/UsefulLinks/UsefulLinksTable'; +import LinkTypeTable from '../ProjectsConfig/LinkTypes/LinkTypeTable'; + +const platformDescriptionSchema = yup.object().shape({ + platformDescription: yup.string().required() +}); const GuestViewConfig: React.FC = () => { + const { data: organization } = useCurrentOrganization(); + const { mutateAsync: setPlatformLogoImage, isLoading: platformLogoLoading } = useSetOrganizationPlatformLogoImage(); + const { mutateAsync: setPlatformDescriptionMutation, isLoading: platformDescriptionSaving } = useSetPlatformDescription(); + const { data: platformLogoImageUrl } = useGetImageUrl(organization?.platformLogoImageId ?? null); + const toast = useToast(); + + const [addedPlatformLogo, setAddedPlatformLogo] = useState(undefined); + + const formKey = organization?.organizationId ?? 'loading'; + + const { control, handleSubmit, reset } = useForm<{ platformDescription: string }>({ + resolver: yupResolver(platformDescriptionSchema), + defaultValues: { platformDescription: organization?.platformDescription ?? '' } + }); + + const onPlatformDescriptionSubmit = async (data: { platformDescription: string }) => { + try { + const updated = await setPlatformDescriptionMutation(data.platformDescription); + reset({ platformDescription: updated.platformDescription }); + toast.success('Platform description saved.'); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Failed to save platform description'); + } + }; + + const handlePlatformLogoUpload = async () => { + if (!addedPlatformLogo) return; + if (addedPlatformLogo.size >= MAX_FILE_SIZE) { + toast.error( + `Error uploading ${addedPlatformLogo.name}; file must be less than ${MAX_FILE_SIZE / 1024 / 1024} MB`, + 5000 + ); + return; + } + try { + await setPlatformLogoImage(addedPlatformLogo); + toast.success('Platform logo uploaded successfully.'); + setAddedPlatformLogo(undefined); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Failed to upload image'); + } + }; + return ( + + + Platform Description + +
{ + e.preventDefault(); + e.stopPropagation(); + handleSubmit(onPlatformDescriptionSubmit)(e); + }} + onKeyPress={(e) => { + e.key === 'Enter' && e.preventDefault(); + }} + > + + + + + + {platformDescriptionSaving ? 'Saving...' : 'Save'} + + +
+
- + + + + + Platform Logo + + {platformLogoLoading ? ( + + + + ) : ( + + { + if (e.target.files?.[0]) setAddedPlatformLogo(e.target.files[0]); + }} + onSubmit={handlePlatformLogoUpload} + addedImage={addedPlatformLogo} + setAddedImage={setAddedPlatformLogo} + /> + {!addedPlatformLogo && platformLogoImageUrl && ( + + )} + + )} + + theme.palette.background.paper, + borderRadius: '10px', + padding: '16px' + }} + > + + Guest Page Links + + + + + + + + Links Config + +
); diff --git a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UsefulLinks/UsefulLinksTable.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UsefulLinks/UsefulLinksTable.tsx index 05adc19bfd..7bfd4bdf7c 100644 --- a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UsefulLinks/UsefulLinksTable.tsx +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UsefulLinks/UsefulLinksTable.tsx @@ -26,22 +26,26 @@ import EditUsefulLinkModal from './EditUsefulLinkModal'; import { linkToLinkCreateArgs } from '../../../../utils/link.utils'; import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; -const UsefulLinksTable = () => { +interface UsefulLinksTableProps { + isOnGuestHomePage?: boolean; +} + +const UsefulLinksTable = ({ isOnGuestHomePage }: UsefulLinksTableProps) => { const currentUser = useCurrentUser(); const { - data: usefulLinks, + data: links, isLoading: usefulLinksIsLoading, isError: usefulLinksIsError, error: usefulLinksError } = useAllUsefulLinks(); const { mutateAsync } = useSetUsefulLinks(); - const { data: linkTypes, isLoading: linkTypesIsLoading } = useAllLinkTypes(); + const { data: linkTypesBeforeFilter, isLoading: linkTypesIsLoading } = useAllLinkTypes(); const [linkToDelete, setLinkToDelete] = useState(); const [editingLink, setEditingLink] = useState(); const [showCreateModel, setShowCreateModel] = useState(false); - if (!usefulLinks || usefulLinksIsLoading || !linkTypes || linkTypesIsLoading) return ; + if (!links || usefulLinksIsLoading || !linkTypesBeforeFilter || linkTypesIsLoading) return ; if (usefulLinksIsError) return ; const handleDelete = (allLinks: Link[], linkToDelete: Link) => { @@ -50,13 +54,24 @@ const UsefulLinksTable = () => { setLinkToDelete(undefined); }; + const linkTypes = linkTypesBeforeFilter.filter((linkType) => + isOnGuestHomePage ? linkType.isOnGuestHomePage : !linkType.isOnGuestHomePage + ); + + const usefulLinks = links.filter((link) => + isOnGuestHomePage ? link.linkType?.isOnGuestHomePage : !link.linkType?.isOnGuestHomePage + ); + + console.log('Links: ', links); + console.log('Links after filter: ', usefulLinks); + console.log('isOnGuestHomePage:', isOnGuestHomePage); return ( setShowCreateModel(false)} linkTypes={linkTypes} - currentLinks={usefulLinks} + currentLinks={links} /> {editingLink && ( { }} linkType={editingLink} linkTypes={linkTypes} - currentLinks={usefulLinks} + currentLinks={links} /> )} diff --git a/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/CreateLinkTypeModal.tsx b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/CreateLinkTypeModal.tsx index ef6085daec..382e16cecb 100644 --- a/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/CreateLinkTypeModal.tsx +++ b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/CreateLinkTypeModal.tsx @@ -8,15 +8,24 @@ interface CreateLinkTypeModalProps { open: boolean; handleClose: () => void; linkTypes: LinkType[]; + isOnGuestHomePage?: boolean; } -const CreateLinkTypeModal = ({ open, handleClose, linkTypes }: CreateLinkTypeModalProps) => { +const CreateLinkTypeModal = ({ open, handleClose, linkTypes, isOnGuestHomePage }: CreateLinkTypeModalProps) => { const { isLoading, isError, error, mutateAsync } = useCreateLinkType(); if (isError) return ; if (isLoading) return ; - return ; + return ( + + ); }; export default CreateLinkTypeModal; diff --git a/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/EditLinkTypeModal.tsx b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/EditLinkTypeModal.tsx index 785b8982d9..a61600c5d3 100644 --- a/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/EditLinkTypeModal.tsx +++ b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/EditLinkTypeModal.tsx @@ -24,6 +24,7 @@ const EditLinkTypeModal = ({ open, handleClose, linkType, linkTypes }: EditLinkT onSubmit={mutateAsync} defaultValues={linkType} linkTypes={linkTypes} + isOnGuestHomePage={linkType.isOnGuestHomePage} /> ); }; diff --git a/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/LinkTypeFormModal.tsx b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/LinkTypeFormModal.tsx index 112b5b131f..2a75d7943b 100644 --- a/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/LinkTypeFormModal.tsx +++ b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/LinkTypeFormModal.tsx @@ -16,9 +16,17 @@ interface LinkTypeFormModalProps { defaultValues?: LinkType; onSubmit: (data: LinkTypeCreatePayload) => void; linkTypes: LinkType[]; + isOnGuestHomePage?: boolean; } -const LinkTypeFormModal = ({ open, handleClose, defaultValues, onSubmit, linkTypes }: LinkTypeFormModalProps) => { +const LinkTypeFormModal = ({ + open, + handleClose, + defaultValues, + onSubmit, + linkTypes, + isOnGuestHomePage +}: LinkTypeFormModalProps) => { const toast = useToast(); const creatingNew = defaultValues === undefined; @@ -31,7 +39,8 @@ const LinkTypeFormModal = ({ open, handleClose, defaultValues, onSubmit, linkTyp .required('LinkType Name is Required') .test('unique-LinkType-test', 'LinkType name must be unique', uniqueLinkTypeTest), iconName: yup.string().required('Icon name is required'), - required: yup.boolean().required('Required field must be specified') + required: yup.boolean().required('Required field must be specified'), + isOnGuestHomePage: yup.boolean().required('Guest page field must be specified') }); const theme = useTheme(); @@ -47,7 +56,8 @@ const LinkTypeFormModal = ({ open, handleClose, defaultValues, onSubmit, linkTyp defaultValues: { name: defaultValues?.name ?? '', iconName: defaultValues?.iconName ?? '', - required: defaultValues?.required ?? false + required: defaultValues?.required ?? false, + isOnGuestHomePage: isOnGuestHomePage ?? false } }); @@ -88,17 +98,19 @@ const LinkTypeFormModal = ({ open, handleClose, defaultValues, onSubmit, linkTyp {errors.name?.message} - - - Required - } - /> - {errors.required?.message} - - + {!isOnGuestHomePage && ( + + + Required + } + /> + {errors.required?.message} + + + )} diff --git a/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/LinkTypeTable.tsx b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/LinkTypeTable.tsx index aae45e2a84..ec5cf130e0 100644 --- a/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/LinkTypeTable.tsx +++ b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/LinkTypes/LinkTypeTable.tsx @@ -10,20 +10,22 @@ import NERTable from '../../../../components/NERTable'; import { isAdmin, LinkType } from 'shared'; import { useCurrentUser } from '../../../../hooks/users.hooks'; -const LinkTypeTable = () => { +interface LinkTypeTableProps { + isOnGuestHomePage?: boolean; +} + +const LinkTypeTable = ({ isOnGuestHomePage }: LinkTypeTableProps) => { const currentUser = useCurrentUser(); - const { - data: linkTypes, - isLoading: linkTypeIsLoading, - isError: linkTypeIsError, - error: linkTypeError - } = useAllLinkTypes(); + const { data: links, isLoading: linkTypeIsLoading, isError: linkTypeIsError, error: linkTypeError } = useAllLinkTypes(); const [createModalShow, setCreateModalShow] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [clickedLinkType, setClickedLinkType] = useState(); - if (!linkTypes || linkTypeIsLoading) return ; + if (!links || linkTypeIsLoading) return ; if (linkTypeIsError) return ; + const linkTypes = links.filter((linkType) => + isOnGuestHomePage ? linkType.isOnGuestHomePage : !linkType.isOnGuestHomePage + ); const linkTypeTableRows = linkTypes.map((linkType, index) => ( { return ( - setCreateModalShow(false)} linkTypes={linkTypes} /> + setCreateModalShow(false)} + linkTypes={linkTypes} + isOnGuestHomePage={isOnGuestHomePage} + /> {clickedLinkType && ( { - const { - mutateAsync: organizationImages, - isLoading: organizationImagesIsLoading, - isError: organizationImagesIsError, - error: organizationImagesError - } = useSetOrganizationImages(); - - const toast = useToast(); - const { data: organization, isLoading: organizationIsLoading, @@ -28,48 +14,11 @@ const AdminToolsRecruitmentConfig: React.FC = () => { error: organizationError } = useCurrentOrganization(); - const { data: applyInterestImageUrl } = useGetImageUrl(organization?.applyInterestImageId ?? null); - const { data: exploreGuestImageUrl } = useGetImageUrl(organization?.exploreAsGuestImageId ?? null); - - const [addedImage1, setAddedImage1] = useState(undefined); - const [addedImage2, setAddedImage2] = useState(undefined); - const [isUploadingApply, setIsUploadingApply] = useState(false); - const [isUploadingExplore, setIsUploadingExplore] = useState(false); - if (organizationIsError) { return ; } - if (organizationImagesIsLoading || !organization || organizationIsLoading) return ; - - const handleFileUpload = async (files: File[], type: 'exploreAsGuest' | 'applyInterest') => { - const validFiles: File[] = []; - files.forEach((file) => { - if (file.size < MAX_FILE_SIZE) { - if (type === 'applyInterest') { - validFiles[0] = file; - } else if (type === 'exploreAsGuest') { - validFiles[1] = file; - } - } else { - toast.error(`Error uploading ${file.name}; file must be less than ${MAX_FILE_SIZE / 1024 / 1024} MB`, 5000); - } - }); - - if (validFiles.length > 0) { - try { - type === 'applyInterest' ? setIsUploadingApply(true) : setIsUploadingExplore(true); - await organizationImages(validFiles); - toast.success('Image uploaded successfully!'); - } catch (error: any) { - if (organizationImagesIsError && organizationImagesError instanceof Error) { - toast.error(organizationImagesError.message); - } - } finally { - type === 'applyInterest' ? setIsUploadingApply(false) : setIsUploadingExplore(false); - } - } - }; + if (!organization || organizationIsLoading) return ; return ( @@ -89,88 +38,6 @@ const AdminToolsRecruitmentConfig: React.FC = () => { - - - Recruitment Images - - - - - Apply Interest Image - - {isUploadingApply ? ( - - - - ) : ( - <> - {!addedImage1 && applyInterestImageUrl && ( - - )} - { - if (e.target.files) { - setAddedImage1(e.target.files[0]); - } - }} - onSubmit={() => { - if (addedImage1) { - handleFileUpload([addedImage1], 'applyInterest'); - setAddedImage1(undefined); - } - }} - addedImage={addedImage1} - setAddedImage={setAddedImage1} - /> - - )} - - - - - Explore As Guest Image - - {isUploadingExplore ? ( - - - - ) : ( - <> - {!addedImage2 && exploreGuestImageUrl && ( - - )} - { - if (e.target.files) { - setAddedImage2(e.target.files[0]); - } - }} - onSubmit={() => { - if (addedImage2) { - handleFileUpload([addedImage2], 'exploreAsGuest'); - setAddedImage2(undefined); - } - }} - addedImage={addedImage2} - setAddedImage={setAddedImage2} - /> - - )} - - - ); diff --git a/src/frontend/src/pages/HomePage/GuestLandingPage.tsx b/src/frontend/src/pages/HomePage/GuestLandingPage.tsx deleted file mode 100644 index c9839917a3..0000000000 --- a/src/frontend/src/pages/HomePage/GuestLandingPage.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { Typography, Box } from '@mui/material'; -import PageLayout from '../../components/PageLayout'; -import ImageWithButton from './components/ImageWithButton'; -import { useHistory } from 'react-router-dom'; -import { routes } from '../../utils/routes'; -import { useCurrentUser } from '../../hooks/users.hooks'; -import { useEffect } from 'react'; -import { useHomePageContext } from '../../app/HomePageContext'; -import { useCurrentOrganization } from '../../hooks/organizations.hooks'; -import LoadingIndicator from '../../components/LoadingIndicator'; -import ErrorPage from '../ErrorPage'; -import { useGetImageUrl } from '../../hooks/onboarding.hook'; - -const GuestHomePage = () => { - const user = useCurrentUser(); - const history = useHistory(); - const { - data: organization, - isLoading: organizationIsLoading, - isError: organizationIsError, - error: organizationError - } = useCurrentOrganization(); - const { setCurrentHomePage } = useHomePageContext(); - - const { - data: applyInterestImageUrl, - isLoading: applyImageLoading, - isError: applyImageIsError, - error: applyImageError - } = useGetImageUrl(organization?.applyInterestImageId ?? null); - const { - data: exploreGuestImageUrl, - isLoading: exploreImageLoading, - isError: exploreImageIsError, - error: exploreImageError - } = useGetImageUrl(organization?.exploreAsGuestImageId ?? null); - - useEffect(() => { - setCurrentHomePage('guest'); - }, [setCurrentHomePage]); - - if (organizationIsError) { - return ; - } - if (applyImageIsError) return ; - if (exploreImageIsError) return ; - - if (!organization || organizationIsLoading || applyImageLoading || exploreImageLoading) return ; - if (!applyInterestImageUrl || !exploreGuestImageUrl) return ; - - return ( - - - {user ? `Welcome, ${user.firstName}!` : 'Welcome, Guest!'} - - - - history.push(routes.HOME_PNM)} - /> - history.push(routes.HOME_MEMBER)} - /> - - - - ); -}; - -export default GuestHomePage; diff --git a/src/frontend/src/pages/HomePage/IntroGuestHomePage.tsx b/src/frontend/src/pages/HomePage/IntroGuestHomePage.tsx index 08e2bd763b..85c80e32b7 100644 --- a/src/frontend/src/pages/HomePage/IntroGuestHomePage.tsx +++ b/src/frontend/src/pages/HomePage/IntroGuestHomePage.tsx @@ -1,19 +1,19 @@ -import { Typography, Box } from '@mui/material'; -import PageLayout from '../../components/PageLayout'; -import ImageWithButton from './components/ImageWithButton'; -import { useHistory } from 'react-router-dom'; -import { routes } from '../../utils/routes'; -import { useCurrentUser } from '../../hooks/users.hooks'; +import { Typography, Box, Icon, Card, CardContent } from '@mui/material'; +import Link from '@mui/material/Link'; +import { Link as RouterLink } from 'react-router-dom'; import { useEffect } from 'react'; import { useHomePageContext } from '../../app/HomePageContext'; import { useCurrentOrganization } from '../../hooks/organizations.hooks'; import LoadingIndicator from '../../components/LoadingIndicator'; import ErrorPage from '../ErrorPage'; import { useGetImageUrl } from '../../hooks/onboarding.hook'; +import FeaturedProjects from './components/FeaturedProjects'; +import { useAllUsefulLinks } from '../../hooks/projects.hooks'; +import { Stack } from '@mui/system'; +import { routes } from '../../utils/routes'; +import { NERButton } from '../../components/NERButton'; const IntroGuestHomePage = () => { - const user = useCurrentUser(); - const history = useHistory(); const { data: organization, isLoading: organizationIsLoading, @@ -22,63 +22,118 @@ const IntroGuestHomePage = () => { } = useCurrentOrganization(); const { setCurrentHomePage } = useHomePageContext(); - const { - data: applyInterestImageUrl, - isLoading: applyImageLoading, - isError: applyImageIsError, - error: applyImageError - } = useGetImageUrl(organization?.applyInterestImageId ?? null); - const { - data: exploreGuestImageUrl, - isLoading: exploreImageLoading, - isError: exploreImageIsError, - error: exploreImageError - } = useGetImageUrl(organization?.exploreAsGuestImageId ?? null); - useEffect(() => { setCurrentHomePage('guest'); }, [setCurrentHomePage]); + const { + data: usefulLinks, + isLoading: usefulLinksIsLoading, + isError: usefulLinksIsError, + error: usefulLinksError + } = useAllUsefulLinks(); + + const { + data: finishlineImageUrl, + isError: finishlineImageIsError, + error: finishlineImageError + } = useGetImageUrl(organization?.platformLogoImageId ?? null); + if (organizationIsError) { return ; } - if (applyImageIsError) return ; - if (exploreImageIsError) return ; - if (!organization || organizationIsLoading || applyImageLoading || exploreImageLoading) return ; - if (!applyInterestImageUrl || !exploreGuestImageUrl) return ; + if (usefulLinksIsError) { + return ; + } + + if (finishlineImageIsError) { + return ; + } + + if (!organization || organizationIsLoading || !usefulLinks || usefulLinksIsLoading) return ; + + const guestPageLinks = usefulLinks?.filter((link) => link.linkType.isOnGuestHomePage); return ( - - - {user ? `Welcome, ${user.firstName}!` : 'Welcome, Guest!'} + + + FinishLine By NER + + + + + {organization.platformDescription} + + + + {guestPageLinks.map((link) => ( + + {link.linkType.iconName} + + ))} + + + - - history.push(routes.HOME_PNM)} - /> - history.push(routes.HOME_MEMBER)} - /> - + + + + Interested in becoming a member? + + + Join {organization.name} + + + + + + - +
); }; diff --git a/src/frontend/src/pages/HomePage/components/FeaturedProjectsCard.tsx b/src/frontend/src/pages/HomePage/components/FeaturedProjectsCard.tsx index 1fc2e6575c..e73bfd0309 100644 --- a/src/frontend/src/pages/HomePage/components/FeaturedProjectsCard.tsx +++ b/src/frontend/src/pages/HomePage/components/FeaturedProjectsCard.tsx @@ -17,7 +17,7 @@ const FeaturedProjectsCard: React.FC = ({ project }) => { minWidth: 'fit-content', minHeight: 'fit-content', width: isMobilePortrait ? '100%' : 'auto', - background: theme.palette.background.paper, + background: theme.palette.mode === 'dark' ? '#000000' : 'rgb(255, 255, 255)', borderRadius: 2 }} > diff --git a/src/frontend/src/pages/HomePage/components/GuestOrganizationInfo.tsx b/src/frontend/src/pages/HomePage/components/GuestOrganizationInfo.tsx index 2cd6779fbe..498631321f 100644 --- a/src/frontend/src/pages/HomePage/components/GuestOrganizationInfo.tsx +++ b/src/frontend/src/pages/HomePage/components/GuestOrganizationInfo.tsx @@ -30,7 +30,7 @@ const GuestOrganizationInfo = () => { const theme = useTheme(); const { data: organization, isLoading, isError, error } = useCurrentOrganization(); const { - data: usefulLinks, + data: links, isLoading: usefulLinksIsLoading, isError: usefulLinksIsError, error: usefulLinksError @@ -40,9 +40,11 @@ const GuestOrganizationInfo = () => { if (isLoading || !organization) return ; if (isError) return ; - if (!usefulLinks || usefulLinksIsLoading || !linkTypes || linkTypesIsLoading) return ; + if (!links || usefulLinksIsLoading || !linkTypes || linkTypesIsLoading) return ; if (usefulLinksIsError) return ; + const usefulLinks = links?.filter((link) => !link.linkType.isOnGuestHomePage); + return ( { error: organizationError } = useCurrentOrganization(); - const { data: links, isError: linksIsError, error: linksError, isLoading: linksIsLoading } = useAllUsefulLinks(); + const { data: usefulLinks, isError: linksIsError, error: linksError, isLoading: linksIsLoading } = useAllUsefulLinks(); if (organizationIsError) { return ; @@ -23,7 +23,9 @@ const OnboardingInfoSection: React.FC = () => { if (linksIsError) return ; - if (!organization || organizationIsLoading || !links || linksIsLoading) return ; + if (!organization || organizationIsLoading || !usefulLinks || linksIsLoading) return ; + + const links = usefulLinks?.filter((link) => !link.linkType.isOnGuestHomePage); return ( diff --git a/src/frontend/src/tests/test-support/test-data/projects.stub.ts b/src/frontend/src/tests/test-support/test-data/projects.stub.ts index 05c37cca77..2cd8e0fa4c 100644 --- a/src/frontend/src/tests/test-support/test-data/projects.stub.ts +++ b/src/frontend/src/tests/test-support/test-data/projects.stub.ts @@ -13,19 +13,22 @@ import { exampleResearchWorkPackage, exampleDesignWorkPackage, exampleManufactur const exampleConfluenceLinkType: LinkType = { name: 'Confluence', iconName: 'confluence', - required: true + required: true, + isOnGuestHomePage: false }; const exampleBomLinkType: LinkType = { name: 'BOM', iconName: 'bom', - required: true + required: true, + isOnGuestHomePage: false }; const exampleGDriveLinkType: LinkType = { name: 'Google Drive', iconName: 'google-drive', - required: true + required: true, + isOnGuestHomePage: false }; const exampleLinks: Link[] = [ diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 32943febe2..3393d6667d 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -377,16 +377,18 @@ const organizations = () => `${API_URL}/organizations`; const currentOrganization = () => `${organizations()}/current`; const organizationsUsefulLinks = () => `${organizations()}/useful-links`; const organizationsSetUsefulLinks = () => `${organizationsUsefulLinks()}/set`; -const organizationsSetImages = () => `${organizations()}/images/update`; const organizationsUpdateContacts = () => `${organizations()}/contacts/set`; const organizationsSetOnboardingText = () => `${organizations()}/onboardingText/set`; const organizationsUpdateApplicationLink = () => `${organizations()}/application-link/update`; const organizationsSetDescription = () => `${organizations()}/description/set`; +const organizationsSetPlatformDescription = () => `${organizations()}/platform-description/set`; const organizationsFeaturedProjects = () => `${organizations()}/featured-projects`; const organizationsLogoImage = () => `${organizations()}/logo`; const organizationsSetLogoImage = () => `${organizations()}/logo/update`; const organizationsNewMemberImage = () => `${organizations()}/new-member-image`; const organizationsSetNewMemberImage = () => `${organizations()}/new-member-image/update`; +const organizationsPlatformLogoImage = () => `${organizations()}/platform-logo`; +const organizationsSetPlatformLogoImage = () => `${organizationsPlatformLogoImage()}/update`; const organizationsSetFeaturedProjects = () => `${organizationsFeaturedProjects()}/set`; const organizationsSetWorkspaceId = () => `${organizations()}/workspaceId/set`; const organizationsGetPartReviewGuideLink = () => `${organizations()}/part-review-guide-link/get`; @@ -749,16 +751,18 @@ export const apiUrls = { currentOrganization, organizationsUsefulLinks, organizationsSetUsefulLinks, - organizationsSetImages, organizationsUpdateContacts, organizationsSetOnboardingText, organizationsUpdateApplicationLink, organizationsFeaturedProjects, organizationsSetDescription, + organizationsSetPlatformDescription, organizationsLogoImage, organizationsSetLogoImage, organizationsNewMemberImage, organizationsSetNewMemberImage, + organizationsPlatformLogoImage, + organizationsSetPlatformLogoImage, organizationsSetFeaturedProjects, organizationsSetWorkspaceId, organizationsGetPartReviewGuideLink, diff --git a/src/shared/src/types/project-types.ts b/src/shared/src/types/project-types.ts index fb1a22bad1..3b43e1129c 100644 --- a/src/shared/src/types/project-types.ts +++ b/src/shared/src/types/project-types.ts @@ -132,6 +132,7 @@ export interface LinkType { name: string; required: boolean; iconName: string; + isOnGuestHomePage: boolean; } export interface Link { @@ -186,6 +187,7 @@ export interface LinkTypeCreatePayload { name: string; iconName: string; required: boolean; + isOnGuestHomePage: boolean; } export interface DescriptionBulletTypeCreatePayload { diff --git a/src/shared/src/types/user-types.ts b/src/shared/src/types/user-types.ts index f0edc940cc..24cc141eac 100644 --- a/src/shared/src/types/user-types.ts +++ b/src/shared/src/types/user-types.ts @@ -48,9 +48,9 @@ export type OrganizationPreview = Pick< | 'dateDeleted' | 'description' | 'applicationLink' - | 'applyInterestImageId' - | 'exploreAsGuestImageId' | 'newMemberImageId' + | 'platformDescription' + | 'platformLogoImageId' >; export interface Organization { @@ -63,8 +63,6 @@ export interface Organization { treasurer?: User; advisor?: User; description: string; - applyInterestImageId?: string; - exploreAsGuestImageId?: string; newMemberImageId?: string; applicationLink?: string; onboardingText?: string; @@ -72,6 +70,8 @@ export interface Organization { slackWorkspaceId?: string; partReviewGuideLink?: string; sponsorshipNotificationsSlackChannelId?: string; + platformDescription: string; + platformLogoImageId?: string; } /**