[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