[resumes][feat] Add API to submit & query for resume reviews (#313)

* [resumes][feat] Add route to submit resume reviews

* [resumes][feat] Add router to query for comments

* [resumes][refactor] Change limit of upvotes query

* [resumes][chore] revert changes

* [resumes][chore] remove comment

* [resumes][chore] Use ResumesSection enum instead of hard-coded string

* [resumes][refactor] Add check for user session in comments

* [resumes][fix] fix linting issues

Co-authored-by: Terence Ho <>
pull/317/head
Terence 2 years ago committed by GitHub
parent 641a565e5c
commit 0933cce7b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -3,7 +3,10 @@ import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { Button, Dialog, TextInput } from '@tih/ui';
import { trpc } from '~/utils/trpc';
type CommentsFormProps = Readonly<{
resumeId: string;
setShowCommentsForm: (show: boolean) => void;
}>;
@ -18,6 +21,7 @@ type IFormInput = {
type InputKeys = keyof IFormInput;
export default function CommentsForm({
resumeId,
setShowCommentsForm,
}: CommentsFormProps) {
const [showDialog, setShowDialog] = useState(false);
@ -35,10 +39,17 @@ export default function CommentsForm({
skills: '',
},
});
const reviewCreateMutation = trpc.useMutation('resumes.reviews.user.create');
// TODO: Give a feedback to the user if the action succeeds/fails
const onSubmit: SubmitHandler<IFormInput> = async (data) => {
await reviewCreateMutation.mutate({
resumeId,
...data,
});
// TODO: Implement mutation to database
const onSubmit: SubmitHandler<IFormInput> = (data) => {
alert(JSON.stringify(data));
// Redirect back to comments section
setShowCommentsForm(false);
};
const onCancel = () => {
@ -54,8 +65,11 @@ export default function CommentsForm({
};
return (
<>
<div className="h-[calc(100vh-13rem)] overflow-y-scroll">
<h2 className="text-xl font-semibold text-gray-800">Add your review</h2>
<p className="text-gray-800">
Please fill in at least one section to submit your review
</p>
<form
className="w-full space-y-8 divide-y divide-gray-200"
@ -144,6 +158,6 @@ export default function CommentsForm({
}}>
<div>Note that your review will not be saved!</div>
</Dialog>
</>
</div>
);
}

@ -1,25 +1,31 @@
import { useState } from 'react';
import { Button, Tabs } from '@tih/ui';
import { Tabs } from '@tih/ui';
import { trpc } from '~/utils/trpc';
import CommentsListButton from './CommentsListButton';
import { COMMENTS_SECTIONS } from './constants';
type CommentsListProps = Readonly<{
resumeId: string;
setShowCommentsForm: (show: boolean) => void;
}>;
export default function CommentsList({
resumeId,
setShowCommentsForm,
}: CommentsListProps) {
const [tab, setTab] = useState(COMMENTS_SECTIONS[0].value);
const commentsQuery = trpc.useQuery(['resumes.reviews.list', { resumeId }]);
/* eslint-disable no-console */
console.log(commentsQuery.data);
/* eslint-enable no-console */
return (
<>
<Button
display="block"
label="Add your review"
variant="tertiary"
onClick={() => setShowCommentsForm(true)}
/>
<div className="space-y-3">
<CommentsListButton setShowCommentsForm={setShowCommentsForm} />
<Tabs
label="comments"
tabs={COMMENTS_SECTIONS}
@ -27,6 +33,6 @@ export default function CommentsList({
onChange={(value) => setTab(value)}
/>
{/* TODO: Add comments lists */}
</>
</div>
);
}

@ -0,0 +1,48 @@
import { signIn, useSession } from 'next-auth/react';
import { Button } from '@tih/ui';
type CommentsListButtonProps = {
setShowCommentsForm: (show: boolean) => void;
};
export default function CommentsListButton({
setShowCommentsForm,
}: CommentsListButtonProps) {
const { data: session, status } = useSession();
const isSessionLoading = status === 'loading';
// Don't render anything
if (isSessionLoading) {
return null;
}
// Not signed in
if (session == null) {
return (
<div className="flex justify-center">
<p>
<a
className="text-primary-800 hover:text-primary-500"
href="/api/auth/signin"
onClick={(event) => {
event.preventDefault();
signIn();
}}>
Sign in
</a>{' '}
to join discussion
</p>
</div>
);
}
// Signed in. Return Add review button
return (
<Button
display="block"
label="Add your review"
variant="tertiary"
onClick={() => setShowCommentsForm(true)}
/>
);
}

@ -3,12 +3,25 @@ import { useState } from 'react';
import CommentsForm from './CommentsForm';
import CommentsList from './CommentsList';
export default function CommentsSection() {
type ICommentsSectionProps = {
resumeId: string;
};
// TODO: Retrieve resumeId for CommentsSection
export default function CommentsSection({
resumeId = '',
}: ICommentsSectionProps) {
const [showCommentsForm, setShowCommentsForm] = useState(false);
return showCommentsForm ? (
<CommentsForm setShowCommentsForm={setShowCommentsForm} />
<CommentsForm
resumeId={resumeId}
setShowCommentsForm={setShowCommentsForm}
/>
) : (
<CommentsList setShowCommentsForm={setShowCommentsForm} />
<CommentsList
resumeId={resumeId}
setShowCommentsForm={setShowCommentsForm}
/>
);
}

@ -1,23 +1,24 @@
// TODO: Move to a general enums/constants file? For resumes
import { ResumesSection } from '@prisma/client';
export const COMMENTS_SECTIONS = [
{
label: 'General',
value: 'general',
value: ResumesSection.GENERAL,
},
{
label: 'Education',
value: 'education',
value: ResumesSection.EDUCATION,
},
{
label: 'Experience',
value: 'experience',
value: ResumesSection.EXPERIENCE,
},
{
label: 'Projects',
value: 'projects',
value: ResumesSection.PROJECTS,
},
{
label: 'Skills',
value: 'skills',
value: ResumesSection.SKILLS,
},
];

@ -73,7 +73,7 @@ export default function ResumeReviewPage() {
<ResumePdf />
</div>
<div className="mx-8 w-1/2">
<CommentsSection />
<CommentsSection resumeId="" />
</div>
</div>
</main>

@ -3,6 +3,8 @@ import superjson from 'superjson';
import { createRouter } from './context';
import { protectedExampleRouter } from './protected-example-router';
import { resumesResumeUserRouter } from './resumes-resume-user-router';
import { resumeReviewsRouter } from './resumes-reviews-router';
import { resumesReviewsUserRouter } from './resumes-reviews-user-router';
import { todosRouter } from './todos';
import { todosUserRouter } from './todos-user-router';
@ -14,7 +16,9 @@ export const appRouter = createRouter()
.merge('auth.', protectedExampleRouter)
.merge('todos.', todosRouter)
.merge('todos.user.', todosUserRouter)
.merge('resumes.resume.user.', resumesResumeUserRouter);
.merge('resumes.resume.user.', resumesResumeUserRouter)
.merge('resumes.reviews.', resumeReviewsRouter)
.merge('resumes.reviews.user.', resumesReviewsUserRouter);
// Export type definition of API
export type AppRouter = typeof appRouter;

@ -0,0 +1,44 @@
import { z } from 'zod';
import { createRouter } from './context';
export const resumeReviewsRouter = createRouter().query('list', {
input: z.object({
resumeId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { resumeId } = input;
// For this resume, we retrieve every comment's information, along with:
// The user's name and image to render
// Number of votes, and whether the user (if-any) has voted
return await ctx.prisma.resumesComment.findMany({
include: {
_count: {
select: {
votes: true,
},
},
user: {
select: {
image: true,
name: true,
},
},
votes: {
take: 1,
where: {
userId,
},
},
},
orderBy: {
createdAt: 'desc',
},
where: {
resumeId,
},
});
},
});

@ -0,0 +1,54 @@
import { z } from 'zod';
import { ResumesSection } from '@prisma/client';
import { createProtectedRouter } from './context';
type IResumeCommentInput = Readonly<{
description: string;
resumeId: string;
section: ResumesSection;
userId: string;
}>;
export const resumesReviewsUserRouter = createProtectedRouter().mutation(
'create',
{
input: z.object({
education: z.string(),
experience: z.string(),
general: z.string(),
projects: z.string(),
resumeId: z.string(),
skills: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user.id;
const { resumeId, education, experience, general, projects, skills } =
input;
// For each section, convert them into ResumesComment model if provided
const comments: Array<IResumeCommentInput> = [
{ description: education, section: ResumesSection.EDUCATION },
{ description: experience, section: ResumesSection.EXPERIENCE },
{ description: general, section: ResumesSection.GENERAL },
{ description: projects, section: ResumesSection.PROJECTS },
{ description: skills, section: ResumesSection.SKILLS },
]
.filter(({ description }) => {
return description.trim().length > 0;
})
.map(({ description, section }) => {
return {
description,
resumeId,
section,
userId,
};
});
return await ctx.prisma.resumesComment.createMany({
data: comments,
});
},
},
);
Loading…
Cancel
Save