mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-30 05:34:33 +08:00
[resumes][feat] fetch comments from database (#320)
* [resumes][feat] Add resume-comments type * [resumes][feat] Add resume-comments type * [resumes][feat] Filter comments * [resumes][feat] Add comments render * [resumes][refactor] rename variables * [resumes][refactor] update invalidateQueries * [resumes][refactor] Use resumeId in [resumeId].tsx * [resumes][fix] fix invalidateQuery Co-authored-by: Terence Ho <>
This commit is contained in:
@ -39,17 +39,29 @@ export default function CommentsForm({
|
|||||||
skills: '',
|
skills: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const reviewCreateMutation = trpc.useMutation('resumes.reviews.user.create');
|
|
||||||
|
const trpcContext = trpc.useContext();
|
||||||
|
const reviewCreateMutation = trpc.useMutation('resumes.reviews.user.create', {
|
||||||
|
onSuccess: () => {
|
||||||
|
// New review added, invalidate query to trigger refetch
|
||||||
|
trpcContext.invalidateQueries(['resumes.reviews.list']);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// TODO: Give a feedback to the user if the action succeeds/fails
|
// TODO: Give a feedback to the user if the action succeeds/fails
|
||||||
const onSubmit: SubmitHandler<IFormInput> = async (data) => {
|
const onSubmit: SubmitHandler<IFormInput> = async (data) => {
|
||||||
await reviewCreateMutation.mutate({
|
return await reviewCreateMutation.mutate(
|
||||||
resumeId,
|
{
|
||||||
...data,
|
resumeId,
|
||||||
});
|
...data,
|
||||||
|
},
|
||||||
// Redirect back to comments section
|
{
|
||||||
setShowCommentsForm(false);
|
onSuccess: () => {
|
||||||
|
// Redirect back to comments section
|
||||||
|
setShowCommentsForm(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
|
import { useSession } from 'next-auth/react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Tabs } from '@tih/ui';
|
import { Tabs } from '@tih/ui';
|
||||||
|
|
||||||
import { trpc } from '~/utils/trpc';
|
import { trpc } from '~/utils/trpc';
|
||||||
|
|
||||||
|
import Comment from './comment/Comment';
|
||||||
import CommentsListButton from './CommentsListButton';
|
import CommentsListButton from './CommentsListButton';
|
||||||
import { COMMENTS_SECTIONS } from './constants';
|
import { COMMENTS_SECTIONS } from './constants';
|
||||||
|
|
||||||
@ -16,12 +18,15 @@ export default function CommentsList({
|
|||||||
setShowCommentsForm,
|
setShowCommentsForm,
|
||||||
}: CommentsListProps) {
|
}: CommentsListProps) {
|
||||||
const [tab, setTab] = useState(COMMENTS_SECTIONS[0].value);
|
const [tab, setTab] = useState(COMMENTS_SECTIONS[0].value);
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const commentsQuery = trpc.useQuery(['resumes.reviews.list', { resumeId }]);
|
// Fetch the most updated comments to render
|
||||||
|
const commentsQuery = trpc.useQuery([
|
||||||
|
'resumes.reviews.list',
|
||||||
|
{ resumeId, section: tab },
|
||||||
|
]);
|
||||||
|
|
||||||
/* eslint-disable no-console */
|
// TODO: Add loading prompt
|
||||||
console.log(commentsQuery.data);
|
|
||||||
/* eslint-enable no-console */
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@ -32,7 +37,18 @@ export default function CommentsList({
|
|||||||
value={tab}
|
value={tab}
|
||||||
onChange={(value) => setTab(value)}
|
onChange={(value) => setTab(value)}
|
||||||
/>
|
/>
|
||||||
{/* TODO: Add comments lists */}
|
|
||||||
|
<div className="m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-3 overflow-y-scroll">
|
||||||
|
{commentsQuery.data?.map((comment) => {
|
||||||
|
return (
|
||||||
|
<Comment
|
||||||
|
key={comment.id}
|
||||||
|
comment={comment}
|
||||||
|
userId={session?.user?.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,18 @@
|
|||||||
|
import CommentBody from './CommentBody';
|
||||||
|
import CommentCard from './CommentCard';
|
||||||
|
|
||||||
|
import type { ResumeComment } from '~/types/resume-comments';
|
||||||
|
|
||||||
|
type CommentProps = {
|
||||||
|
comment: ResumeComment;
|
||||||
|
userId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Comment({ comment, userId }: CommentProps) {
|
||||||
|
const isCommentOwner = userId === comment.user.userId;
|
||||||
|
return (
|
||||||
|
<CommentCard isCommentOwner={isCommentOwner}>
|
||||||
|
<CommentBody comment={comment} isCommentOwner={isCommentOwner} />
|
||||||
|
</CommentCard>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
ArrowDownCircleIcon,
|
||||||
|
ArrowUpCircleIcon,
|
||||||
|
} from '@heroicons/react/20/solid';
|
||||||
|
import { FaceSmileIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
import type { ResumeComment } from '~/types/resume-comments';
|
||||||
|
|
||||||
|
type CommentBodyProps = {
|
||||||
|
comment: ResumeComment;
|
||||||
|
isCommentOwner?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CommentBody({
|
||||||
|
comment,
|
||||||
|
isCommentOwner,
|
||||||
|
}: CommentBodyProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-row space-x-2 p-1 align-top">
|
||||||
|
{comment.user.image ? (
|
||||||
|
<img
|
||||||
|
alt={comment.user.name ?? 'Reviewer'}
|
||||||
|
className="mt-1 h-8 w-8 rounded-full"
|
||||||
|
src={comment.user.image!}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FaceSmileIcon className="h-8 w-8 rounded-full" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col space-y-1">
|
||||||
|
{/* Name and creation time */}
|
||||||
|
<div className="flex flex-row justify-between">
|
||||||
|
<div className="font-medium">
|
||||||
|
{comment.user.name ?? 'Reviewer ABC'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600">
|
||||||
|
{comment.createdAt.toLocaleString('en-US', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="text-sm">{comment.description}</div>
|
||||||
|
|
||||||
|
{/* Upvote and edit */}
|
||||||
|
<div className="flex flex-row space-x-1 pt-1 align-middle">
|
||||||
|
{/* TODO: Implement upvote */}
|
||||||
|
<ArrowUpCircleIcon className="h-4 w-4 fill-gray-400" />
|
||||||
|
<div className="text-xs">{comment.numVotes}</div>
|
||||||
|
<ArrowDownCircleIcon className="h-4 w-4 fill-gray-400" />
|
||||||
|
|
||||||
|
{/* TODO: Implement edit */}
|
||||||
|
{isCommentOwner ? (
|
||||||
|
<div className="text-primary-800 hover:text-primary-400 px-1 text-xs">
|
||||||
|
Edit
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
type CommentCardProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
isCommentOwner?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CommentCard({
|
||||||
|
isCommentOwner,
|
||||||
|
children,
|
||||||
|
}: CommentCardProps) {
|
||||||
|
// Used two different <div> to allow customisation of owner comments
|
||||||
|
return isCommentOwner ? (
|
||||||
|
<div className="border-primary-300 float-right w-3/4 rounded-md border-2 bg-white p-2 drop-shadow-md">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border-primary-300 float-left w-3/4 rounded-md border-2 bg-white p-2 drop-shadow-md">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -137,8 +137,7 @@ export default function ResumeReviewPage() {
|
|||||||
<ResumePdf url={detailsQuery.data.url} />
|
<ResumePdf url={detailsQuery.data.url} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-8 w-1/2">
|
<div className="mx-8 w-1/2">
|
||||||
{/* TODO: Update resumeId */}
|
<CommentsSection resumeId={resumeId as string} />
|
||||||
<CommentsSection resumeId="cl8y6gtez0009yedbne9qp5zi" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
@ -1,19 +1,23 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { ResumesSection } from '@prisma/client';
|
||||||
|
|
||||||
import { createRouter } from './context';
|
import { createRouter } from './context';
|
||||||
|
|
||||||
|
import type { ResumeComment } from '~/types/resume-comments';
|
||||||
|
|
||||||
export const resumeReviewsRouter = createRouter().query('list', {
|
export const resumeReviewsRouter = createRouter().query('list', {
|
||||||
input: z.object({
|
input: z.object({
|
||||||
resumeId: z.string(),
|
resumeId: z.string(),
|
||||||
|
section: z.nativeEnum(ResumesSection),
|
||||||
}),
|
}),
|
||||||
async resolve({ ctx, input }) {
|
async resolve({ ctx, input }) {
|
||||||
const userId = ctx.session?.user?.id;
|
const userId = ctx.session?.user?.id;
|
||||||
const { resumeId } = input;
|
const { resumeId, section } = input;
|
||||||
|
|
||||||
// For this resume, we retrieve every comment's information, along with:
|
// For this resume, we retrieve every comment's information, along with:
|
||||||
// The user's name and image to render
|
// The user's name and image to render
|
||||||
// Number of votes, and whether the user (if-any) has voted
|
// Number of votes, and whether the user (if-any) has voted
|
||||||
return await ctx.prisma.resumesComment.findMany({
|
const comments = await ctx.prisma.resumesComment.findMany({
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
@ -38,7 +42,31 @@ export const resumeReviewsRouter = createRouter().query('list', {
|
|||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
resumeId,
|
resumeId,
|
||||||
|
section,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return comments.map((data) => {
|
||||||
|
const hasVoted = data.votes.length > 0;
|
||||||
|
const numVotes = data._count.votes;
|
||||||
|
|
||||||
|
const comment: ResumeComment = {
|
||||||
|
createdAt: data.createdAt,
|
||||||
|
description: data.description,
|
||||||
|
hasVoted,
|
||||||
|
id: data.id,
|
||||||
|
numVotes,
|
||||||
|
resumeId: data.resumeId,
|
||||||
|
section: data.section,
|
||||||
|
updatedAt: data.updatedAt,
|
||||||
|
user: {
|
||||||
|
image: data.user.image,
|
||||||
|
name: data.user.name,
|
||||||
|
userId: data.userId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return comment;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
21
apps/portal/src/types/resume-comments.d.ts
vendored
Normal file
21
apps/portal/src/types/resume-comments.d.ts
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import type { ResumesSection } from '@prisma/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returned by `resumeReviewsRouter` (query for 'resumes.reviews.list') and received as prop by `Comment` in `CommentsList`
|
||||||
|
* frontend-friendly representation of the query
|
||||||
|
*/
|
||||||
|
export type ResumeComment = {
|
||||||
|
createdAt: Date;
|
||||||
|
description: string;
|
||||||
|
hasVoted: boolean;
|
||||||
|
id: string;
|
||||||
|
numVotes: number;
|
||||||
|
resumeId: string;
|
||||||
|
section: ResumesSection;
|
||||||
|
updatedAt: Date;
|
||||||
|
user: {
|
||||||
|
image: string?;
|
||||||
|
name: string?;
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
};
|
Reference in New Issue
Block a user