Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Update Project registration flow with invite code #624

Merged
merged 12 commits into from
Jan 5, 2024
4 changes: 3 additions & 1 deletion packages/api/src/api/project/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
};
52 changes: 45 additions & 7 deletions packages/api/tests/api/project/post.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/database/src/entities/Project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Node } from './Node';
import { User } from './User';

export type ProjectDTO = EntityDTO<Project>;
export type ProjectDTOWithInviteCode = ProjectDTO & Pick<Project, 'inviteCode'>;

export type ProjectConstructorValues = ConstructorValues<
Project,
Expand Down
3 changes: 2 additions & 1 deletion packages/shared/src/types/entities/Project.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ProjectDTO } from '@hangar/database';
import { ProjectDTO, ProjectDTOWithInviteCode } from '@hangar/database';
import { Node, SerializedNode } from './Node';

export type Project = Node<ProjectDTO>;
export type ProjectWithInviteCode = Node<ProjectDTOWithInviteCode>;
export type SerializedProject = SerializedNode<Project>;
Original file line number Diff line number Diff line change
@@ -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<CopyProjectInviteCodeProps> = ({ project }) => {
const inviteCodeUrl = `${env.baseUrl}?projectInviteCode=${project.inviteCode}`;
const { onCopy } = useClipboard(inviteCodeUrl);
return (
<Flex p={3} direction={'column'} alignItems={'center'} gap={10}>
<Flex direction={'column'} alignItems={'center'}>
<Heading size={'lg'}>Invite Your Team</Heading>
<Text>Share the link below to add others to this project.</Text>
</Flex>

<Flex
onClick={() => {
onCopy();
openSuccessToast({ title: 'Link Copied' });
}}
direction={'column'}
alignItems={'center'}
gap={2}
cursor={'pointer'}
>
<Code>{inviteCodeUrl}</Code>
<Box>
<Button>Copy Invite Code</Button>
</Box>
</Flex>

<Alert status="warning">
<AlertIcon />
<Text>
This is the only point where you can access the invite code. Make sure to copy it before
leaving this page.
</Text>
</Alert>
</Flex>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './CopyProjectInviteCode';
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProjectWithInviteCode | undefined>();
const { isOpen, onOpen, onClose } = useDisclosure();
const { triggerRedirect } = useRedirectToAuth();

Expand All @@ -45,14 +48,24 @@ export const ProjectRegistrationButton: React.FC = () => {
<>
<Button onClick={onRegistrationClick}>Register Project</Button>

<Modal isOpen={isOpen} onClose={onClose} size="xl">
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={!newProject}>
<ModalOverlay />
<ModalContent pb={4} mx={3}>
<ModalHeader>Project Registration</ModalHeader>
<ModalCloseButton />

<ModalBody>
<ProjectRegistrationForm onComplete={onClose} />
{newProject ? (
<CopyProjectInviteCode project={newProject} />
) : (
<ProjectRegistrationForm
onComplete={(project) => {
if ('inviteCode' in project) {
setNewProject(project);
}
}}
/>
)}
</ModalBody>
</ModalContent>
</Modal>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */
/* eslint-disable no-underscore-dangle */
import React from 'react';
import {
Expand Down Expand Up @@ -41,6 +42,7 @@ export const ProjectRegistrationForm: React.FC<RegistrationFormProps> = ({
project,
onComplete,
});

return (
<form onSubmit={formik.handleSubmit}>
<VStack alignItems="stretch">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Schema.project.post>;
Expand All @@ -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) => {
Expand Down Expand Up @@ -46,7 +47,7 @@ export const useProjectRegistrationForm = ({ onComplete, project }: Registration
async onSubmit(values) {
try {
setIsLoading(true);
const { data: updatedProject } = await axios<Project>(
const { data: projectData } = await axios<SerializedProject>(
projectExists ? `/api/project/${id}` : `/api/project`,
{
method: projectExists ? 'PUT' : 'POST',
Expand All @@ -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'
Expand Down
5 changes: 4 additions & 1 deletion packages/web/src/pages/project/[id]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -69,6 +69,9 @@ const ProjectDetails: NextPage = () => {
onComplete={(updatedProject) => {
setProject(updatedProject);
setIsEditing(false);
openSuccessToast({
title: 'Project Updated',
});
}}
/>
) : (
Expand Down
Loading