[resumes][feat] Add resume comment upvote/downvote (#389)

* [resumes][feat] Add upvote/downvote

* [resumes][refactor] abstract comment votes fetching from comments

* [resumes][chore] remove votes from comments query

Co-authored-by: Terence Ho <>
pull/394/head
Terence 2 years ago committed by GitHub
parent 925ba937b4
commit 22805022a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,3 +1,4 @@
import clsx from 'clsx';
import { useState } from 'react'; import { useState } from 'react';
import type { SubmitHandler } from 'react-hook-form'; import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@ -6,6 +7,7 @@ import {
ArrowUpCircleIcon, ArrowUpCircleIcon,
} from '@heroicons/react/20/solid'; } from '@heroicons/react/20/solid';
import { FaceSmileIcon } from '@heroicons/react/24/outline'; import { FaceSmileIcon } from '@heroicons/react/24/outline';
import { Vote } from '@prisma/client';
import { Button, TextArea } from '@tih/ui'; import { Button, TextArea } from '@tih/ui';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
@ -53,6 +55,31 @@ export default function ResumeCommentListItem({
}, },
); );
// COMMENT VOTES
const commentVotesQuery = trpc.useQuery([
'resumes.comments.votes.list',
{ commentId: comment.id },
]);
const commentVotesUpsertMutation = trpc.useMutation(
'resumes.comments.votes.user.upsert',
{
onSuccess: () => {
// Comment updated, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.votes.list']);
},
},
);
const commentVotesDeleteMutation = trpc.useMutation(
'resumes.comments.votes.user.delete',
{
onSuccess: () => {
// Comment updated, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.votes.list']);
},
},
);
// FORM ACTIONS
const onCancel = () => { const onCancel = () => {
reset({ description: comment.description }); reset({ description: comment.description });
setIsEditingComment(false); setIsEditingComment(false);
@ -60,7 +87,7 @@ export default function ResumeCommentListItem({
const onSubmit: SubmitHandler<ICommentInput> = async (data) => { const onSubmit: SubmitHandler<ICommentInput> = async (data) => {
const { id } = comment; const { id } = comment;
return await commentUpdateMutation.mutate( return commentUpdateMutation.mutate(
{ {
id, id,
...data, ...data,
@ -77,6 +104,18 @@ export default function ResumeCommentListItem({
setValue('description', value.trim(), { shouldDirty: true }); setValue('description', value.trim(), { shouldDirty: true });
}; };
const onVote = async (value: Vote) => {
if (commentVotesQuery.data?.userVote?.value === value) {
return commentVotesDeleteMutation.mutate({
commentId: comment.id,
});
}
return commentVotesUpsertMutation.mutate({
commentId: comment.id,
value,
});
};
return ( return (
<div className="border-primary-300 w-11/12 min-w-fit rounded-md border-2 bg-white p-2 drop-shadow-md"> <div className="border-primary-300 w-11/12 min-w-fit rounded-md border-2 bg-white p-2 drop-shadow-md">
<div className="flex flex-row space-x-2 p-1 align-top"> <div className="flex flex-row space-x-2 p-1 align-top">
@ -154,18 +193,57 @@ export default function ResumeCommentListItem({
{/* Upvote and edit */} {/* Upvote and edit */}
<div className="flex flex-row space-x-1 pt-1 align-middle"> <div className="flex flex-row space-x-1 pt-1 align-middle">
{/* TODO: Implement upvote */} <button
<ArrowUpCircleIcon className="h-4 w-4 fill-gray-400" /> disabled={
<div className="text-xs">{comment.numVotes}</div> !userId ||
<ArrowDownCircleIcon className="h-4 w-4 fill-gray-400" /> commentVotesQuery.isLoading ||
commentVotesUpsertMutation.isLoading ||
commentVotesDeleteMutation.isLoading
}
type="button"
onClick={() => onVote(Vote.UPVOTE)}>
<ArrowUpCircleIcon
className={clsx(
'h-4 w-4',
commentVotesQuery.data?.userVote?.value === Vote.UPVOTE
? 'fill-indigo-500'
: 'fill-gray-400',
userId && 'hover:fill-indigo-500',
)}
/>
</button>
<div className="text-xs">
{commentVotesQuery.data?.numVotes ?? 0}
</div>
<button
disabled={
!userId ||
commentVotesQuery.isLoading ||
commentVotesUpsertMutation.isLoading ||
commentVotesDeleteMutation.isLoading
}
type="button"
onClick={() => onVote(Vote.DOWNVOTE)}>
<ArrowDownCircleIcon
className={clsx(
'h-4 w-4',
commentVotesQuery.data?.userVote?.value === Vote.DOWNVOTE
? 'fill-red-500'
: 'fill-gray-400',
userId && 'hover:fill-red-500',
)}
/>
</button>
{isCommentOwner && !isEditingComment && ( {isCommentOwner && !isEditingComment && (
<a <button
className="text-primary-800 hover:text-primary-400 px-1 text-xs" className="text-primary-800 hover:text-primary-400 px-1 text-xs"
href="#" type="button"
onClick={() => setIsEditingComment(true)}> onClick={() => setIsEditingComment(true)}>
Edit Edit
</a> </button>
)} )}
</div> </div>
</div> </div>

@ -13,6 +13,8 @@ import { questionsQuestionCommentRouter } from './questions-question-comment-rou
import { questionsQuestionRouter } from './questions-question-router'; import { questionsQuestionRouter } from './questions-question-router';
import { resumeCommentsRouter } from './resumes/resumes-comments-router'; import { resumeCommentsRouter } from './resumes/resumes-comments-router';
import { resumesCommentsUserRouter } from './resumes/resumes-comments-user-router'; import { resumesCommentsUserRouter } from './resumes/resumes-comments-user-router';
import { resumesCommentsVotesRouter } from './resumes/resumes-comments-votes-router';
import { resumesCommentsVotesUserRouter } from './resumes/resumes-comments-votes-user-router';
import { resumesRouter } from './resumes/resumes-resume-router'; import { resumesRouter } from './resumes/resumes-resume-router';
import { resumesResumeUserRouter } from './resumes/resumes-resume-user-router'; import { resumesResumeUserRouter } from './resumes/resumes-resume-user-router';
import { resumesStarUserRouter } from './resumes/resumes-star-user-router'; import { resumesStarUserRouter } from './resumes/resumes-star-user-router';
@ -33,6 +35,8 @@ export const appRouter = createRouter()
.merge('resumes.resume.', resumesStarUserRouter) .merge('resumes.resume.', resumesStarUserRouter)
.merge('resumes.comments.', resumeCommentsRouter) .merge('resumes.comments.', resumeCommentsRouter)
.merge('resumes.comments.user.', resumesCommentsUserRouter) .merge('resumes.comments.user.', resumesCommentsUserRouter)
.merge('resumes.comments.votes.', resumesCommentsVotesRouter)
.merge('resumes.comments.votes.user.', resumesCommentsVotesUserRouter)
.merge('questions.answers.comments.', questionsAnswerCommentRouter) .merge('questions.answers.comments.', questionsAnswerCommentRouter)
.merge('questions.answers.', questionsAnswerRouter) .merge('questions.answers.', questionsAnswerRouter)
.merge('questions.questions.comments.', questionsQuestionCommentRouter) .merge('questions.questions.comments.', questionsQuestionCommentRouter)

@ -9,7 +9,6 @@ export const resumeCommentsRouter = createRouter().query('list', {
resumeId: z.string(), resumeId: z.string(),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { resumeId } = input; const { resumeId } = input;
// For this resume, we retrieve every comment's information, along with: // For this resume, we retrieve every comment's information, along with:
@ -17,23 +16,12 @@ export const resumeCommentsRouter = createRouter().query('list', {
// Number of votes, and whether the user (if-any) has voted // Number of votes, and whether the user (if-any) has voted
const comments = await ctx.prisma.resumesComment.findMany({ const comments = await ctx.prisma.resumesComment.findMany({
include: { include: {
_count: {
select: {
votes: true,
},
},
user: { user: {
select: { select: {
image: true, image: true,
name: true, name: true,
}, },
}, },
votes: {
take: 1,
where: {
userId,
},
},
}, },
orderBy: { orderBy: {
createdAt: 'desc', createdAt: 'desc',
@ -44,15 +32,10 @@ export const resumeCommentsRouter = createRouter().query('list', {
}); });
return comments.map((data) => { return comments.map((data) => {
const hasVoted = data.votes.length > 0;
const numVotes = data._count.votes;
const comment: ResumeComment = { const comment: ResumeComment = {
createdAt: data.createdAt, createdAt: data.createdAt,
description: data.description, description: data.description,
hasVoted,
id: data.id, id: data.id,
numVotes,
resumeId: data.resumeId, resumeId: data.resumeId,
section: data.section, section: data.section,
updatedAt: data.updatedAt, updatedAt: data.updatedAt,

@ -0,0 +1,38 @@
import { z } from 'zod';
import type { ResumesCommentVote } from '@prisma/client';
import { Vote } from '@prisma/client';
import { createRouter } from '../context';
import type { ResumeCommentVote } from '~/types/resume-comments';
export const resumesCommentsVotesRouter = createRouter().query('list', {
input: z.object({
commentId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { commentId } = input;
const votes = await ctx.prisma.resumesCommentVote.findMany({
where: {
commentId,
},
});
let userVote: ResumesCommentVote | null = null;
let numVotes = 0;
votes.forEach((vote) => {
numVotes += vote.value === Vote.UPVOTE ? 1 : -1;
userVote = vote.userId === userId ? vote : null;
});
const resumeCommentVote: ResumeCommentVote = {
numVotes,
userVote,
};
return resumeCommentVote;
},
});

@ -0,0 +1,45 @@
import { z } from 'zod';
import { Vote } from '@prisma/client';
import { createProtectedRouter } from '../context';
export const resumesCommentsVotesUserRouter = createProtectedRouter()
.mutation('upsert', {
input: z.object({
commentId: z.string(),
value: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session.user.id;
const { commentId, value } = input;
await ctx.prisma.resumesCommentVote.upsert({
create: {
commentId,
userId,
value,
},
update: {
value,
},
where: {
userId_commentId: { commentId, userId },
},
});
},
})
.mutation('delete', {
input: z.object({
commentId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session.user.id;
const { commentId } = input;
await ctx.prisma.resumesCommentVote.delete({
where: {
userId_commentId: { commentId, userId },
},
});
},
});

@ -1,4 +1,4 @@
import type { ResumesSection } from '@prisma/client'; import type { ResumesCommentVote, ResumesSection } from '@prisma/client';
/** /**
* Returned by `resumeCommentsRouter` (query for 'resumes.comments.list') and received as prop by `Comment` in `CommentsList` * Returned by `resumeCommentsRouter` (query for 'resumes.comments.list') and received as prop by `Comment` in `CommentsList`
@ -7,9 +7,7 @@ import type { ResumesSection } from '@prisma/client';
export type ResumeComment = Readonly<{ export type ResumeComment = Readonly<{
createdAt: Date; createdAt: Date;
description: string; description: string;
hasVoted: boolean;
id: string; id: string;
numVotes: number;
resumeId: string; resumeId: string;
section: ResumesSection; section: ResumesSection;
updatedAt: Date; updatedAt: Date;
@ -19,3 +17,8 @@ export type ResumeComment = Readonly<{
userId: string; userId: string;
}; };
}>; }>;
export type ResumeCommentVote = Readonly<{
numVotes: number;
userVote: ResumesCommentVote?;
}>;

Loading…
Cancel
Save