[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 <>pull/405/head
parent
6a665bc976
commit
d10377e0f9
@ -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;
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in new issue