[resumes][refactor] Abstract comment edit form and votes to their components

pull/401/head
Terence Ho 3 years ago
parent ecf67bc23d
commit 671484147e

@ -1,18 +1,8 @@
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 { 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 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,10 +13,6 @@ type ResumeCommentListItemProps = {
userId: string | undefined; userId: string | undefined;
}; };
type ICommentInput = {
description: string;
};
export default function ResumeCommentListItem({ export default function ResumeCommentListItem({
comment, comment,
userId, userId,
@ -34,108 +20,6 @@ export default function ResumeCommentListItem({
const isCommentOwner = userId === comment.user.userId; const isCommentOwner = userId === comment.user.userId;
const [isEditingComment, setIsEditingComment] = useState(false); const [isEditingComment, setIsEditingComment] = useState(false);
const [upvoteAnimation, setUpvoteAnimation] = useState(false);
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="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">
@ -174,102 +58,17 @@ 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>
<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>
{isCommentOwner && !isEditingComment && ( {isCommentOwner && !isEditingComment && (
<button <button

@ -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,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…
Cancel
Save