[resumes][fix] Fix bugs in comments section (#363)
* [resumes][fix] left-align all comments * [resumes][fix] add comment owner OP tag * [resumes][fix] render multi-line text in comments * [resumes][feat] add see more/less for overflow comments * [resumes][refactor] prefix comments section with Resume * [resumes][refactor] Refactor routers from reviews -> comments * [resumes][refactor] use Vote enum in ResumesCommentVote * [resumes][refactor] add comment count to tabs * [resumes][refactor] combine resume-card and resume-body into resume-list-item Co-authored-by: Terence Ho <>pull/374/head
parent
ffd7539179
commit
fd67a20a2b
@ -0,0 +1,9 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Changed the type of `value` on the `ResumesCommentVote` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "ResumesCommentVote" DROP COLUMN "value",
|
||||
ADD COLUMN "value" "Vote" NOT NULL;
|
||||
@ -1,35 +0,0 @@
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { Spinner } from '@tih/ui';
|
||||
|
||||
import Comment from './comment/Comment';
|
||||
|
||||
import type { ResumeComment } from '~/types/resume-comments';
|
||||
|
||||
type Props = Readonly<{
|
||||
comments: Array<ResumeComment>;
|
||||
isLoading: boolean;
|
||||
}>;
|
||||
|
||||
export default function CommentListItems({ comments, isLoading }: Props) {
|
||||
const { data: session } = useSession();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="col-span-10 pt-4">
|
||||
<Spinner display="block" size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-3 overflow-y-auto">
|
||||
{comments.map((comment) => (
|
||||
<Comment
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
userId={session?.user?.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,54 +0,0 @@
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useState } from 'react';
|
||||
import { Tabs } from '@tih/ui';
|
||||
import { Button } from '@tih/ui';
|
||||
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
import CommentListItems from './CommentListItems';
|
||||
import { COMMENTS_SECTIONS } from './constants';
|
||||
import ResumeSignInButton from '../shared/ResumeSignInButton';
|
||||
|
||||
type CommentsListProps = Readonly<{
|
||||
resumeId: string;
|
||||
setShowCommentsForm: (show: boolean) => void;
|
||||
}>;
|
||||
|
||||
export default function CommentsList({
|
||||
resumeId,
|
||||
setShowCommentsForm,
|
||||
}: CommentsListProps) {
|
||||
const { data: sessionData } = useSession();
|
||||
const [tab, setTab] = useState(COMMENTS_SECTIONS[0].value);
|
||||
|
||||
const commentsQuery = trpc.useQuery(['resumes.reviews.list', { resumeId }]);
|
||||
const renderButton = () => {
|
||||
if (sessionData === null) {
|
||||
return <ResumeSignInButton text="to join discussion" />;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
display="block"
|
||||
label="Add your review"
|
||||
variant="tertiary"
|
||||
onClick={() => setShowCommentsForm(true)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{renderButton()}
|
||||
<Tabs
|
||||
label="comments"
|
||||
tabs={COMMENTS_SECTIONS}
|
||||
value={tab}
|
||||
onChange={(value) => setTab(value)}
|
||||
/>
|
||||
<CommentListItems
|
||||
comments={commentsQuery.data?.filter((c) => c.section === tab) ?? []}
|
||||
isLoading={commentsQuery.isFetching}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
import {
|
||||
ArrowDownCircleIcon,
|
||||
ArrowUpCircleIcon,
|
||||
} from '@heroicons/react/20/solid';
|
||||
import { FaceSmileIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
import ResumeExpandableText from '../shared/ResumeExpandableText';
|
||||
|
||||
import type { ResumeComment } from '~/types/resume-comments';
|
||||
|
||||
type ResumeCommentListItemProps = {
|
||||
comment: ResumeComment;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export default function ResumeCommentListItem({
|
||||
comment,
|
||||
userId,
|
||||
}: ResumeCommentListItemProps) {
|
||||
const isCommentOwner = userId === comment.user.userId;
|
||||
|
||||
return (
|
||||
<div className="border-primary-300 w-3/4 rounded-md border-2 bg-white p-2 drop-shadow-md">
|
||||
<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="flex flex-row items-center space-x-1">
|
||||
<div className="font-medium">
|
||||
{comment.user.name ?? 'Reviewer ABC'}
|
||||
</div>
|
||||
|
||||
<div className="text-primary-800 text-xs font-medium">
|
||||
{isCommentOwner ? '(Me)' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-600">
|
||||
{comment.createdAt.toLocaleString('en-US', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<ResumeExpandableText>{comment.description}</ResumeExpandableText>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,86 @@
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useState } from 'react';
|
||||
import { Spinner, Tabs } from '@tih/ui';
|
||||
import { Button } from '@tih/ui';
|
||||
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
import { RESUME_COMMENTS_SECTIONS } from './resumeCommentConstants';
|
||||
import ResumeCommentListItem from './ResumeCommentListItem';
|
||||
import ResumeSignInButton from '../shared/ResumeSignInButton';
|
||||
|
||||
import type { ResumeComment } from '~/types/resume-comments';
|
||||
|
||||
type ResumeCommentsListProps = Readonly<{
|
||||
resumeId: string;
|
||||
setShowCommentsForm: (show: boolean) => void;
|
||||
}>;
|
||||
|
||||
export default function ResumeCommentsList({
|
||||
resumeId,
|
||||
setShowCommentsForm,
|
||||
}: ResumeCommentsListProps) {
|
||||
const { data: sessionData } = useSession();
|
||||
const [tab, setTab] = useState(RESUME_COMMENTS_SECTIONS[0].value);
|
||||
const [tabs, setTabs] = useState(RESUME_COMMENTS_SECTIONS);
|
||||
|
||||
const commentsQuery = trpc.useQuery(['resumes.comments.list', { resumeId }], {
|
||||
onSuccess: (data: Array<ResumeComment>) => {
|
||||
const updatedTabs = RESUME_COMMENTS_SECTIONS.map(({ label, value }) => {
|
||||
const count = data.filter(({ section }) => section === value).length;
|
||||
const updatedLabel = count > 0 ? `${label} (${count})` : label;
|
||||
return {
|
||||
label: updatedLabel,
|
||||
value,
|
||||
};
|
||||
});
|
||||
|
||||
setTabs(updatedTabs);
|
||||
},
|
||||
});
|
||||
|
||||
const renderButton = () => {
|
||||
if (sessionData === null) {
|
||||
return <ResumeSignInButton text="to join discussion" />;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
display="block"
|
||||
label="Add your review"
|
||||
variant="tertiary"
|
||||
onClick={() => setShowCommentsForm(true)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{renderButton()}
|
||||
|
||||
<Tabs
|
||||
label="comments"
|
||||
tabs={tabs}
|
||||
value={tab}
|
||||
onChange={(value) => setTab(value)}
|
||||
/>
|
||||
|
||||
{commentsQuery.isFetching ? (
|
||||
<div className="col-span-10 pt-4">
|
||||
<Spinner display="block" size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-3 overflow-y-auto">
|
||||
{(commentsQuery.data?.filter((c) => c.section === tab) ?? []).map(
|
||||
(comment) => (
|
||||
<ResumeCommentListItem
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
userId={sessionData?.user?.id}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { ResumesSection } from '@prisma/client';
|
||||
|
||||
export const COMMENTS_SECTIONS = [
|
||||
export const RESUME_COMMENTS_SECTIONS = [
|
||||
{
|
||||
label: 'General',
|
||||
value: ResumesSection.GENERAL,
|
||||
@ -0,0 +1,48 @@
|
||||
import clsx from 'clsx';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
type ResumeExpandableTextProps = Readonly<{
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
export default function ResumeExpandableText({
|
||||
children,
|
||||
}: ResumeExpandableTextProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
|
||||
const [descriptionOverflow, setDescriptionOverflow] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (ref.current && ref.current.clientHeight < ref.current.scrollHeight) {
|
||||
setDescriptionOverflow(true);
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
const onSeeActionClicked = () => {
|
||||
setDescriptionExpanded(!descriptionExpanded);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
'whitespace-pre-wrap text-sm',
|
||||
'line-clamp-3',
|
||||
descriptionExpanded ? 'line-clamp-none' : '',
|
||||
)}>
|
||||
{children}
|
||||
</span>
|
||||
{descriptionOverflow && (
|
||||
<div className="flex flex-row">
|
||||
<div
|
||||
className="text-xs text-indigo-500 hover:text-indigo-300"
|
||||
onClick={onSeeActionClicked}>
|
||||
{descriptionExpanded ? 'See Less' : 'See More'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in new issue