mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-28 20:52:00 +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 { 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 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
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`
|
* 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?;
|
||||||
|
}>;
|
||||||
|
Reference in New Issue
Block a user