mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-28 20:52:00 +08:00
[resumes][feat] replying comments (#401)
* [resumes][feat] add resume comment parent * [resumes][refactor] Abstract comment edit form and votes to their components * [resumes][feat] Add reply form * [resumes][feat] Render replies * [resumes][feat] add collapsible comments * [resumes][chore] remove comment Co-authored-by: Terence Ho <>
This commit is contained in:
@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ResumesComment" ADD COLUMN "parentId" TEXT;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ResumesComment" ADD CONSTRAINT "ResumesComment_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "ResumesComment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
@ -140,6 +140,7 @@ model ResumesComment {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
resumeId String
|
resumeId String
|
||||||
|
parentId String?
|
||||||
description String @db.Text
|
description String @db.Text
|
||||||
section ResumesSection
|
section ResumesSection
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@ -147,6 +148,8 @@ model ResumesComment {
|
|||||||
resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade)
|
resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade)
|
||||||
votes ResumesCommentVote[]
|
votes ResumesCommentVote[]
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
parent ResumesComment? @relation("parentComment", fields: [parentId], references: [id])
|
||||||
|
children ResumesComment[] @relation("parentComment")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ResumesSection {
|
enum ResumesSection {
|
||||||
|
@ -1,18 +1,11 @@
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import type { Dispatch, SetStateAction } from 'react';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { SubmitHandler } from 'react-hook-form';
|
import { ChevronUpIcon } from '@heroicons/react/20/solid';
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import {
|
|
||||||
ArrowDownCircleIcon,
|
|
||||||
ArrowUpCircleIcon,
|
|
||||||
} 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 { trpc } from '~/utils/trpc';
|
|
||||||
|
|
||||||
|
import ResumeCommentEditForm from './comment/ResumeCommentEditForm';
|
||||||
|
import ResumeCommentReplyForm from './comment/ResumeCommentReplyForm';
|
||||||
|
import ResumeCommentVoteButtons from './comment/ResumeCommentVoteButtons';
|
||||||
import ResumeUserBadges from '../badges/ResumeUserBadges';
|
import ResumeUserBadges from '../badges/ResumeUserBadges';
|
||||||
import ResumeExpandableText from '../shared/ResumeExpandableText';
|
import ResumeExpandableText from '../shared/ResumeExpandableText';
|
||||||
|
|
||||||
@ -23,141 +16,55 @@ type ResumeCommentListItemProps = {
|
|||||||
userId: string | undefined;
|
userId: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ICommentInput = {
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ResumeCommentListItem({
|
export default function ResumeCommentListItem({
|
||||||
comment,
|
comment,
|
||||||
userId,
|
userId,
|
||||||
}: ResumeCommentListItemProps) {
|
}: ResumeCommentListItemProps) {
|
||||||
const isCommentOwner = userId === comment.user.userId;
|
const isCommentOwner = userId === comment.user.userId;
|
||||||
const [isEditingComment, setIsEditingComment] = useState(false);
|
const [isEditingComment, setIsEditingComment] = useState(false);
|
||||||
|
const [isReplyingComment, setIsReplyingComment] = useState(false);
|
||||||
const [upvoteAnimation, setUpvoteAnimation] = useState(false);
|
const [showReplies, setShowReplies] = useState(true);
|
||||||
const [downvoteAnimation, setDownvoteAnimation] = useState(false);
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
setValue,
|
|
||||||
formState: { errors, isDirty },
|
|
||||||
reset,
|
|
||||||
} = useForm<ICommentInput>({
|
|
||||||
defaultValues: {
|
|
||||||
description: comment.description,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const trpcContext = trpc.useContext();
|
|
||||||
const commentUpdateMutation = trpc.useMutation(
|
|
||||||
'resumes.comments.user.update',
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
// Comment updated, invalidate query to trigger refetch
|
|
||||||
trpcContext.invalidateQueries(['resumes.comments.list']);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit: SubmitHandler<ICommentInput> = async (data) => {
|
|
||||||
const { id } = comment;
|
|
||||||
return commentUpdateMutation.mutate(
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
...data,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
setIsEditingComment(false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setFormValue = (value: string) => {
|
|
||||||
setValue('description', value.trim(), { shouldDirty: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const onVote = async (
|
|
||||||
value: Vote,
|
|
||||||
setAnimation: Dispatch<SetStateAction<boolean>>,
|
|
||||||
) => {
|
|
||||||
setAnimation(true);
|
|
||||||
|
|
||||||
if (commentVotesQuery.data?.userVote?.value === value) {
|
|
||||||
return commentVotesDeleteMutation.mutate(
|
|
||||||
{
|
|
||||||
commentId: comment.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSettled: async () => setAnimation(false),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return commentVotesUpsertMutation.mutate(
|
|
||||||
{
|
|
||||||
commentId: comment.id,
|
|
||||||
value,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSettled: async () => setAnimation(false),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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={clsx(
|
||||||
|
'min-w-fit rounded-md bg-white ',
|
||||||
|
!comment.parentId &&
|
||||||
|
'w-11/12 border-2 border-indigo-300 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">
|
||||||
|
{/* Image Icon */}
|
||||||
{comment.user.image ? (
|
{comment.user.image ? (
|
||||||
<img
|
<img
|
||||||
alt={comment.user.name ?? 'Reviewer'}
|
alt={comment.user.name ?? 'Reviewer'}
|
||||||
className="mt-1 h-8 w-8 rounded-full"
|
className={clsx(
|
||||||
|
'mt-1 rounded-full',
|
||||||
|
comment.parentId ? 'h-6 w-6' : 'h-8 w-8 ',
|
||||||
|
)}
|
||||||
src={comment.user.image!}
|
src={comment.user.image!}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<FaceSmileIcon className="h-8 w-8 rounded-full" />
|
<FaceSmileIcon
|
||||||
|
className={clsx(
|
||||||
|
'mt-1 rounded-full',
|
||||||
|
comment.parentId ? 'h-6 w-6' : 'h-8 w-8 ',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex w-full flex-col space-y-1">
|
<div className="flex w-full flex-col space-y-1">
|
||||||
{/* Name and creation time */}
|
{/* Name and creation time */}
|
||||||
<div className="flex flex-row justify-between">
|
<div className="flex flex-row justify-between">
|
||||||
<div className="flex flex-row items-center space-x-1">
|
<div className="flex flex-row items-center space-x-1">
|
||||||
<p className="font-medium">
|
<p
|
||||||
|
className={clsx(
|
||||||
|
'font-medium text-black',
|
||||||
|
!!comment.parentId && 'text-sm',
|
||||||
|
)}>
|
||||||
{comment.user.name ?? 'Reviewer ABC'}
|
{comment.user.name ?? 'Reviewer ABC'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-primary-800 text-xs font-medium">
|
<p className="text-xs font-medium text-indigo-800">
|
||||||
{isCommentOwner ? '(Me)' : ''}
|
{isCommentOwner ? '(Me)' : ''}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -174,112 +81,78 @@ export default function ResumeCommentListItem({
|
|||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{isEditingComment ? (
|
{isEditingComment ? (
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<ResumeCommentEditForm
|
||||||
<div className="flex-column mt-1 space-y-2">
|
comment={comment}
|
||||||
<TextArea
|
setIsEditingComment={setIsEditingComment}
|
||||||
{...(register('description', {
|
|
||||||
required: 'Comments cannot be empty!',
|
|
||||||
}),
|
|
||||||
{})}
|
|
||||||
defaultValue={comment.description}
|
|
||||||
disabled={commentUpdateMutation.isLoading}
|
|
||||||
errorMessage={errors.description?.message}
|
|
||||||
label=""
|
|
||||||
placeholder="Leave your comment here"
|
|
||||||
onChange={setFormValue}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-row space-x-2">
|
|
||||||
<Button
|
|
||||||
disabled={commentUpdateMutation.isLoading}
|
|
||||||
label="Cancel"
|
|
||||||
size="sm"
|
|
||||||
variant="tertiary"
|
|
||||||
onClick={onCancel}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
disabled={!isDirty || commentUpdateMutation.isLoading}
|
|
||||||
isLoading={commentUpdateMutation.isLoading}
|
|
||||||
label="Confirm"
|
|
||||||
size="sm"
|
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
) : (
|
) : (
|
||||||
<ResumeExpandableText text={comment.description} />
|
<ResumeExpandableText text={comment.description} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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">
|
||||||
<button
|
<ResumeCommentVoteButtons commentId={comment.id} userId={userId} />
|
||||||
disabled={
|
|
||||||
!userId ||
|
|
||||||
commentVotesQuery.isLoading ||
|
|
||||||
commentVotesUpsertMutation.isLoading ||
|
|
||||||
commentVotesDeleteMutation.isLoading
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onVote(Vote.UPVOTE, setUpvoteAnimation)}>
|
|
||||||
<ArrowUpCircleIcon
|
|
||||||
className={clsx(
|
|
||||||
'h-4 w-4',
|
|
||||||
commentVotesQuery.data?.userVote?.value === Vote.UPVOTE ||
|
|
||||||
upvoteAnimation
|
|
||||||
? 'fill-indigo-500'
|
|
||||||
: 'fill-gray-400',
|
|
||||||
userId &&
|
|
||||||
!downvoteAnimation &&
|
|
||||||
!upvoteAnimation &&
|
|
||||||
'hover:fill-indigo-500',
|
|
||||||
upvoteAnimation &&
|
|
||||||
'animate-[bounce_0.5s_infinite] cursor-default',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="text-xs">
|
|
||||||
{commentVotesQuery.data?.numVotes ?? 0}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{/* Action buttons; only present when not editing/replying */}
|
||||||
|
{isCommentOwner && !isEditingComment && !isReplyingComment && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
disabled={
|
className="px-1 text-xs text-indigo-800 hover:text-indigo-400"
|
||||||
!userId ||
|
|
||||||
commentVotesQuery.isLoading ||
|
|
||||||
commentVotesUpsertMutation.isLoading ||
|
|
||||||
commentVotesDeleteMutation.isLoading
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onVote(Vote.DOWNVOTE, setDownvoteAnimation)}>
|
|
||||||
<ArrowDownCircleIcon
|
|
||||||
className={clsx(
|
|
||||||
'h-4 w-4',
|
|
||||||
commentVotesQuery.data?.userVote?.value === Vote.DOWNVOTE ||
|
|
||||||
downvoteAnimation
|
|
||||||
? 'fill-red-500'
|
|
||||||
: 'fill-gray-400',
|
|
||||||
userId &&
|
|
||||||
!downvoteAnimation &&
|
|
||||||
!upvoteAnimation &&
|
|
||||||
'hover:fill-red-500',
|
|
||||||
downvoteAnimation &&
|
|
||||||
'animate-[bounce_0.5s_infinite] cursor-default',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isCommentOwner && !isEditingComment && (
|
|
||||||
<button
|
|
||||||
className="text-primary-800 hover:text-primary-400 px-1 text-xs"
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsEditingComment(true)}>
|
onClick={() => setIsEditingComment(true)}>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{!comment.parentId && (
|
||||||
|
<button
|
||||||
|
className="px-1 text-xs text-indigo-800 hover:text-indigo-400"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsReplyingComment(true)}>
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reply Form */}
|
||||||
|
{isReplyingComment && (
|
||||||
|
<ResumeCommentReplyForm
|
||||||
|
parentId={comment.id}
|
||||||
|
resumeId={comment.resumeId}
|
||||||
|
section={comment.section}
|
||||||
|
setIsReplyingComment={setIsReplyingComment}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Replies */}
|
||||||
|
{comment.children.length > 0 && (
|
||||||
|
<div className="min-w-fit space-y-1 pt-2">
|
||||||
|
<button
|
||||||
|
className="flex items-center space-x-1 rounded-md text-xs font-medium text-indigo-800 hover:text-indigo-300"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowReplies(!showReplies)}>
|
||||||
|
<ChevronUpIcon
|
||||||
|
className={clsx(
|
||||||
|
'h-5 w-5 ',
|
||||||
|
!showReplies && 'rotate-180 transform',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span>{showReplies ? 'Hide replies' : 'Show replies'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showReplies &&
|
||||||
|
comment.children.map((child) => {
|
||||||
|
return (
|
||||||
|
<ResumeCommentListItem
|
||||||
|
key={child.id}
|
||||||
|
comment={child}
|
||||||
|
userId={userId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -0,0 +1,106 @@
|
|||||||
|
import type { SubmitHandler } from 'react-hook-form';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { Button, TextArea } from '@tih/ui';
|
||||||
|
|
||||||
|
import { trpc } from '~/utils/trpc';
|
||||||
|
|
||||||
|
import type { ResumeComment } from '~/types/resume-comments';
|
||||||
|
|
||||||
|
type ResumeCommentEditFormProps = {
|
||||||
|
comment: ResumeComment;
|
||||||
|
setIsEditingComment: (value: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ICommentInput = {
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ResumeCommentEditForm({
|
||||||
|
comment,
|
||||||
|
setIsEditingComment,
|
||||||
|
}: ResumeCommentEditFormProps) {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
formState: { errors, isDirty },
|
||||||
|
reset,
|
||||||
|
} = useForm<ICommentInput>({
|
||||||
|
defaultValues: {
|
||||||
|
description: comment.description,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const trpcContext = trpc.useContext();
|
||||||
|
const commentUpdateMutation = trpc.useMutation(
|
||||||
|
'resumes.comments.user.update',
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
// Comment updated, invalidate query to trigger refetch
|
||||||
|
trpcContext.invalidateQueries(['resumes.comments.list']);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
reset({ description: comment.description });
|
||||||
|
setIsEditingComment(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit: SubmitHandler<ICommentInput> = async (data) => {
|
||||||
|
const { id } = comment;
|
||||||
|
return commentUpdateMutation.mutate(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsEditingComment(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setFormValue = (value: string) => {
|
||||||
|
setValue('description', value.trim(), { shouldDirty: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="flex-column mt-1 space-y-2">
|
||||||
|
<TextArea
|
||||||
|
{...(register('description', {
|
||||||
|
required: 'Comments cannot be empty!',
|
||||||
|
}),
|
||||||
|
{})}
|
||||||
|
defaultValue={comment.description}
|
||||||
|
disabled={commentUpdateMutation.isLoading}
|
||||||
|
errorMessage={errors.description?.message}
|
||||||
|
label=""
|
||||||
|
placeholder="Leave your comment here"
|
||||||
|
onChange={setFormValue}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-row space-x-2">
|
||||||
|
<Button
|
||||||
|
disabled={commentUpdateMutation.isLoading}
|
||||||
|
label="Cancel"
|
||||||
|
size="sm"
|
||||||
|
variant="tertiary"
|
||||||
|
onClick={onCancel}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={!isDirty || commentUpdateMutation.isLoading}
|
||||||
|
isLoading={commentUpdateMutation.isLoading}
|
||||||
|
label="Confirm"
|
||||||
|
size="sm"
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,107 @@
|
|||||||
|
import type { SubmitHandler } from 'react-hook-form';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import type { ResumesSection } from '@prisma/client';
|
||||||
|
import { Button, TextArea } from '@tih/ui';
|
||||||
|
|
||||||
|
import { trpc } from '~/utils/trpc';
|
||||||
|
|
||||||
|
type ResumeCommentEditFormProps = {
|
||||||
|
parentId: string;
|
||||||
|
resumeId: string;
|
||||||
|
section: ResumesSection;
|
||||||
|
setIsReplyingComment: (value: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IReplyInput = {
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ResumeCommentReplyForm({
|
||||||
|
parentId,
|
||||||
|
setIsReplyingComment,
|
||||||
|
resumeId,
|
||||||
|
section,
|
||||||
|
}: ResumeCommentEditFormProps) {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
formState: { errors, isDirty },
|
||||||
|
reset,
|
||||||
|
} = useForm<IReplyInput>({
|
||||||
|
defaultValues: {
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const trpcContext = trpc.useContext();
|
||||||
|
const commentReplyMutation = trpc.useMutation('resumes.comments.user.reply', {
|
||||||
|
onSuccess: () => {
|
||||||
|
// Comment updated, invalidate query to trigger refetch
|
||||||
|
trpcContext.invalidateQueries(['resumes.comments.list']);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
reset({ description: '' });
|
||||||
|
setIsReplyingComment(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit: SubmitHandler<IReplyInput> = async (data) => {
|
||||||
|
return commentReplyMutation.mutate(
|
||||||
|
{
|
||||||
|
parentId,
|
||||||
|
resumeId,
|
||||||
|
section,
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsReplyingComment(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setFormValue = (value: string) => {
|
||||||
|
setValue('description', value.trim(), { shouldDirty: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="flex-column space-y-2 pt-2">
|
||||||
|
<TextArea
|
||||||
|
{...(register('description', {
|
||||||
|
required: 'Reply cannot be empty!',
|
||||||
|
}),
|
||||||
|
{})}
|
||||||
|
defaultValue=""
|
||||||
|
disabled={commentReplyMutation.isLoading}
|
||||||
|
errorMessage={errors.description?.message}
|
||||||
|
label=""
|
||||||
|
placeholder="Leave your reply here"
|
||||||
|
onChange={setFormValue}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-row space-x-2">
|
||||||
|
<Button
|
||||||
|
disabled={commentReplyMutation.isLoading}
|
||||||
|
label="Cancel"
|
||||||
|
size="sm"
|
||||||
|
variant="tertiary"
|
||||||
|
onClick={onCancel}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={!isDirty || commentReplyMutation.isLoading}
|
||||||
|
isLoading={commentReplyMutation.isLoading}
|
||||||
|
label="Confirm"
|
||||||
|
size="sm"
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,129 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ArrowDownCircleIcon,
|
||||||
|
ArrowUpCircleIcon,
|
||||||
|
} from '@heroicons/react/20/solid';
|
||||||
|
import { Vote } from '@prisma/client';
|
||||||
|
|
||||||
|
import { trpc } from '~/utils/trpc';
|
||||||
|
|
||||||
|
type ResumeCommentVoteButtonsProps = {
|
||||||
|
commentId: string;
|
||||||
|
userId: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ResumeCommentVoteButtons({
|
||||||
|
commentId,
|
||||||
|
userId,
|
||||||
|
}: ResumeCommentVoteButtonsProps) {
|
||||||
|
const [upvoteAnimation, setUpvoteAnimation] = useState(false);
|
||||||
|
const [downvoteAnimation, setDownvoteAnimation] = useState(false);
|
||||||
|
|
||||||
|
const trpcContext = trpc.useContext();
|
||||||
|
|
||||||
|
// COMMENT VOTES
|
||||||
|
const commentVotesQuery = trpc.useQuery([
|
||||||
|
'resumes.comments.votes.list',
|
||||||
|
{ commentId },
|
||||||
|
]);
|
||||||
|
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']);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onVote = async (value: Vote, setAnimation: (_: boolean) => void) => {
|
||||||
|
setAnimation(true);
|
||||||
|
|
||||||
|
if (commentVotesQuery.data?.userVote?.value === value) {
|
||||||
|
return commentVotesDeleteMutation.mutate(
|
||||||
|
{
|
||||||
|
commentId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSettled: async () => setAnimation(false),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return commentVotesUpsertMutation.mutate(
|
||||||
|
{
|
||||||
|
commentId,
|
||||||
|
value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSettled: async () => setAnimation(false),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
disabled={
|
||||||
|
!userId ||
|
||||||
|
commentVotesQuery.isLoading ||
|
||||||
|
commentVotesUpsertMutation.isLoading ||
|
||||||
|
commentVotesDeleteMutation.isLoading
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onVote(Vote.UPVOTE, setUpvoteAnimation)}>
|
||||||
|
<ArrowUpCircleIcon
|
||||||
|
className={clsx(
|
||||||
|
'h-4 w-4',
|
||||||
|
commentVotesQuery.data?.userVote?.value === Vote.UPVOTE ||
|
||||||
|
upvoteAnimation
|
||||||
|
? 'fill-indigo-500'
|
||||||
|
: 'fill-gray-400',
|
||||||
|
userId &&
|
||||||
|
!downvoteAnimation &&
|
||||||
|
!upvoteAnimation &&
|
||||||
|
'hover:fill-indigo-500',
|
||||||
|
upvoteAnimation && 'animate-[bounce_0.5s_infinite] cursor-default',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</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, setDownvoteAnimation)}>
|
||||||
|
<ArrowDownCircleIcon
|
||||||
|
className={clsx(
|
||||||
|
'h-4 w-4',
|
||||||
|
commentVotesQuery.data?.userVote?.value === Vote.DOWNVOTE ||
|
||||||
|
downvoteAnimation
|
||||||
|
? 'fill-red-500'
|
||||||
|
: 'fill-gray-400',
|
||||||
|
userId &&
|
||||||
|
!downvoteAnimation &&
|
||||||
|
!upvoteAnimation &&
|
||||||
|
'hover:fill-red-500',
|
||||||
|
downvoteAnimation &&
|
||||||
|
'animate-[bounce_0.5s_infinite] cursor-default',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -15,6 +15,19 @@ export const resumeCommentsRouter = createRouter().query('list', {
|
|||||||
// The user's name and image to render
|
// The user's name and image to render
|
||||||
const comments = await ctx.prisma.resumesComment.findMany({
|
const comments = await ctx.prisma.resumesComment.findMany({
|
||||||
include: {
|
include: {
|
||||||
|
children: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
image: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
image: true,
|
image: true,
|
||||||
@ -26,15 +39,35 @@ export const resumeCommentsRouter = createRouter().query('list', {
|
|||||||
createdAt: 'desc',
|
createdAt: 'desc',
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
resumeId,
|
AND: [{ resumeId }, { parentId: null }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return comments.map((data) => {
|
return comments.map((data) => {
|
||||||
|
const children: Array<ResumeComment> = data.children.map((child) => {
|
||||||
|
return {
|
||||||
|
children: [],
|
||||||
|
createdAt: child.createdAt,
|
||||||
|
description: child.description,
|
||||||
|
id: child.id,
|
||||||
|
parentId: data.id,
|
||||||
|
resumeId: child.resumeId,
|
||||||
|
section: child.section,
|
||||||
|
updatedAt: child.updatedAt,
|
||||||
|
user: {
|
||||||
|
image: child.user.image,
|
||||||
|
name: child.user.name,
|
||||||
|
userId: child.userId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const comment: ResumeComment = {
|
const comment: ResumeComment = {
|
||||||
|
children,
|
||||||
createdAt: data.createdAt,
|
createdAt: data.createdAt,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
id: data.id,
|
id: data.id,
|
||||||
|
parentId: data.parentId,
|
||||||
resumeId: data.resumeId,
|
resumeId: data.resumeId,
|
||||||
section: data.section,
|
section: data.section,
|
||||||
updatedAt: data.updatedAt,
|
updatedAt: data.updatedAt,
|
||||||
|
@ -67,4 +67,26 @@ export const resumesCommentsUserRouter = createProtectedRouter()
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
})
|
||||||
|
.mutation('reply', {
|
||||||
|
input: z.object({
|
||||||
|
description: z.string(),
|
||||||
|
parentId: z.string(),
|
||||||
|
resumeId: z.string(),
|
||||||
|
section: z.nativeEnum(ResumesSection),
|
||||||
|
}),
|
||||||
|
async resolve({ ctx, input }) {
|
||||||
|
const userId = ctx.session.user.id;
|
||||||
|
const { description, parentId, resumeId, section } = input;
|
||||||
|
|
||||||
|
return await ctx.prisma.resumesComment.create({
|
||||||
|
data: {
|
||||||
|
description,
|
||||||
|
parentId,
|
||||||
|
resumeId,
|
||||||
|
section,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
2
apps/portal/src/types/resume-comments.d.ts
vendored
2
apps/portal/src/types/resume-comments.d.ts
vendored
@ -5,9 +5,11 @@ import type { ResumesCommentVote, ResumesSection } from '@prisma/client';
|
|||||||
* frontend-friendly representation of the query
|
* frontend-friendly representation of the query
|
||||||
*/
|
*/
|
||||||
export type ResumeComment = Readonly<{
|
export type ResumeComment = Readonly<{
|
||||||
|
children: Array<ResumeComment>;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
description: string;
|
description: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
parentId: string?;
|
||||||
resumeId: string;
|
resumeId: string;
|
||||||
section: ResumesSection;
|
section: ResumesSection;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
Reference in New Issue
Block a user