[questions][feat] add upvote, downvotes

pull/355/head
Jeff Sieu 3 years ago
parent 18a9f62b12
commit 452bd8e27e

@ -1,8 +1,11 @@
import { format } from 'date-fns';
import { useAnswerCommentVote } from '~/utils/questions/useVote';
import VotingButtons from './VotingButtons';
export type CommentListItemProps = {
export type AnswerCommentListItemProps = {
answerCommentId: string;
authorImageUrl: string;
authorName: string;
content: string;
@ -10,16 +13,26 @@ export type CommentListItemProps = {
upvoteCount: number;
};
export default function CommentListItem({
export default function AnswerCommentListItem({
authorImageUrl,
authorName,
content,
createdAt,
upvoteCount,
}: CommentListItemProps) {
answerCommentId,
}: AnswerCommentListItemProps) {
const { handleDownvote, handleUpvote, vote } =
useAnswerCommentVote(answerCommentId);
return (
<div className="flex gap-4 border bg-white p-2 ">
<VotingButtons size="sm" upvoteCount={upvoteCount} />
<VotingButtons
size="sm"
upvoteCount={upvoteCount}
vote={vote}
onDownvote={handleDownvote}
onUpvote={handleUpvote}
/>
<div className="mt-1 flex flex-col gap-1">
<div className="flex items-center gap-2">
<img

@ -1,14 +1,18 @@
import React from 'react';
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline';
import type { Vote } from '@prisma/client';
import type { ButtonSize } from '@tih/ui';
import { Button } from '@tih/ui';
import { VoteState } from '~/utils/questions/useVote';
export type BackendVote = {
id: string;
vote: Vote;
};
export type VotingButtonsCallbackProps = {
onDownvote: () => void;
onUpvote: () => void;
voteState: VoteState;
vote: BackendVote | null;
};
export type VotingButtonsProps = VotingButtonsCallbackProps & {
@ -17,16 +21,16 @@ export type VotingButtonsProps = VotingButtonsCallbackProps & {
};
export default function VotingButtons({
voteState,
vote,
onDownvote,
onUpvote,
upvoteCount,
size = 'md',
}: VotingButtonsProps) {
const upvoteButtonVarient =
voteState === VoteState.UPVOTE ? 'secondary' : 'tertiary';
const downvoteButtonVarient =
voteState === VoteState.DOWNVOTE ? 'secondary' : 'tertiary';
const upvoteButtonVariant =
vote?.vote === 'UPVOTE' ? 'secondary' : 'tertiary';
const downvoteButtonVariant =
vote?.vote === 'DOWNVOTE' ? 'secondary' : 'tertiary';
return (
<div className="flex flex-col items-center">
<Button
@ -34,7 +38,7 @@ export default function VotingButtons({
isLabelHidden={true}
label="Upvote"
size={size}
variant={upvoteButtonVarient}
variant={upvoteButtonVariant}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
@ -47,7 +51,7 @@ export default function VotingButtons({
isLabelHidden={true}
label="Downvote"
size={size}
variant={downvoteButtonVarient}
variant={downvoteButtonVariant}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();

@ -1,13 +1,13 @@
import { format } from 'date-fns';
import { ChatBubbleLeftRightIcon } from '@heroicons/react/24/outline';
import type {
VotingButtonsCallbackProps,
VotingButtonsProps,
} from '../VotingButtons';
import { useAnswerVote } from '~/utils/questions/useVote';
import type { VotingButtonsProps } from '../VotingButtons';
import VotingButtons from '../VotingButtons';
export type AnswerCardProps = VotingButtonsCallbackProps & {
export type AnswerCardProps = {
answerId: string;
authorImageUrl: string;
authorName: string;
commentCount?: number;
@ -18,25 +18,24 @@ export type AnswerCardProps = VotingButtonsCallbackProps & {
};
export default function AnswerCard({
voteState,
onDownvote,
onUpvote,
answerId,
authorName,
authorImageUrl,
upvoteCount,
content,
createdAt,
commentCount,
votingButtonsSize,
upvoteCount,
}: AnswerCardProps) {
const { handleUpvote, handleDownvote, vote } = useAnswerVote(answerId);
return (
<article className="flex gap-4 rounded-md border bg-white p-2">
<VotingButtons
size={votingButtonsSize}
upvoteCount={upvoteCount}
voteState={voteState}
onDownvote={onDownvote}
onUpvote={onUpvote}
vote={vote}
onDownvote={handleDownvote}
onUpvote={handleUpvote}
/>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">

@ -1,72 +1,26 @@
import type { QuestionsQuestionType } from '@prisma/client';
import { Badge } from '@tih/ui';
import type { QuestionCardProps } from './QuestionCard';
import QuestionCard from './QuestionCard';
import QuestionTypeBadge from '../QuestionTypeBadge';
import type { VotingButtonsCallbackProps } from '../VotingButtons';
import VotingButtons from '../VotingButtons';
export type QuestionOverviewCardProps = Omit<
QuestionCardProps & {
showActionButton: false;
showUserStatistics: false;
showVoteButtons: true;
},
| 'actionButtonLabel'
| 'onActionButtonClick'
| 'showActionButton'
| 'showUserStatistics'
| 'showVoteButtons'
>;
type UpvoteProps =
| {
showVoteButtons: true;
upvoteCount: number;
}
| {
showVoteButtons?: false;
upvoteCount?: never;
};
export type FullQuestionCardProps = UpvoteProps &
VotingButtonsCallbackProps & {
company: string;
content: string;
location: string;
receivedCount: number;
role: string;
timestamp: string;
type: QuestionsQuestionType;
};
export default function FullQuestionCard({
voteState,
onDownvote,
onUpvote,
company,
content,
showVoteButtons,
upvoteCount,
timestamp,
role,
location,
type,
}: FullQuestionCardProps) {
const altText = `${company} logo`;
export default function FullQuestionCard(props: QuestionOverviewCardProps) {
return (
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4">
{showVoteButtons && (
<VotingButtons
upvoteCount={upvoteCount}
voteState={voteState}
onDownvote={onDownvote}
onUpvote={onUpvote}
/>
)}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<img alt={altText} src="https://logo.clearbit.com/google.com"></img>
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2 text-slate-500">
<Badge label={company} variant="primary" />
<QuestionTypeBadge type={type} />
<p className="text-xs">
{timestamp} · {location} · {role}
</p>
</div>
</div>
<div className="mx-2 mb-2">
<p>{content}</p>
</div>
</div>
</article>
<QuestionCard
{...props}
showActionButton={false}
showUserStatistics={false}
showVoteButtons={true}
/>
);
}

@ -2,8 +2,9 @@ import { ChatBubbleBottomCenterTextIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import { Badge, Button } from '@tih/ui';
import { useQuestionVote } from '~/utils/questions/useVote';
import QuestionTypeBadge from '../QuestionTypeBadge';
import type { VotingButtonsCallbackProps } from '../VotingButtons';
import VotingButtons from '../VotingButtons';
type UpvoteProps =
@ -40,12 +41,11 @@ type ActionButtonProps =
export type QuestionCardProps = ActionButtonProps &
StatisticsProps &
UpvoteProps &
VotingButtonsCallbackProps & {
UpvoteProps & {
company: string;
content: string;
href?: string;
location: string;
questionId: string;
receivedCount: number;
role: string;
timestamp: string;
@ -53,9 +53,7 @@ export type QuestionCardProps = ActionButtonProps &
};
export default function QuestionCard({
voteState,
onDownvote,
onUpvote,
questionId,
company,
answerCount,
content,
@ -71,14 +69,16 @@ export default function QuestionCard({
role,
location,
}: QuestionCardProps) {
const { handleDownvote, handleUpvote, vote } = useQuestionVote(questionId);
return (
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4 hover:bg-slate-50">
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4">
{showVoteButtons && (
<VotingButtons
upvoteCount={upvoteCount}
voteState={voteState}
onDownvote={onDownvote}
onUpvote={onUpvote}
vote={vote}
onDownvote={handleDownvote}
onUpvote={handleUpvote}
/>
)}
<div className="flex flex-col gap-2">

@ -3,8 +3,8 @@ import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
import { Button, Select, TextArea } from '@tih/ui';
import AnswerCommentListItem from '~/components/questions/AnswerCommentListItem';
import FullAnswerCard from '~/components/questions/card/FullAnswerCard';
import CommentListItem from '~/components/questions/CommentListItem';
import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
import { useFormRegister } from '~/utils/questions/useFormRegister';
@ -81,6 +81,7 @@ export default function QuestionPage() {
<div className="flex w-full justify-center overflow-y-auto py-4 px-5">
<div className="flex max-w-7xl flex-1 flex-col gap-2">
<FullAnswerCard
answerId={answer.id}
authorImageUrl={answer.userImage}
authorName={answer.user}
content={answer.content}
@ -138,8 +139,9 @@ export default function QuestionPage() {
</form>
{(comments ?? []).map((comment) => (
<CommentListItem
<AnswerCommentListItem
key={comment.id}
answerCommentId={comment.id}
authorImageUrl={comment.userImage}
authorName={comment.user}
content={comment.content}

@ -3,14 +3,13 @@ import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
import { Button, Collapsible, Select, TextArea } from '@tih/ui';
import AnswerCommentListItem from '~/components/questions/AnswerCommentListItem';
import FullQuestionCard from '~/components/questions/card/FullQuestionCard';
import QuestionAnswerCard from '~/components/questions/card/QuestionAnswerCard';
import CommentListItem from '~/components/questions/CommentListItem';
import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
import createSlug from '~/utils/questions/createSlug';
import { useFormRegister } from '~/utils/questions/useFormRegister';
import { useVote } from '~/utils/questions/useVote';
import { trpc } from '~/utils/trpc';
export type AnswerQuestionData = {
@ -40,7 +39,6 @@ export default function QuestionPage() {
const commentRegister = useFormRegister(comRegister);
const { questionId } = router.query;
const [handleUpvote, handleDownvote, voteState] = useVote();
const { data: question } = trpc.useQuery([
'questions.questions.getQuestionById',
@ -114,12 +112,9 @@ export default function QuestionPage() {
<div className="flex w-full justify-center overflow-y-auto py-4 px-5">
<div className="flex max-w-7xl flex-1 flex-col gap-2">
<FullQuestionCard
voteState={voteState}
onDownvote={() => handleDownvote(questionId as string)}
onUpvote={() => handleUpvote(questionId as string)}
{...question}
receivedCount={0} // TODO: Change to actual value
showVoteButtons={true}
questionId={question.id}
receivedCount={0}
timestamp={question.seenAt.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
@ -178,8 +173,9 @@ export default function QuestionPage() {
</form>
{(comments ?? []).map((comment) => (
<CommentListItem
<AnswerCommentListItem
key={comment.id}
answerCommentId={comment.id}
authorImageUrl={comment.userImage}
authorName={comment.user}
content={comment.content}
@ -240,6 +236,7 @@ export default function QuestionPage() {
{(answers ?? []).map((answer) => (
<QuestionAnswerCard
key={answer.id}
answerId={answer.id}
authorImageUrl={answer.userImage}
authorName={answer.user}
commentCount={answer.numComments}

@ -24,7 +24,6 @@ import {
useSearchFilter,
useSearchFilterSingle,
} from '~/utils/questions/useSearchFilter';
import { useVote } from '~/utils/questions/useVote';
import { trpc } from '~/utils/trpc';
export default function QuestionsHomePage() {
@ -145,7 +144,6 @@ export default function QuestionsHomePage() {
]);
const { pathname } = router;
const [handleDownvote, handleUpvote, voteState] = useVote();
useEffect(() => {
if (areFiltersInitialized) {
// Router.replace used instead of router.replace to avoid
@ -293,6 +291,7 @@ export default function QuestionsHomePage() {
question.content,
)}`}
location={question.location}
questionId={question.id}
receivedCount={0}
role={question.role}
timestamp={question.seenAt.toLocaleDateString(undefined, {
@ -301,9 +300,6 @@ export default function QuestionsHomePage() {
})}
type={question.type} // TODO: Implement received count
upvoteCount={question.numVotes}
voteState={voteState}
onDownvote={() => handleDownvote(question.id)}
onUpvote={() => handleUpvote(question.id)}
/>
))}
{questions?.length === 0 && (

@ -184,7 +184,7 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
},
});
if (voteToUpdate?.id !== userId) {
if (voteToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@ -215,7 +215,7 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
},
});
if (voteToDelete?.id !== userId) {
if (voteToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',

@ -245,7 +245,7 @@ export const questionsAnswerRouter = createProtectedRouter()
},
});
if (voteToUpdate?.id !== userId) {
if (voteToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@ -275,7 +275,7 @@ export const questionsAnswerRouter = createProtectedRouter()
},
});
if (voteToDelete?.id !== userId) {
if (voteToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',

@ -183,7 +183,7 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
},
});
if (voteToUpdate?.id !== userId) {
if (voteToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@ -214,7 +214,7 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
},
});
if (voteToDelete?.id !== userId) {
if (voteToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',

@ -335,7 +335,7 @@ export const questionsQuestionRouter = createProtectedRouter()
},
});
if (voteToUpdate?.id !== userId) {
if (voteToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@ -365,7 +365,7 @@ export const questionsQuestionRouter = createProtectedRouter()
},
});
if (voteToDelete?.id !== userId) {
if (voteToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',

@ -1,19 +1,165 @@
import { useState } from 'react';
import { useCallback } from 'react';
import type { Vote } from '@prisma/client';
export const enum VoteState {
NO_VOTE,
UPVOTE,
DOWNVOTE,
}
import { trpc } from '../trpc';
export const useVote = () => {
const [voteState, setVoteState] = useState(VoteState.NO_VOTE);
type UseVoteOptions = {
createVote: (opts: { vote: Vote }) => void;
deleteVote: (opts: { id: string }) => void;
updateVote: (opts: BackendVote) => void;
};
type BackendVote = {
id: string;
vote: Vote;
};
const createVoteCallbacks = (
vote: BackendVote | null,
opts: UseVoteOptions,
) => {
const { createVote, updateVote, deleteVote } = opts;
const handleUpvote = (id: string, voteState) => {
//
const handleUpvote = () => {
// Either upvote or remove upvote
if (vote) {
if (vote.vote === 'DOWNVOTE') {
updateVote({
id: vote.id,
vote: 'UPVOTE',
});
} else {
deleteVote({
id: vote.id,
});
}
// Update vote to an upvote
} else {
createVote({
vote: 'UPVOTE',
});
}
};
const handleDownvote = (id: string) => {};
const handleDownvote = () => {
// Either downvote or remove downvote
if (vote) {
if (vote.vote === 'UPVOTE') {
updateVote({
id: vote.id,
vote: 'DOWNVOTE',
});
} else {
deleteVote({
id: vote.id,
});
}
// Update vote to an upvote
} else {
createVote({
vote: 'DOWNVOTE',
});
}
};
return { handleDownvote, handleUpvote };
};
type MutationKey = Parameters<typeof trpc.useMutation>[0];
type QueryKey = Parameters<typeof trpc.useQuery>[0][0];
export const useQuestionVote = (id: string) => {
return useVote(id, {
create: 'questions.questions.createVote',
deleteKey: 'questions.questions.deleteVote',
idKey: 'questionId',
query: 'questions.questions.getVote',
update: 'questions.questions.updateVote',
});
};
export const useAnswerVote = (id: string) => {
return useVote(id, {
create: 'questions.answers.createVote',
deleteKey: 'questions.answers.deleteVote',
idKey: 'answerId',
query: 'questions.answers.getVote',
update: 'questions.answers.updateVote',
});
};
export const useQuestionCommentVote = (id: string) => {
return useVote(id, {
create: 'questions.questions.comments.createVote',
deleteKey: 'questions.questions.comments.deleteVote',
idKey: 'questionCommentId',
query: 'questions.questions.comments.getVote',
update: 'questions.questions.comments.updateVote',
});
};
export const useAnswerCommentVote = (id: string) => {
return useVote(id, {
create: 'questions.answers.comments.createVote',
deleteKey: 'questions.answers.comments.deleteVote',
idKey: 'answerCommentId',
query: 'questions.answers.comments.getVote',
update: 'questions.answers.comments.updateVote',
});
};
type VoteProps<VoteQueryKey extends QueryKey = QueryKey> = {
create: MutationKey;
deleteKey: MutationKey;
idKey: string;
query: VoteQueryKey;
update: MutationKey;
};
export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
id: string,
opts: VoteProps<VoteQueryKey>,
) => {
const { create, deleteKey, query, update, idKey } = opts;
const utils = trpc.useContext();
const onVoteUpdate = useCallback(() => {
utils.invalidateQueries([query, { [idKey]: id } as any]);
}, [id, idKey, utils, query]);
const { data } = trpc.useQuery([
query,
{
[idKey]: id,
},
] as any);
const backendVote = data as BackendVote;
const { mutate: createVote } = trpc.useMutation(create, {
onSuccess: onVoteUpdate,
});
const { mutate: updateVote } = trpc.useMutation(update, {
onSuccess: onVoteUpdate,
});
const { mutate: deleteVote } = trpc.useMutation(deleteKey, {
onSuccess: onVoteUpdate,
});
const { handleDownvote, handleUpvote } = createVoteCallbacks(
backendVote ?? null,
{
createVote: ({ vote }) => {
createVote({
[idKey]: id,
vote,
} as any);
},
deleteVote,
updateVote,
},
);
return [handleUpvote, handleDownvote, voteState] as const;
return { handleDownvote, handleUpvote, vote: backendVote ?? null };
};

Loading…
Cancel
Save