diff --git a/src/backend/src/services/projects.services.ts b/src/backend/src/services/projects.services.ts index 304205f595..eb1c9d2726 100644 --- a/src/backend/src/services/projects.services.ts +++ b/src/backend/src/services/projects.services.ts @@ -46,17 +46,6 @@ import { import { getLinkQueryArgs } from '../prisma-query-args/links.query-args.js'; import { getDescriptionBulletQueryArgs } from '../prisma-query-args/description-bullets.query-args.js'; -const validateSingleLinkTypeDashboard = ( - isOnGuestHomePage: boolean, - isOnNewMemberDashboard: boolean, - isOnOnboardingDashboard: boolean -): void => { - const dashboardFlagCount = [isOnGuestHomePage, isOnNewMemberDashboard, isOnOnboardingDashboard].filter(Boolean).length; - if (dashboardFlagCount > 1) { - throw new HttpException(400, 'A LinkType can only be on one dashboard at a time'); - } -}; - export default class ProjectsService { /** * Get all the non deleted projects in the database for the given organization @@ -632,8 +621,6 @@ export default class ProjectsService { if (!(await userHasPermission(user.userId, organization.organizationId, isAdmin))) throw new AccessDeniedException('Only admins can create link types'); - validateSingleLinkTypeDashboard(isOnGuestHomePage, isOnNewMemberDashboard, isOnOnboardingDashboard); - const existingLinkType = await prisma.link_Type.findUnique({ where: { uniqueLinkType: { name, organizationId: organization.organizationId } } }); @@ -683,8 +670,6 @@ export default class ProjectsService { if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) throw new AccessDeniedException('Only an admin can update the linkType'); - validateSingleLinkTypeDashboard(isOnGuestHomePage, isOnNewMemberDashboard, isOnOnboardingDashboard); - // check if the linkType we are trying to update exists const linkType = await prisma.link_Type.findUnique({ where: { diff --git a/src/backend/tests/unmocked/link-type.test.ts b/src/backend/tests/unmocked/link-type.test.ts new file mode 100644 index 0000000000..369367ee02 --- /dev/null +++ b/src/backend/tests/unmocked/link-type.test.ts @@ -0,0 +1,107 @@ +import { Organization, User } from '@prisma/client'; +import { AccessDeniedException, HttpException, NotFoundException } from '../../src/utils/errors.utils.js'; +import { batmanAppAdmin, wonderwomanGuest } from '../test-data/users.test-data.js'; +import { createTestOrganization, createTestUser, resetUsers } from '../test-utils.js'; +import ProjectsService from '../../src/services/projects.services.js'; + +describe('LinkType Tests', () => { + let orgId: string; + let organization: Organization; + let testBatman: User; + + beforeEach(async () => { + organization = await createTestOrganization(); + orgId = organization.organizationId; + testBatman = await createTestUser(batmanAppAdmin, orgId); + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('Create LinkType', () => { + it('Fails if user is not an admin', async () => { + await expect( + ProjectsService.createLinkType( + await createTestUser(wonderwomanGuest, orgId), + 'GitHub', + 'code', + false, + organization, + false, + false, + false + ) + ).rejects.toThrow(new AccessDeniedException('Only admins can create link types')); + }); + + it('Fails if a LinkType with that name already exists in the organization', async () => { + await ProjectsService.createLinkType(testBatman, 'GitHub', 'code', false, organization, false, false, false); + + await expect( + ProjectsService.createLinkType(testBatman, 'GitHub', 'code', false, organization, false, false, false) + ).rejects.toThrow(new HttpException(400, 'LinkType with that name already exists in this organization.')); + }); + + it('Succeeds and creates a LinkType flagged for multiple dashboards at once', async () => { + const linkType = await ProjectsService.createLinkType( + testBatman, + 'GitHub', + 'code', + false, + organization, + false, + true, + true + ); + + expect(linkType.isOnNewMemberDashboard).toBe(true); + expect(linkType.isOnOnboardingDashboard).toBe(true); + expect(linkType.isOnGuestHomePage).toBe(false); + }); + }); + + describe('Edit LinkType', () => { + it('Fails if user is not an admin', async () => { + await ProjectsService.createLinkType(testBatman, 'GitHub', 'code', false, organization, false, false, false); + + await expect( + ProjectsService.editLinkType( + 'GitHub', + 'code', + false, + await createTestUser(wonderwomanGuest, orgId), + organization, + false, + false, + false + ) + ).rejects.toThrow(new AccessDeniedException('Only an admin can update the linkType')); + }); + + it('Fails if the LinkType does not exist', async () => { + await expect( + ProjectsService.editLinkType('Nonexistent', 'code', false, testBatman, organization, false, false, false) + ).rejects.toThrow(new NotFoundException('Link Type', 'Nonexistent')); + }); + + it('Succeeds and adds a second dashboard flag onto a LinkType already on another dashboard', async () => { + await ProjectsService.createLinkType(testBatman, 'GitHub', 'code', false, organization, false, false, true); + + const updatedLinkType = await ProjectsService.editLinkType( + 'GitHub', + 'code', + false, + testBatman, + organization, + false, + true, + true + ); + + expect(updatedLinkType.isOnOnboardingDashboard).toBe(true); + expect(updatedLinkType.isOnNewMemberDashboard).toBe(true); + expect(updatedLinkType.isOnGuestHomePage).toBe(false); + }); + }); +}); diff --git a/src/frontend/src/hooks/projects.hooks.ts b/src/frontend/src/hooks/projects.hooks.ts index 1381a0064c..5f49d5a89c 100644 --- a/src/frontend/src/hooks/projects.hooks.ts +++ b/src/frontend/src/hooks/projects.hooks.ts @@ -297,6 +297,27 @@ export const useEditLinkType = (linkTypeName: string) => { ); }; +/** + * Custom React Hook to edit a LinkType, given the name of the LinkType at call time rather than + * hook-mount time. Used when the LinkType being edited isn't known until a form is submitted, + * e.g. flipping a dashboard placement flag on whichever LinkType was picked from a dropdown. + */ +export const useEditLinkTypeByName = () => { + const queryClient = useQueryClient(); + return useMutation( + ['linkTypes', 'edit'], + async ({ name, data }) => { + const { data: updatedLinkType } = await editLinkType(name, data); + return updatedLinkType; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['linkTypes']); + } + } + ); +}; + /** * Custom React Hook to get all useful links */ diff --git a/src/frontend/src/pages/AdminToolsPage/AdminToolsRecruitment.tsx b/src/frontend/src/pages/AdminToolsPage/AdminToolsRecruitment.tsx index ff4cdd052c..64d0dfa99e 100644 --- a/src/frontend/src/pages/AdminToolsPage/AdminToolsRecruitment.tsx +++ b/src/frontend/src/pages/AdminToolsPage/AdminToolsRecruitment.tsx @@ -20,7 +20,7 @@ const AdminToolsProjectsConfig: React.FC = () => { Useful Links - + Description Bullet Types diff --git a/src/frontend/src/pages/AdminToolsPage/EditGuestView/GuestViewConfig.tsx b/src/frontend/src/pages/AdminToolsPage/EditGuestView/GuestViewConfig.tsx index de745504c6..5431c01302 100644 --- a/src/frontend/src/pages/AdminToolsPage/EditGuestView/GuestViewConfig.tsx +++ b/src/frontend/src/pages/AdminToolsPage/EditGuestView/GuestViewConfig.tsx @@ -170,7 +170,7 @@ const GuestViewConfig: React.FC = () => { > Guest Page Links - + diff --git a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/NewMemberDashboardUsefulLinksSection.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/NewMemberDashboardUsefulLinksSection.tsx index 6fa92594c4..98533394de 100644 --- a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/NewMemberDashboardUsefulLinksSection.tsx +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/NewMemberDashboardUsefulLinksSection.tsx @@ -27,7 +27,7 @@ const NewMemberDashboardUsefulLinksSection: React.FC = () => { New Member Dashboard Useful Links - + ); }; diff --git a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingConfigSection.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingConfigSection.tsx index d583dd4fd7..4617aa48a9 100644 --- a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingConfigSection.tsx +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/OnboardingConfigSection.tsx @@ -62,7 +62,7 @@ const OnboardingConfigSection: React.FC = () => { Onboarding Page Useful Links - + diff --git a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UsefulLinks/CreateUsefulLinkModal.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UsefulLinks/CreateUsefulLinkModal.tsx index 8f1ad9184d..1309680ef5 100644 --- a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UsefulLinks/CreateUsefulLinkModal.tsx +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UsefulLinks/CreateUsefulLinkModal.tsx @@ -9,9 +9,20 @@ interface CreateUsefulLinkModalProps { handleClose: () => void; linkTypes: LinkType[]; currentLinks: Link[]; + isOnGuestHomePage: boolean; + isOnNewMemberDashboard: boolean; + isOnOnboardingDashboard: boolean; } -const CreateUsefulLinkModal = ({ open, handleClose, linkTypes, currentLinks }: CreateUsefulLinkModalProps) => { +const CreateUsefulLinkModal = ({ + open, + handleClose, + linkTypes, + currentLinks, + isOnGuestHomePage, + isOnNewMemberDashboard, + isOnOnboardingDashboard +}: CreateUsefulLinkModalProps) => { const { isLoading, isError, error, mutateAsync } = useSetUsefulLinks(); if (isError) return ; @@ -24,6 +35,9 @@ const CreateUsefulLinkModal = ({ open, handleClose, linkTypes, currentLinks }: C onSubmit={mutateAsync} linkTypes={linkTypes} currentLinks={currentLinks} + isOnGuestHomePage={isOnGuestHomePage} + isOnNewMemberDashboard={isOnNewMemberDashboard} + isOnOnboardingDashboard={isOnOnboardingDashboard} /> ); }; diff --git a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UsefulLinks/EditUsefulLinkModal.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UsefulLinks/EditUsefulLinkModal.tsx index 4cb9b3abb0..1c3caf21f3 100644 --- a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UsefulLinks/EditUsefulLinkModal.tsx +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UsefulLinks/EditUsefulLinkModal.tsx @@ -10,9 +10,21 @@ interface EditUsefulLinkModalProps { linkType: Link; linkTypes: LinkType[]; currentLinks: Link[]; + isOnGuestHomePage: boolean; + isOnNewMemberDashboard: boolean; + isOnOnboardingDashboard: boolean; } -const EditUsefulLinkModal = ({ open, handleClose, linkType, linkTypes, currentLinks }: EditUsefulLinkModalProps) => { +const EditUsefulLinkModal = ({ + open, + handleClose, + linkType, + linkTypes, + currentLinks, + isOnGuestHomePage, + isOnNewMemberDashboard, + isOnOnboardingDashboard +}: EditUsefulLinkModalProps) => { const { isLoading, isError, error, mutateAsync } = useSetUsefulLinks(); if (isError) return ; @@ -26,6 +38,9 @@ const EditUsefulLinkModal = ({ open, handleClose, linkType, linkTypes, currentLi defaulValues={linkType} linkTypes={linkTypes} currentLinks={currentLinks} + isOnGuestHomePage={isOnGuestHomePage} + isOnNewMemberDashboard={isOnNewMemberDashboard} + isOnOnboardingDashboard={isOnOnboardingDashboard} /> ); }; diff --git a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UsefulLinks/UsefulLinkFormModal.tsx b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UsefulLinks/UsefulLinkFormModal.tsx index 0fbc3b2b34..686897ba61 100644 --- a/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UsefulLinks/UsefulLinkFormModal.tsx +++ b/src/frontend/src/pages/AdminToolsPage/OnboardingConfig/UsefulLinks/UsefulLinkFormModal.tsx @@ -7,14 +7,18 @@ import * as yup from 'yup'; import { yupResolver } from '@hookform/resolvers/yup'; import { Link, LinkCreateArgs, LinkType } from 'shared'; import { linkToLinkCreateArgs } from '../../../../utils/link.utils'; +import { useEditLinkTypeByName } from '../../../../hooks/projects.hooks'; interface UsefulLinkFormModalProps { open: boolean; handleClose: () => void; defaulValues?: Link; - onSubmit: (data: LinkCreateArgs[]) => void; + onSubmit: (data: LinkCreateArgs[]) => Promise; linkTypes: LinkType[]; currentLinks: Link[]; + isOnGuestHomePage: boolean; + isOnNewMemberDashboard: boolean; + isOnOnboardingDashboard: boolean; } const UsefulLinkFormModal = ({ @@ -23,9 +27,13 @@ const UsefulLinkFormModal = ({ defaulValues, onSubmit, linkTypes, - currentLinks + currentLinks, + isOnGuestHomePage, + isOnNewMemberDashboard, + isOnOnboardingDashboard }: UsefulLinkFormModalProps) => { const toast = useToast(); + const { mutateAsync: editLinkTypeAsync } = useEditLinkTypeByName(); const schema = yup.object().shape({ linkTypeName: yup.string().required('LinkType is Required'), @@ -47,17 +55,38 @@ const UsefulLinkFormModal = ({ const onFormSubmit = async (data: LinkCreateArgs) => { try { + const selectedLinkType = linkTypes.find((linkType) => linkType.name === data.linkTypeName); + const needsDashboardFlag = + selectedLinkType && + ((isOnGuestHomePage && !selectedLinkType.isOnGuestHomePage) || + (isOnNewMemberDashboard && !selectedLinkType.isOnNewMemberDashboard) || + (isOnOnboardingDashboard && !selectedLinkType.isOnOnboardingDashboard)); + + if (selectedLinkType && needsDashboardFlag) { + await editLinkTypeAsync({ + name: selectedLinkType.name, + data: { + name: selectedLinkType.name, + iconName: selectedLinkType.iconName, + required: selectedLinkType.required, + isOnGuestHomePage: selectedLinkType.isOnGuestHomePage || isOnGuestHomePage, + isOnNewMemberDashboard: selectedLinkType.isOnNewMemberDashboard || isOnNewMemberDashboard, + isOnOnboardingDashboard: selectedLinkType.isOnOnboardingDashboard || isOnOnboardingDashboard + } + }); + } + const previousLinks = linkToLinkCreateArgs(currentLinks); const newLinks = defaulValues ? [...previousLinks.filter((link) => link.linkId !== defaulValues.linkId), data] : [...previousLinks, data]; - onSubmit(newLinks); + await onSubmit(newLinks); + handleClose(); } catch (error: unknown) { if (error instanceof Error) { toast.error(error.message); } } - handleClose(); }; return ( { @@ -83,8 +83,6 @@ const UsefulLinksTable = ({ isOnGuestHomePage, isOnNewMemberDashboard, isOnOnboa return !linkType.isOnGuestHomePage && !linkType.isOnNewMemberDashboard && !linkType.isOnOnboardingDashboard; }; - const linkTypes = linkTypesBeforeFilter.filter(matchesDashboard); - const usefulLinks = links.filter((link) => matchesDashboard(link.linkType)); return ( @@ -92,8 +90,11 @@ const UsefulLinksTable = ({ isOnGuestHomePage, isOnNewMemberDashboard, isOnOnboa setShowCreateModel(false)} - linkTypes={linkTypes} + linkTypes={linkTypesBeforeFilter} currentLinks={links} + isOnGuestHomePage={isOnGuestHomePage} + isOnNewMemberDashboard={isOnNewMemberDashboard} + isOnOnboardingDashboard={isOnOnboardingDashboard} /> {editingLink && ( )}