[resumes][feat] Add upvote/downvote

pull/389/head
Terence Ho 3 years ago
parent 612bef14ad
commit 2900dbeb1a

@ -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';
@ -52,6 +54,24 @@ export default function ResumeCommentListItem({
}, },
}, },
); );
const commentUpvoteUpsertMutation = trpc.useMutation(
'resumes.comments.upvotes.user.upsert',
{
onSuccess: () => {
// Comment updated, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.list']);
},
},
);
const commentUpvoteDeleteMutation = trpc.useMutation(
'resumes.comments.upvotes.user.delete',
{
onSuccess: () => {
// Comment updated, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.list']);
},
},
);
const onCancel = () => { const onCancel = () => {
reset({ description: comment.description }); reset({ description: comment.description });
@ -60,7 +80,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 +97,19 @@ export default function ResumeCommentListItem({
setValue('description', value.trim(), { shouldDirty: true }); setValue('description', value.trim(), { shouldDirty: true });
}; };
const onVote = async (value: Vote) => {
if (comment.userVote?.value === value) {
return commentUpvoteDeleteMutation.mutate({
commentId: comment.id,
});
}
return commentUpvoteUpsertMutation.mutate({
commentId: comment.id,
id: comment.userVote?.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 +187,53 @@ 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={
!userId ||
commentUpvoteUpsertMutation.isLoading ||
commentUpvoteDeleteMutation.isLoading
}
type="button"
onClick={() => onVote(Vote.UPVOTE)}>
<ArrowUpCircleIcon
className={clsx(
'h-4 w-4',
comment.userVote?.value === Vote.UPVOTE
? 'fill-indigo-500'
: 'fill-gray-400',
userId && 'hover:fill-indigo-500',
)}
/>
</button>
<div className="text-xs">{comment.numVotes}</div> <div className="text-xs">{comment.numVotes}</div>
<ArrowDownCircleIcon className="h-4 w-4 fill-gray-400" />
<button
disabled={
!userId ||
commentUpvoteUpsertMutation.isLoading ||
commentUpvoteDeleteMutation.isLoading
}
type="button"
onClick={() => onVote(Vote.DOWNVOTE)}>
<ArrowDownCircleIcon
className={clsx(
'h-4 w-4',
comment.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>

@ -12,6 +12,7 @@ import { questionsAnswerRouter } from './questions-answer-router';
import { questionsQuestionCommentRouter } from './questions-question-comment-router'; import { questionsQuestionCommentRouter } from './questions-question-comment-router';
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 { resumesCommentsUpvotesUserRouter } from './resumes/resumes-comments-upvotes-user-router';
import { resumesCommentsUserRouter } from './resumes/resumes-comments-user-router'; import { resumesCommentsUserRouter } from './resumes/resumes-comments-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';
@ -33,6 +34,7 @@ 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.upvotes.user.', resumesCommentsUpvotesUserRouter)
.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)

@ -1,4 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import type { ResumesCommentVote } from '@prisma/client';
import { Vote } from '@prisma/client';
import { createRouter } from '../context'; import { createRouter } from '../context';
@ -17,23 +19,13 @@ 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: { votes: true,
take: 1,
where: {
userId,
},
},
}, },
orderBy: { orderBy: {
createdAt: 'desc', createdAt: 'desc',
@ -44,13 +36,17 @@ export const resumeCommentsRouter = createRouter().query('list', {
}); });
return comments.map((data) => { return comments.map((data) => {
const hasVoted = data.votes.length > 0; let userVote: ResumesCommentVote | undefined = undefined;
const numVotes = data._count.votes; let numVotes = 0;
data.votes.forEach((vote) => {
numVotes += vote.value === Vote.UPVOTE ? 1 : -1;
userVote = vote.userId === userId ? vote : undefined;
});
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, numVotes,
resumeId: data.resumeId, resumeId: data.resumeId,
@ -61,6 +57,7 @@ export const resumeCommentsRouter = createRouter().query('list', {
name: data.user.name, name: data.user.name,
userId: data.userId, userId: data.userId,
}, },
userVote,
}; };
return comment; return comment;

@ -0,0 +1,49 @@
import { z } from 'zod';
import { Vote } from '@prisma/client';
import { createProtectedRouter } from '../context';
export const resumesCommentsUpvotesUserRouter = createProtectedRouter()
.mutation('upsert', {
input: z.object({
commentId: z.string(),
id: z.string().optional(),
value: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session.user.id;
const { id, commentId, value } = input;
await ctx.prisma.resumesCommentVote.upsert({
create: {
commentId,
userId,
value,
},
update: {
commentId,
id,
userId,
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,7 +7,6 @@ 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; numVotes: number;
resumeId: string; resumeId: string;
@ -18,4 +17,5 @@ export type ResumeComment = Readonly<{
name: string?; name: string?;
userId: string; userId: string;
}; };
userVote: ResumesCommentVote | undefined;
}>; }>;

Loading…
Cancel
Save