diff --git a/packages/api/src/api/project/post.ts b/packages/api/src/api/project/post.ts index e8d916c8..a897613f 100644 --- a/packages/api/src/api/project/post.ts +++ b/packages/api/src/api/project/post.ts @@ -40,6 +40,7 @@ export const post = async (req: Request, res: Response) => { project.contributors.add(lockedUser); em.persist(project); }); + if (!project) throw new Error('Project not created'); } catch (error) { if ((error as Error).name === 'NotFoundError') { // User already has project @@ -62,5 +63,6 @@ export const post = async (req: Request, res: Response) => { return; } - res.send(project); + const projectWithInviteCode = { ...project.toPOJO(), inviteCode: project.inviteCode }; + res.send(projectWithInviteCode); }; diff --git a/packages/api/tests/api/project/post.test.ts b/packages/api/tests/api/project/post.test.ts index 841d0575..5534f45a 100644 --- a/packages/api/tests/api/project/post.test.ts +++ b/packages/api/tests/api/project/post.test.ts @@ -19,26 +19,48 @@ jest.mock('../../../src/utils/validatePayload'); const validatePayloadMock = getMock(validatePayload); describe('project post endpoint', () => { - it('should create a project, add a contributor, and return a 200', async () => { - const data = { name: 'A cool project' }; - validatePayloadMock.mockReturnValueOnce({ errorHandled: false, data } as any); + it('should create a project, add a contributor, and return project with inviteCode and a 200 status', async () => { + const mockProjectData = { name: 'A cool project' }; + + validatePayloadMock.mockReturnValueOnce({ errorHandled: false, data: mockProjectData } as any); + const mockUser = { id: '1' }; const req = createMockRequest({ user: mockUser as any }); + const res = createMockResponse(); + const { entityManager } = req; entityManager.findOneOrFail.mockResolvedValueOnce(mockUser); - const res = createMockResponse(); - const mockProject = { contributors: { add: jest.fn() } }; + + const entityData = { + // Data returned from the creation of the entity + ...mockProjectData, + id: '123', + inviteCode: 'a code', + }; + const mockProject = { + ...entityData, + contributors: { add: jest.fn() }, + toPOJO: jest.fn(), + }; (Project.prototype.constructor as jest.Mock).mockReturnValueOnce(mockProject); + + const { inviteCode, ...entityDataWithoutInviteCode } = { ...entityData }; + mockProject.toPOJO.mockReturnValueOnce(entityDataWithoutInviteCode); + (axios.get as jest.Mock).mockResolvedValueOnce({ status: 200 }); await post(req as any, res as any); - expect(Project.prototype.constructor as jest.Mock).toHaveBeenCalledWith(data); + expect(Project.prototype.constructor as jest.Mock).toHaveBeenCalledWith(mockProjectData); expect(entityManager.transactional).toBeCalledTimes(1); expect(entityManager.findOneOrFail).toBeCalledTimes(1); expect(mockProject.contributors.add).toBeCalledWith(mockUser); expect(entityManager.persist).toBeCalledWith(mockProject); - expect(res.send).toHaveBeenCalledWith(mockProject); + expect(mockProject.toPOJO).toBeCalledTimes(1); + expect(res.send).toHaveBeenCalledWith({ + inviteCode, + ...entityDataWithoutInviteCode, + }); }); it('should return 409 if the project already exists', async () => { @@ -130,6 +152,22 @@ describe('project post endpoint', () => { expect(req.entityManager.transactional).not.toBeCalled(); }); + it('should throw an error of project not created', async () => { + const data = { name: 'A cool project' }; + validatePayloadMock.mockReturnValueOnce({ errorHandled: false, data } as any); + const req = createMockRequest(); + const res = createMockResponse(); + (req.entityManager.transactional as jest.Mock).mockResolvedValueOnce(undefined); + (axios.get as jest.Mock).mockResolvedValueOnce({ status: 200 }); + + await post(req as any, res as any); + + expect(req.entityManager.transactional).toBeCalledTimes(1); + expect(req.entityManager.findOneOrFail).not.toBeCalled(); + expect(req.entityManager.persist).not.toBeCalled(); + expect(res.sendStatus).toHaveBeenCalledWith(500); + }); + it('returns a 400 when there is a duplicate constraint violation', async () => { const data = { name: 'A cool project' }; validatePayloadMock.mockReturnValueOnce({ errorHandled: false, data } as any); diff --git a/packages/database/src/entities/Project.ts b/packages/database/src/entities/Project.ts index 99281c66..5ff9a559 100644 --- a/packages/database/src/entities/Project.ts +++ b/packages/database/src/entities/Project.ts @@ -15,6 +15,7 @@ import { Node } from './Node'; import { User } from './User'; export type ProjectDTO = EntityDTO; +export type ProjectDTOWithInviteCode = ProjectDTO & Pick; export type ProjectConstructorValues = ConstructorValues< Project, diff --git a/packages/shared/src/types/entities/Project.ts b/packages/shared/src/types/entities/Project.ts index 82166dec..dfde1e5e 100644 --- a/packages/shared/src/types/entities/Project.ts +++ b/packages/shared/src/types/entities/Project.ts @@ -1,5 +1,6 @@ -import { ProjectDTO } from '@hangar/database'; +import { ProjectDTO, ProjectDTOWithInviteCode } from '@hangar/database'; import { Node, SerializedNode } from './Node'; export type Project = Node; +export type ProjectWithInviteCode = Node; export type SerializedProject = SerializedNode; diff --git a/packages/web/src/components/ProjectRegistrationButton/CopyProjectInviteCode/CopyProjectInviteCode.tsx b/packages/web/src/components/ProjectRegistrationButton/CopyProjectInviteCode/CopyProjectInviteCode.tsx new file mode 100644 index 00000000..5614df95 --- /dev/null +++ b/packages/web/src/components/ProjectRegistrationButton/CopyProjectInviteCode/CopyProjectInviteCode.tsx @@ -0,0 +1,54 @@ +import { + Box, + Button, + Text, + Code, + Flex, + Heading, + useClipboard, + Alert, + AlertIcon, +} from '@chakra-ui/react'; +import { ProjectWithInviteCode } from '@hangar/shared'; +import React from 'react'; +import { env } from '../../../env'; +import { openSuccessToast } from '../../utils/CustomToast'; + +type CopyProjectInviteCodeProps = { project: ProjectWithInviteCode }; + +export const CopyProjectInviteCode: React.FC = ({ project }) => { + const inviteCodeUrl = `${env.baseUrl}?projectInviteCode=${project.inviteCode}`; + const { onCopy } = useClipboard(inviteCodeUrl); + return ( + + + Invite Your Team + Share the link below to add others to this project. + + + { + onCopy(); + openSuccessToast({ title: 'Link Copied' }); + }} + direction={'column'} + alignItems={'center'} + gap={2} + cursor={'pointer'} + > + {inviteCodeUrl} + + + + + + + + + This is the only point where you can access the invite code. Make sure to copy it before + leaving this page. + + + + ); +}; diff --git a/packages/web/src/components/ProjectRegistrationButton/CopyProjectInviteCode/index.tsx b/packages/web/src/components/ProjectRegistrationButton/CopyProjectInviteCode/index.tsx new file mode 100644 index 00000000..fdd645d2 --- /dev/null +++ b/packages/web/src/components/ProjectRegistrationButton/CopyProjectInviteCode/index.tsx @@ -0,0 +1 @@ +export * from './CopyProjectInviteCode'; diff --git a/packages/web/src/components/ProjectRegistrationButton/ProjectRegistrationButton.tsx b/packages/web/src/components/ProjectRegistrationButton/ProjectRegistrationButton.tsx index 05b125f6..351021fe 100644 --- a/packages/web/src/components/ProjectRegistrationButton/ProjectRegistrationButton.tsx +++ b/packages/web/src/components/ProjectRegistrationButton/ProjectRegistrationButton.tsx @@ -10,15 +10,18 @@ import { useDisclosure, } from '@chakra-ui/react'; import { useRouter } from 'next/router'; +import { ProjectWithInviteCode } from '@hangar/shared'; import { ProjectRegistrationForm } from '../ProjectRegistrationForm'; import { useUserStore } from '../../stores/user'; import { useRedirectToAuth } from '../layout/RedirectToAuthModal'; +import { CopyProjectInviteCode } from './CopyProjectInviteCode/CopyProjectInviteCode'; const openModalQueryKey = 'registration'; export const ProjectRegistrationButton: React.FC = () => { const router = useRouter(); const hasHandledQueryRef = React.useRef(false); + const [newProject, setNewProject] = React.useState(); const { isOpen, onOpen, onClose } = useDisclosure(); const { triggerRedirect } = useRedirectToAuth(); @@ -45,14 +48,24 @@ export const ProjectRegistrationButton: React.FC = () => { <> - + Project Registration - + {newProject ? ( + + ) : ( + { + if ('inviteCode' in project) { + setNewProject(project); + } + }} + /> + )} diff --git a/packages/web/src/components/ProjectRegistrationForm/ProjectRegistrationForm.tsx b/packages/web/src/components/ProjectRegistrationForm/ProjectRegistrationForm.tsx index 2bab17e3..512db542 100644 --- a/packages/web/src/components/ProjectRegistrationForm/ProjectRegistrationForm.tsx +++ b/packages/web/src/components/ProjectRegistrationForm/ProjectRegistrationForm.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ /* eslint-disable no-underscore-dangle */ import React from 'react'; import { @@ -41,6 +42,7 @@ export const ProjectRegistrationForm: React.FC = ({ project, onComplete, }); + return (
diff --git a/packages/web/src/components/ProjectRegistrationForm/useProjectRegistrationForm.tsx b/packages/web/src/components/ProjectRegistrationForm/useProjectRegistrationForm.tsx index 59d7e18c..572d3397 100644 --- a/packages/web/src/components/ProjectRegistrationForm/useProjectRegistrationForm.tsx +++ b/packages/web/src/components/ProjectRegistrationForm/useProjectRegistrationForm.tsx @@ -2,8 +2,9 @@ import React from 'react'; import { ZodFormattedError, z } from 'zod'; import { useFormik } from 'formik'; import axios, { isAxiosError } from 'axios'; -import { Project, Schema } from '@hangar/shared'; -import { openErrorToast, openSuccessToast } from '../utils/CustomToast'; +import { Project, ProjectWithInviteCode, Schema, SerializedProject } from '@hangar/shared'; +import dayjs from 'dayjs'; +import { openErrorToast } from '../utils/CustomToast'; import { useUserStore } from '../../stores/user'; type CreateProjectValues = z.infer; @@ -12,7 +13,7 @@ type CreateOrUpdateProjectValues = CreateProjectValues | UpdateProjectValues; export type RegistrationFormProps = { project?: Project; - onComplete?: (project: Project) => void; + onComplete: (project: Project | ProjectWithInviteCode) => void; }; export const useProjectRegistrationForm = ({ onComplete, project }: RegistrationFormProps) => { @@ -46,7 +47,7 @@ export const useProjectRegistrationForm = ({ onComplete, project }: Registration async onSubmit(values) { try { setIsLoading(true); - const { data: updatedProject } = await axios( + const { data: projectData } = await axios( projectExists ? `/api/project/${id}` : `/api/project`, { method: projectExists ? 'PUT' : 'POST', @@ -57,12 +58,13 @@ export const useProjectRegistrationForm = ({ onComplete, project }: Registration headers: { 'Content-Type': 'application/json' }, }, ); - openSuccessToast({ - title: `Project ${projectExists ? 'Updated' : 'Registered'}`, - description: projectExists ? undefined : "You're all set! 🚀", - }); + await useUserStore.getState().fetchUser(); // Refresh user to populate project - onComplete?.(updatedProject); + onComplete?.({ + ...projectData, + createdAt: dayjs(projectData.createdAt), + updatedAt: dayjs(projectData.updatedAt), + }); } catch (error) { let errorMessage = isAxiosError(error) && typeof error.response?.data === 'string' diff --git a/packages/web/src/pages/project/[id]/index.tsx b/packages/web/src/pages/project/[id]/index.tsx index 2cb480a0..6e1e7a31 100644 --- a/packages/web/src/pages/project/[id]/index.tsx +++ b/packages/web/src/pages/project/[id]/index.tsx @@ -8,7 +8,7 @@ import { Circle } from '@chakra-ui/react'; import { MdEdit } from 'react-icons/md'; import { PageContainer } from '../../../components/layout/PageContainer'; import { ProjectCard } from '../../../components/ProjectCard'; -import { openErrorToast } from '../../../components/utils/CustomToast'; +import { openErrorToast, openSuccessToast } from '../../../components/utils/CustomToast'; import { colors } from '../../../theme'; import { useUserStore } from '../../../stores/user'; import { ProjectRegistrationForm } from '../../../components/ProjectRegistrationForm'; @@ -69,6 +69,9 @@ const ProjectDetails: NextPage = () => { onComplete={(updatedProject) => { setProject(updatedProject); setIsEditing(false); + openSuccessToast({ + title: 'Project Updated', + }); }} /> ) : (