mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-28 12:43:12 +08:00
[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 <>
This commit is contained in:
@ -1,3 +1,4 @@
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
import type { SubmitHandler } from 'react-hook-form';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@ -6,6 +7,7 @@ import {
|
||||
ArrowUpCircleIcon,
|
||||
} from '@heroicons/react/20/solid';
|
||||
import { FaceSmileIcon } from '@heroicons/react/24/outline';
|
||||
import { Vote } from '@prisma/client';
|
||||
import { Button, TextArea } from '@tih/ui';
|
||||
|
||||
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 = () => {
|
||||
reset({ description: comment.description });
|
||||
setIsEditingComment(false);
|
||||
@ -60,7 +87,7 @@ export default function ResumeCommentListItem({
|
||||
|
||||
const onSubmit: SubmitHandler<ICommentInput> = async (data) => {
|
||||
const { id } = comment;
|
||||
return await commentUpdateMutation.mutate(
|
||||
return commentUpdateMutation.mutate(
|
||||
{
|
||||
id,
|
||||
...data,
|
||||
@ -77,6 +104,18 @@ export default function ResumeCommentListItem({
|
||||
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 (
|
||||
<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">
|
||||
@ -154,18 +193,57 @@ export default function ResumeCommentListItem({
|
||||
|
||||
{/* Upvote and edit */}
|
||||
<div className="flex flex-row space-x-1 pt-1 align-middle">
|
||||
{/* TODO: Implement upvote */}
|
||||
<ArrowUpCircleIcon className="h-4 w-4 fill-gray-400" />
|
||||
<div className="text-xs">{comment.numVotes}</div>
|
||||
<ArrowDownCircleIcon className="h-4 w-4 fill-gray-400" />
|
||||
<button
|
||||
disabled={
|
||||
!userId ||
|
||||
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 && (
|
||||
<a
|
||||
<button
|
||||
className="text-primary-800 hover:text-primary-400 px-1 text-xs"
|
||||
href="#"
|
||||
type="button"
|
||||
onClick={() => setIsEditingComment(true)}>
|
||||
Edit
|
||||
</a>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -13,6 +13,8 @@ import { questionsQuestionCommentRouter } from './questions-question-comment-rou
|
||||
import { questionsQuestionRouter } from './questions-question-router';
|
||||
import { resumeCommentsRouter } from './resumes/resumes-comments-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 { resumesResumeUserRouter } from './resumes/resumes-resume-user-router';
|
||||
import { resumesStarUserRouter } from './resumes/resumes-star-user-router';
|
||||
@ -33,6 +35,8 @@ export const appRouter = createRouter()
|
||||
.merge('resumes.resume.', resumesStarUserRouter)
|
||||
.merge('resumes.comments.', resumeCommentsRouter)
|
||||
.merge('resumes.comments.user.', resumesCommentsUserRouter)
|
||||
.merge('resumes.comments.votes.', resumesCommentsVotesRouter)
|
||||
.merge('resumes.comments.votes.user.', resumesCommentsVotesUserRouter)
|
||||
.merge('questions.answers.comments.', questionsAnswerCommentRouter)
|
||||
.merge('questions.answers.', questionsAnswerRouter)
|
||||
.merge('questions.questions.comments.', questionsQuestionCommentRouter)
|
||||
|
@ -9,7 +9,6 @@ export const resumeCommentsRouter = createRouter().query('list', {
|
||||
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:
|
||||
@ -17,23 +16,12 @@ export const resumeCommentsRouter = createRouter().query('list', {
|
||||
// Number of votes, and whether the user (if-any) has voted
|
||||
const comments = await ctx.prisma.resumesComment.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
votes: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
image: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
votes: {
|
||||
take: 1,
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
@ -44,15 +32,10 @@ export const resumeCommentsRouter = createRouter().query('list', {
|
||||
});
|
||||
|
||||
return comments.map((data) => {
|
||||
const hasVoted = data.votes.length > 0;
|
||||
const numVotes = data._count.votes;
|
||||
|
||||
const comment: ResumeComment = {
|
||||
createdAt: data.createdAt,
|
||||
description: data.description,
|
||||
hasVoted,
|
||||
id: data.id,
|
||||
numVotes,
|
||||
resumeId: data.resumeId,
|
||||
section: data.section,
|
||||
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 },
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
9
apps/portal/src/types/resume-comments.d.ts
vendored
9
apps/portal/src/types/resume-comments.d.ts
vendored
@ -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`
|
||||
@ -7,9 +7,7 @@ import type { ResumesSection } from '@prisma/client';
|
||||
export type ResumeComment = Readonly<{
|
||||
createdAt: Date;
|
||||
description: string;
|
||||
hasVoted: boolean;
|
||||
id: string;
|
||||
numVotes: number;
|
||||
resumeId: string;
|
||||
section: ResumesSection;
|
||||
updatedAt: Date;
|
||||
@ -19,3 +17,8 @@ export type ResumeComment = Readonly<{
|
||||
userId: string;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type ResumeCommentVote = Readonly<{
|
||||
numVotes: number;
|
||||
userVote: ResumesCommentVote?;
|
||||
}>;
|
||||
|
Reference in New Issue
Block a user