[question][ui] integrate backend voting (#355)
Co-authored-by: wlren <weilinwork99@gmail.com>pull/356/head
parent
7052e8c175
commit
50d3386592
@ -0,0 +1,17 @@
|
||||
import type { QuestionsQuestionType } from '@prisma/client';
|
||||
import { Badge } from '@tih/ui';
|
||||
|
||||
import { QUESTION_TYPES } from '~/utils/questions/constants';
|
||||
|
||||
export type QuestionTypeBadgeProps = {
|
||||
type: QuestionsQuestionType;
|
||||
};
|
||||
|
||||
export default function QuestionTypeBadge({ type }: QuestionTypeBadgeProps) {
|
||||
return (
|
||||
<Badge
|
||||
label={QUESTION_TYPES.find(({ value }) => value === type)!.label}
|
||||
variant="info"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,38 +1,11 @@
|
||||
import { format } from 'date-fns';
|
||||
import type { AnswerCardProps } from './AnswerCard';
|
||||
import AnswerCard from './AnswerCard';
|
||||
|
||||
import VotingButtons from '../VotingButtons';
|
||||
export type FullAnswerCardProps = Omit<
|
||||
AnswerCardProps,
|
||||
'commentCount' | 'votingButtonsSize'
|
||||
>;
|
||||
|
||||
export type FullAnswerCardProps = {
|
||||
authorImageUrl: string;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
upvoteCount: number;
|
||||
};
|
||||
|
||||
export default function FullAnswerCard({
|
||||
authorImageUrl,
|
||||
authorName,
|
||||
content,
|
||||
createdAt,
|
||||
upvoteCount,
|
||||
}: FullAnswerCardProps) {
|
||||
return (
|
||||
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4">
|
||||
<VotingButtons upvoteCount={upvoteCount}></VotingButtons>
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
alt={`${authorName} profile picture`}
|
||||
className="h-8 w-8 rounded-full"
|
||||
src={authorImageUrl}></img>
|
||||
<h1 className="font-bold">{authorName}</h1>
|
||||
<p className="pt-1 text-xs font-extralight">
|
||||
Posted on: {format(createdAt, 'Pp')}
|
||||
</p>
|
||||
</div>
|
||||
<p className="pl-1 pt-1">{content}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
export default function FullAnswerCard(props: FullAnswerCardProps) {
|
||||
return <AnswerCard {...props} votingButtonsSize="md" />;
|
||||
}
|
||||
|
||||
@ -1,58 +1,26 @@
|
||||
import { Badge } from '@tih/ui';
|
||||
import type { QuestionCardProps } from './QuestionCard';
|
||||
import QuestionCard from './QuestionCard';
|
||||
|
||||
import VotingButtons from '../VotingButtons';
|
||||
|
||||
type UpvoteProps =
|
||||
| {
|
||||
export type QuestionOverviewCardProps = Omit<
|
||||
QuestionCardProps & {
|
||||
showActionButton: false;
|
||||
showUserStatistics: false;
|
||||
showVoteButtons: true;
|
||||
upvoteCount: number;
|
||||
}
|
||||
| {
|
||||
showVoteButtons?: false;
|
||||
upvoteCount?: never;
|
||||
};
|
||||
|
||||
export type FullQuestionCardProps = UpvoteProps & {
|
||||
company: string;
|
||||
content: string;
|
||||
location: string;
|
||||
receivedCount: number;
|
||||
role: string;
|
||||
timestamp: string;
|
||||
type: string;
|
||||
};
|
||||
},
|
||||
| 'actionButtonLabel'
|
||||
| 'onActionButtonClick'
|
||||
| 'showActionButton'
|
||||
| 'showUserStatistics'
|
||||
| 'showVoteButtons'
|
||||
>;
|
||||
|
||||
export default function FullQuestionCard({
|
||||
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} />}
|
||||
<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>
|
||||
<h2 className="ml-2 text-xl">{company}</h2>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2 text-slate-500">
|
||||
<Badge label={type} variant="primary" />
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
import withHref from '~/utils/questions/withHref';
|
||||
|
||||
import type { AnswerCardProps } from './AnswerCard';
|
||||
import AnswerCard from './AnswerCard';
|
||||
|
||||
export type QuestionAnswerCardProps = Required<
|
||||
Omit<AnswerCardProps, 'votingButtonsSize'>
|
||||
>;
|
||||
|
||||
function QuestionAnswerCardWithoutHref(props: QuestionAnswerCardProps) {
|
||||
return <AnswerCard {...props} votingButtonsSize="sm" />;
|
||||
}
|
||||
|
||||
const QuestionAnswerCard = withHref(QuestionAnswerCardWithoutHref);
|
||||
export default QuestionAnswerCard;
|
||||
@ -1,25 +0,0 @@
|
||||
import { useId } from 'react';
|
||||
|
||||
export type CheckboxProps = {
|
||||
checked: boolean;
|
||||
label: string;
|
||||
onChange: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
export default function Checkbox({ label, checked, onChange }: CheckboxProps) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
checked={checked}
|
||||
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-gray-300"
|
||||
id={id}
|
||||
type="checkbox"
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
<label className="ml-3 min-w-0 flex-1 text-gray-700" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
export type RadioProps = {
|
||||
onChange: (value: string) => void;
|
||||
radioData: Array<RadioData>;
|
||||
};
|
||||
|
||||
export type RadioData = {
|
||||
checked: boolean;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export default function RadioGroup({ radioData, onChange }: RadioProps) {
|
||||
return (
|
||||
<div className="mx-1 space-y-1">
|
||||
{radioData.map((radio) => (
|
||||
<div key={radio.value} className="flex items-center">
|
||||
<input
|
||||
checked={radio.checked}
|
||||
className="text-primary-600 focus:ring-primary-500 h-4 w-4 border-gray-300"
|
||||
type="radio"
|
||||
value={radio.value}
|
||||
onChange={(event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
onChange(target.value);
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
className="ml-3 min-w-0 flex-1 text-gray-700"
|
||||
htmlFor={radio.value}>
|
||||
{radio.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,175 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { useCallback } from 'react';
|
||||
import type { Vote } from '@prisma/client';
|
||||
|
||||
import { trpc } from '../trpc';
|
||||
|
||||
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 = () => {
|
||||
// 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 = () => {
|
||||
// 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(() => {
|
||||
// TODO: Optimise query invalidation
|
||||
utils.invalidateQueries([query, { [idKey]: id } as any]);
|
||||
utils.invalidateQueries(['questions.questions.getQuestionsByFilter']);
|
||||
utils.invalidateQueries(['questions.questions.getQuestionById']);
|
||||
utils.invalidateQueries(['questions.answers.getAnswers']);
|
||||
utils.invalidateQueries(['questions.answers.getAnswerById']);
|
||||
utils.invalidateQueries([
|
||||
'questions.questions.comments.getQuestionComments',
|
||||
]);
|
||||
utils.invalidateQueries(['questions.answers.comments.getAnswerComments']);
|
||||
}, [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 { handleDownvote, handleUpvote, vote: backendVote ?? null };
|
||||
};
|
||||
Loading…
Reference in new issue