[question][ui] integrate backend voting (#355)

Co-authored-by: wlren <weilinwork99@gmail.com>
This commit is contained in:
Jeff Sieu
2022-10-10 22:23:58 +08:00
committed by GitHub
parent 7052e8c175
commit 50d3386592
25 changed files with 639 additions and 382 deletions

View File

@ -1,8 +1,11 @@
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useAnswerCommentVote } from '~/utils/questions/useVote';
import VotingButtons from './VotingButtons'; import VotingButtons from './VotingButtons';
export type CommentListItemProps = { export type AnswerCommentListItemProps = {
answerCommentId: string;
authorImageUrl: string; authorImageUrl: string;
authorName: string; authorName: string;
content: string; content: string;
@ -10,16 +13,26 @@ export type CommentListItemProps = {
upvoteCount: number; upvoteCount: number;
}; };
export default function CommentListItem({ export default function AnswerCommentListItem({
authorImageUrl, authorImageUrl,
authorName, authorName,
content, content,
createdAt, createdAt,
upvoteCount, upvoteCount,
}: CommentListItemProps) { answerCommentId,
}: AnswerCommentListItemProps) {
const { handleDownvote, handleUpvote, vote } =
useAnswerCommentVote(answerCommentId);
return ( return (
<div className="flex gap-4 border bg-white p-2 "> <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="mt-1 flex flex-col gap-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img <img
@ -28,7 +41,7 @@ export default function CommentListItem({
src={authorImageUrl}></img> src={authorImageUrl}></img>
<h1 className="font-bold">{authorName}</h1> <h1 className="font-bold">{authorName}</h1>
<p className="pt-1 text-xs font-extralight"> <p className="pt-1 text-xs font-extralight">
Posted on: {format(createdAt, 'Pp')} Posted on: {format(createdAt, 'h:mm a, MMMM dd, yyyy')}
</p> </p>
</div> </div>
<p className="pl-1 pt-1">{content}</p> <p className="pl-1 pt-1">{content}</p>

View File

@ -40,7 +40,7 @@ export default function ContributeQuestionCard({
placeholder="Contribute a question" placeholder="Contribute a question"
onChange={handleOpenContribute} onChange={handleOpenContribute}
/> />
<div className="flex items-end justify-center gap-x-2"> <div className="flex flex-wrap items-end justify-center gap-x-2">
<div className="min-w-[150px] flex-1"> <div className="min-w-[150px] flex-1">
<TextInput <TextInput
disabled={true} disabled={true}

View File

@ -64,7 +64,7 @@ export default function ContributeQuestionDialog({
<Dialog.Panel className="relative max-w-5xl transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full"> <Dialog.Panel className="relative max-w-5xl transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full">
<div className="bg-white p-6 pt-5 sm:pb-4"> <div className="bg-white p-6 pt-5 sm:pb-4">
<div className="flex flex-1 items-stretch"> <div className="flex flex-1 items-stretch">
<div className="mt-3 w-full sm:mt-0 sm:ml-4 sm:text-left"> <div className="mt-3 w-full sm:mt-0 sm:text-left">
<Dialog.Title <Dialog.Title
as="h3" as="h3"
className="text-lg font-medium leading-6 text-gray-900"> className="text-lg font-medium leading-6 text-gray-900">

View File

@ -1,12 +1,16 @@
import { startOfMonth } from 'date-fns';
import { useState } from 'react'; import { useState } from 'react';
import { useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { import { CalendarDaysIcon, UserIcon } from '@heroicons/react/24/outline';
BuildingOffice2Icon,
CalendarDaysIcon,
UserIcon,
} from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client'; import type { QuestionsQuestionType } from '@prisma/client';
import { Button, Collapsible, Select, TextArea, TextInput } from '@tih/ui'; import {
Button,
CheckboxInput,
Collapsible,
Select,
TextArea,
TextInput,
} from '@tih/ui';
import { QUESTION_TYPES } from '~/utils/questions/constants'; import { QUESTION_TYPES } from '~/utils/questions/constants';
import { import {
@ -14,7 +18,9 @@ import {
useSelectRegister, useSelectRegister,
} from '~/utils/questions/useFormRegister'; } from '~/utils/questions/useFormRegister';
import Checkbox from './ui-patch/Checkbox'; import CompaniesTypeahead from '../shared/CompaniesTypeahead';
import type { Month } from '../shared/MonthYearPicker';
import MonthYearPicker from '../shared/MonthYearPicker';
export type ContributeQuestionData = { export type ContributeQuestionData = {
company: string; company: string;
@ -35,8 +41,15 @@ export default function ContributeQuestionForm({
onSubmit, onSubmit,
onDiscard, onDiscard,
}: ContributeQuestionFormProps) { }: ContributeQuestionFormProps) {
const { register: formRegister, handleSubmit } = const {
useForm<ContributeQuestionData>(); control,
register: formRegister,
handleSubmit,
} = useForm<ContributeQuestionData>({
defaultValues: {
date: startOfMonth(new Date()),
},
});
const register = useFormRegister(formRegister); const register = useFormRegister(formRegister);
const selectRegister = useSelectRegister(formRegister); const selectRegister = useSelectRegister(formRegister);
@ -66,24 +79,35 @@ export default function ContributeQuestionForm({
/> />
</div> </div>
<div className="min-w-[150px] max-w-[300px] flex-1"> <div className="min-w-[150px] max-w-[300px] flex-1">
<TextInput <Controller
label="Company" control={control}
required={true} name="company"
startAddOn={BuildingOffice2Icon} render={({ field }) => (
startAddOnType="icon" <CompaniesTypeahead
{...register('company')} onSelect={({ label }) => {
// TODO: To change from using company name to company id (i.e., value)
field.onChange(label);
}}
/>
)}
/> />
</div> </div>
<div className="min-w-[150px] max-w-[300px] flex-1"> <div className="min-w-[150px] max-w-[300px] flex-1">
<TextInput <Controller
label="Date" control={control}
required={true} name="date"
startAddOn={CalendarDaysIcon} render={({ field }) => (
startAddOnType="icon" <MonthYearPicker
{...register('date', { value={{
valueAsDate: true, month: (field.value.getMonth() + 1) as Month,
})} year: field.value.getFullYear(),
}}
onChange={({ month, year }) =>
field.onChange(startOfMonth(new Date(year, month - 1)))
}
/>
)}
/> />
</div> </div>
</div> </div>
@ -130,10 +154,11 @@ export default function ContributeQuestionForm({
</div> */} </div> */}
<div className="bg-primary-50 fixed bottom-0 left-0 w-full px-4 py-3 sm:flex sm:flex-row sm:justify-between sm:px-6"> <div className="bg-primary-50 fixed bottom-0 left-0 w-full px-4 py-3 sm:flex sm:flex-row sm:justify-between sm:px-6">
<div className="mb-1 flex"> <div className="mb-1 flex">
<Checkbox <CheckboxInput
checked={canSubmit}
label="I have checked that my question is new" label="I have checked that my question is new"
onChange={handleCheckSimilarQuestions}></Checkbox> value={canSubmit}
onChange={handleCheckSimilarQuestions}
/>
</div> </div>
<div className=" flex gap-x-2"> <div className=" flex gap-x-2">
<button <button

View File

@ -1,5 +1,8 @@
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import {
import { Select, TextInput } from '@tih/ui'; AdjustmentsHorizontalIcon,
MagnifyingGlassIcon,
} from '@heroicons/react/24/outline';
import { Button, Select, TextInput } from '@tih/ui';
export type SortOption = { export type SortOption = {
label: string; label: string;
@ -7,6 +10,7 @@ export type SortOption = {
}; };
export type QuestionSearchBarProps<SortOptions extends Array<SortOption>> = { export type QuestionSearchBarProps<SortOptions extends Array<SortOption>> = {
onFilterOptionsToggle: () => void;
onSortChange?: (sortValue: SortOptions[number]['value']) => void; onSortChange?: (sortValue: SortOptions[number]['value']) => void;
sortOptions: SortOptions; sortOptions: SortOptions;
sortValue: SortOptions[number]['value']; sortValue: SortOptions[number]['value'];
@ -18,10 +22,11 @@ export default function QuestionSearchBar<
onSortChange, onSortChange,
sortOptions, sortOptions,
sortValue, sortValue,
onFilterOptionsToggle,
}: QuestionSearchBarProps<SortOptions>) { }: QuestionSearchBarProps<SortOptions>) {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-4">
<div className="flex-1 pt-1"> <div className="flex-1">
<TextInput <TextInput
isLabelHidden={true} isLabelHidden={true}
label="Search by content" label="Search by content"
@ -30,7 +35,8 @@ export default function QuestionSearchBar<
startAddOnType="icon" startAddOnType="icon"
/> />
</div> </div>
<span aria-hidden={true} className="pl-3 pr-1 pt-1 text-sm"> <div className="flex items-center gap-2">
<span aria-hidden={true} className="align-middle text-sm font-medium">
Sort by: Sort by:
</span> </span>
<Select <Select
@ -39,7 +45,18 @@ export default function QuestionSearchBar<
label="Sort by" label="Sort by"
options={sortOptions} options={sortOptions}
value={sortValue} value={sortValue}
onChange={onSortChange}></Select> onChange={onSortChange}
/>
</div>
<div className="lg:hidden">
<Button
addonPosition="start"
icon={AdjustmentsHorizontalIcon}
label="Filter options"
variant="tertiary"
onClick={onFilterOptionsToggle}
/>
</div>
</div> </div>
); );
} }

View File

@ -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"
/>
);
}

View File

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

View File

@ -1,48 +1,63 @@
import { format } from 'date-fns'; import { format } from 'date-fns';
import { ChatBubbleLeftRightIcon } from '@heroicons/react/24/outline';
import withHref from '~/utils/questions/withHref'; import { useAnswerVote } from '~/utils/questions/useVote';
import type { VotingButtonsProps } from '../VotingButtons';
import VotingButtons from '../VotingButtons'; import VotingButtons from '../VotingButtons';
export type AnswerCardProps = { export type AnswerCardProps = {
answerId: string;
authorImageUrl: string; authorImageUrl: string;
authorName: string; authorName: string;
commentCount: number; commentCount?: number;
content: string; content: string;
createdAt: Date; createdAt: Date;
upvoteCount: number; upvoteCount: number;
votingButtonsSize: VotingButtonsProps['size'];
}; };
function AnswerCardWithoutHref({ export default function AnswerCard({
answerId,
authorName, authorName,
authorImageUrl, authorImageUrl,
upvoteCount,
content, content,
createdAt, createdAt,
commentCount, commentCount,
votingButtonsSize,
upvoteCount,
}: AnswerCardProps) { }: AnswerCardProps) {
const { handleUpvote, handleDownvote, vote } = useAnswerVote(answerId);
return ( return (
<div className="flex gap-4 rounded-md border bg-white p-2 hover:bg-slate-50"> <article className="flex gap-4 rounded-md border bg-white p-2">
<VotingButtons size="sm" upvoteCount={upvoteCount} /> <VotingButtons
<div className="mt-1 flex flex-col gap-1"> size={votingButtonsSize}
upvoteCount={upvoteCount}
vote={vote}
onDownvote={handleDownvote}
onUpvote={handleUpvote}
/>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img <img
alt={`${authorName} profile picture`} alt={`${authorName} profile picture`}
className="h-8 w-8 rounded-full" className="h-8 w-8 rounded-full"
src={authorImageUrl}></img> src={authorImageUrl}></img>
<h1 className="font-bold">{authorName}</h1> <h1 className="font-bold">{authorName}</h1>
<p className="pt-1 text-xs font-extralight"> <p className="text-xs font-extralight">
Posted on: {format(createdAt, 'Pp')} Posted on: {format(createdAt, 'h:mm a, MMMM dd, yyyy')}
</p> </p>
</div> </div>
<p className="pl-1 pt-1">{content}</p> <p>{content}</p>
<p className="py-1 pl-3 text-sm font-light underline underline-offset-4"> {commentCount !== undefined && (
{commentCount} comment(s) <div className="flex items-center gap-2 text-slate-500">
<ChatBubbleLeftRightIcon className="h-6 w-6" />
<p className="text-sm font-medium">
{commentCount} {commentCount === 1 ? 'comment' : 'comments'}
</p> </p>
</div> </div>
)}
</div> </div>
</article>
); );
} }
const AnswerCard = withHref(AnswerCardWithoutHref);
export default AnswerCard;

View File

@ -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 = { export default function FullAnswerCard(props: FullAnswerCardProps) {
authorImageUrl: string; return <AnswerCard {...props} votingButtonsSize="md" />;
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>
);
} }

View File

@ -1,58 +1,26 @@
import { Badge } from '@tih/ui'; import type { QuestionCardProps } from './QuestionCard';
import QuestionCard from './QuestionCard';
import VotingButtons from '../VotingButtons'; export type QuestionOverviewCardProps = Omit<
QuestionCardProps & {
type UpvoteProps = showActionButton: false;
| { showUserStatistics: false;
showVoteButtons: true; showVoteButtons: true;
upvoteCount: number; },
} | 'actionButtonLabel'
| { | 'onActionButtonClick'
showVoteButtons?: false; | 'showActionButton'
upvoteCount?: never; | 'showUserStatistics'
}; | 'showVoteButtons'
>;
export type FullQuestionCardProps = UpvoteProps & { export default function FullQuestionCard(props: QuestionOverviewCardProps) {
company: string;
content: string;
location: string;
receivedCount: number;
role: string;
timestamp: string;
type: string;
};
export default function FullQuestionCard({
company,
content,
showVoteButtons,
upvoteCount,
timestamp,
role,
location,
type,
}: FullQuestionCardProps) {
const altText = company + ' logo';
return ( return (
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4"> <QuestionCard
{showVoteButtons && <VotingButtons upvoteCount={upvoteCount} />} {...props}
<div className="flex flex-col gap-2"> showActionButton={false}
<div className="flex items-center gap-2"> showUserStatistics={false}
<img alt={altText} src="https://logo.clearbit.com/google.com"></img> showVoteButtons={true}
<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>
); );
} }

View File

@ -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;

View File

@ -1,9 +1,10 @@
import { import { ChatBubbleBottomCenterTextIcon } from '@heroicons/react/24/outline';
ChatBubbleBottomCenterTextIcon, import type { QuestionsQuestionType } from '@prisma/client';
// EyeIcon,
} from '@heroicons/react/24/outline';
import { Badge, Button } from '@tih/ui'; import { Badge, Button } from '@tih/ui';
import { useQuestionVote } from '~/utils/questions/useVote';
import QuestionTypeBadge from '../QuestionTypeBadge';
import VotingButtons from '../VotingButtons'; import VotingButtons from '../VotingButtons';
type UpvoteProps = type UpvoteProps =
@ -41,16 +42,19 @@ type ActionButtonProps =
export type QuestionCardProps = ActionButtonProps & export type QuestionCardProps = ActionButtonProps &
StatisticsProps & StatisticsProps &
UpvoteProps & { UpvoteProps & {
company: string;
content: string; content: string;
href?: string;
location: string; location: string;
questionId: string;
receivedCount: number; receivedCount: number;
role: string; role: string;
timestamp: string; timestamp: string;
type: string; type: QuestionsQuestionType;
}; };
export default function QuestionCard({ export default function QuestionCard({
questionId,
company,
answerCount, answerCount,
content, content,
// ReceivedCount, // ReceivedCount,
@ -65,13 +69,23 @@ export default function QuestionCard({
role, role,
location, location,
}: QuestionCardProps) { }: QuestionCardProps) {
const { handleDownvote, handleUpvote, vote } = useQuestionVote(questionId);
return ( 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} />} {showVoteButtons && (
<VotingButtons
upvoteCount={upvoteCount}
vote={vote}
onDownvote={handleDownvote}
onUpvote={handleUpvote}
/>
)}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<div className="flex items-center gap-2 text-slate-500"> <div className="flex items-baseline gap-2 text-slate-500">
<Badge label={type} variant="primary" /> <Badge label={company} variant="primary" />
<QuestionTypeBadge type={type} />
<p className="text-xs"> <p className="text-xs">
{timestamp} · {location} · {role} {timestamp} · {location} · {role}
</p> </p>

View File

@ -1,8 +1,5 @@
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { Collapsible, TextInput } from '@tih/ui'; import { CheckboxInput, Collapsible, RadioList, TextInput } from '@tih/ui';
import Checkbox from '../ui-patch/Checkbox';
import RadioGroup from '../ui-patch/RadioGroup';
export type FilterOption<V extends string = string> = { export type FilterOption<V extends string = string> = {
checked: boolean; checked: boolean;
@ -66,17 +63,29 @@ export default function FilterSection<
/> />
)} )}
{isSingleSelect ? ( {isSingleSelect ? (
<RadioGroup <div className="px-1.5">
radioData={options} <RadioList
label=""
value={options.find((option) => option.checked)?.value}
onChange={(value) => { onChange={(value) => {
onOptionChange(value); onOptionChange(value);
}}></RadioGroup> }}>
) : (
<div className="mx-1">
{options.map((option) => ( {options.map((option) => (
<Checkbox <RadioList.Item
key={option.value} key={option.value}
{...option} label={option.label}
value={option.value}
/>
))}
</RadioList>
</div>
) : (
<div className="px-1.5">
{options.map((option) => (
<CheckboxInput
key={option.value}
label={option.label}
value={option.checked}
onChange={(checked) => { onChange={(checked) => {
onOptionChange(option.value, checked); onOptionChange(option.value, checked);
}} }}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -3,14 +3,10 @@ import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline'; import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
import { Button, Select, TextArea } from '@tih/ui'; import { Button, Select, TextArea } from '@tih/ui';
import AnswerCommentListItem from '~/components/questions/AnswerCommentListItem';
import FullAnswerCard from '~/components/questions/card/FullAnswerCard'; import FullAnswerCard from '~/components/questions/card/FullAnswerCard';
import CommentListItem from '~/components/questions/CommentListItem';
import FullScreenSpinner from '~/components/questions/FullScreenSpinner'; import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
import {
SAMPLE_ANSWER,
SAMPLE_ANSWER_COMMENT,
} from '~/utils/questions/constants';
import { useFormRegister } from '~/utils/questions/useFormRegister'; import { useFormRegister } from '~/utils/questions/useFormRegister';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
@ -47,7 +43,7 @@ export default function QuestionPage() {
'questions.answers.comments.create', 'questions.answers.comments.create',
{ {
onSuccess: () => { onSuccess: () => {
utils.invalidateQuery([ utils.invalidateQueries([
'questions.answers.comments.getAnswerComments', 'questions.answers.comments.getAnswerComments',
{ answerId: answerId as string }, { answerId: answerId as string },
]); ]);
@ -85,11 +81,12 @@ export default function QuestionPage() {
<div className="flex w-full justify-center overflow-y-auto py-4 px-5"> <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"> <div className="flex max-w-7xl flex-1 flex-col gap-2">
<FullAnswerCard <FullAnswerCard
authorImageUrl={SAMPLE_ANSWER.authorImageUrl} answerId={answer.id}
authorImageUrl={answer.userImage}
authorName={answer.user} authorName={answer.user}
content={answer.content} content={answer.content}
createdAt={answer.createdAt} createdAt={answer.createdAt}
upvoteCount={0} upvoteCount={answer.numVotes}
/> />
<div className="mx-2"> <div className="mx-2">
<form <form
@ -142,9 +139,10 @@ export default function QuestionPage() {
</form> </form>
{(comments ?? []).map((comment) => ( {(comments ?? []).map((comment) => (
<CommentListItem <AnswerCommentListItem
key={comment.id} key={comment.id}
authorImageUrl={SAMPLE_ANSWER_COMMENT.authorImageUrl} answerCommentId={comment.id}
authorImageUrl={comment.userImage}
authorName={comment.user} authorName={comment.user}
content={comment.content} content={comment.content}
createdAt={comment.createdAt} createdAt={comment.createdAt}

View File

@ -3,15 +3,11 @@ import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline'; import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
import { Button, Collapsible, Select, TextArea } from '@tih/ui'; import { Button, Collapsible, Select, TextArea } from '@tih/ui';
import AnswerCard from '~/components/questions/card/AnswerCard'; import AnswerCommentListItem from '~/components/questions/AnswerCommentListItem';
import FullQuestionCard from '~/components/questions/card/FullQuestionCard'; import FullQuestionCard from '~/components/questions/card/FullQuestionCard';
import CommentListItem from '~/components/questions/CommentListItem'; import QuestionAnswerCard from '~/components/questions/card/QuestionAnswerCard';
import FullScreenSpinner from '~/components/questions/FullScreenSpinner'; import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
import {
SAMPLE_ANSWER,
SAMPLE_QUESTION_COMMENT,
} from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug'; import createSlug from '~/utils/questions/createSlug';
import { useFormRegister } from '~/utils/questions/useFormRegister'; import { useFormRegister } from '~/utils/questions/useFormRegister';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
@ -29,6 +25,7 @@ export default function QuestionPage() {
const { const {
register: ansRegister, register: ansRegister,
handleSubmit, handleSubmit,
reset: resetAnswer,
formState: { isDirty, isValid }, formState: { isDirty, isValid },
} = useForm<AnswerQuestionData>({ mode: 'onChange' }); } = useForm<AnswerQuestionData>({ mode: 'onChange' });
const answerRegister = useFormRegister(ansRegister); const answerRegister = useFormRegister(ansRegister);
@ -86,6 +83,7 @@ export default function QuestionPage() {
content: data.answerContent, content: data.answerContent,
questionId: questionId as string, questionId: questionId as string,
}); });
resetAnswer();
}; };
const handleSubmitComment = (data: QuestionCommentData) => { const handleSubmitComment = (data: QuestionCommentData) => {
@ -115,13 +113,16 @@ export default function QuestionPage() {
<div className="flex max-w-7xl flex-1 flex-col gap-2"> <div className="flex max-w-7xl flex-1 flex-col gap-2">
<FullQuestionCard <FullQuestionCard
{...question} {...question}
receivedCount={0} // TODO: Change to actual value questionId={question.id}
showVoteButtons={true} receivedCount={0}
timestamp={question.seenAt.toLocaleDateString()} timestamp={question.seenAt.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
})}
upvoteCount={question.numVotes} upvoteCount={question.numVotes}
/> />
<div className="mx-2"> <div className="mx-2">
<Collapsible label={`${question.numComments} comment(s)`}> <Collapsible label={`${(comments ?? []).length} comment(s)`}>
<form <form
className="mb-2" className="mb-2"
onSubmit={handleCommentSubmit(handleSubmitComment)}> onSubmit={handleCommentSubmit(handleSubmitComment)}>
@ -172,9 +173,10 @@ export default function QuestionPage() {
</form> </form>
{(comments ?? []).map((comment) => ( {(comments ?? []).map((comment) => (
<CommentListItem <AnswerCommentListItem
key={comment.id} key={comment.id}
authorImageUrl={SAMPLE_QUESTION_COMMENT.authorImageUrl} answerCommentId={comment.id}
authorImageUrl={comment.userImage}
authorName={comment.user} authorName={comment.user}
content={comment.content} content={comment.content}
createdAt={comment.createdAt} createdAt={comment.createdAt}
@ -196,7 +198,7 @@ export default function QuestionPage() {
/> />
<div className="mt-3 mb-1 flex justify-between"> <div className="mt-3 mb-1 flex justify-between">
<div className="flex items-baseline justify-start gap-2"> <div className="flex items-baseline justify-start gap-2">
<p>{question.numAnswers} answers</p> <p>{(answers ?? []).length} answers</p>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span aria-hidden={true} className="text-sm"> <span aria-hidden={true} className="text-sm">
Sort by: Sort by:
@ -232,9 +234,10 @@ export default function QuestionPage() {
</div> </div>
</form> </form>
{(answers ?? []).map((answer) => ( {(answers ?? []).map((answer) => (
<AnswerCard <QuestionAnswerCard
key={answer.id} key={answer.id}
authorImageUrl={SAMPLE_ANSWER.authorImageUrl} answerId={answer.id}
authorImageUrl={answer.userImage}
authorName={answer.user} authorName={answer.user}
commentCount={answer.numComments} commentCount={answer.numComments}
content={answer.content} content={answer.content}

View File

@ -1,7 +1,9 @@
import { subMonths, subYears } from 'date-fns'; import { subMonths, subYears } from 'date-fns';
import { useRouter } from 'next/router'; import Router, { useRouter } from 'next/router';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { NoSymbolIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client'; import type { QuestionsQuestionType } from '@prisma/client';
import { SlideOut } from '@tih/ui';
import QuestionOverviewCard from '~/components/questions/card/QuestionOverviewCard'; import QuestionOverviewCard from '~/components/questions/card/QuestionOverviewCard';
import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard'; import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
@ -59,7 +61,8 @@ export default function QuestionsHomePage() {
: undefined; : undefined;
}, [selectedQuestionAge]); }, [selectedQuestionAge]);
const { data: questions } = trpc.useQuery([ const { data: questions } = trpc.useQuery(
[
'questions.questions.getQuestionsByFilter', 'questions.questions.getQuestionsByFilter',
{ {
companies: selectedCompanies, companies: selectedCompanies,
@ -69,7 +72,11 @@ export default function QuestionsHomePage() {
roles: [], roles: [],
startDate, startDate,
}, },
]); ],
{
keepPreviousData: true,
},
);
const utils = trpc.useContext(); const utils = trpc.useContext();
const { mutate: createQuestion } = trpc.useMutation( const { mutate: createQuestion } = trpc.useMutation(
@ -83,6 +90,7 @@ export default function QuestionsHomePage() {
const [hasLanded, setHasLanded] = useState(false); const [hasLanded, setHasLanded] = useState(false);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const companyFilterOptions = useMemo(() => { const companyFilterOptions = useMemo(() => {
return COMPANIES.map((company) => ({ return COMPANIES.map((company) => ({
@ -112,8 +120,9 @@ export default function QuestionsHomePage() {
})); }));
}, [selectedLocations]); }, [selectedLocations]);
const handleLandingQuery = (data: LandingQueryData) => { const handleLandingQuery = async (data: LandingQueryData) => {
const { company, location, questionType } = data; const { company, location, questionType } = data;
setSelectedCompanies([company]); setSelectedCompanies([company]);
setSelectedLocations([location]); setSelectedLocations([location]);
setSelectedQuestionTypes([questionType as QuestionsQuestionType]); setSelectedQuestionTypes([questionType as QuestionsQuestionType]);
@ -134,33 +143,48 @@ export default function QuestionsHomePage() {
areLocationsInitialized, areLocationsInitialized,
]); ]);
const { pathname } = router;
useEffect(() => { useEffect(() => {
if (areFiltersInitialized) { if (areFiltersInitialized) {
// Router.replace used instead of router.replace to avoid
// the page reloading itself since the router.replace
// callback changes on every page load
Router.replace({
pathname,
query: {
companies: selectedCompanies,
locations: selectedLocations,
questionAge: selectedQuestionAge,
questionTypes: selectedQuestionTypes,
},
});
const hasFilter = const hasFilter =
router.query.companies || selectedCompanies.length > 0 ||
router.query.questionTypes || selectedLocations.length > 0 ||
router.query.questionAge || selectedQuestionAge !== 'all' ||
router.query.locations; selectedQuestionTypes.length > 0;
if (hasFilter) { if (hasFilter) {
setHasLanded(true); setHasLanded(true);
} }
// Console.log('landed', hasLanded);
setLoaded(true); setLoaded(true);
} }
}, [areFiltersInitialized, hasLanded, router.query]); }, [
areFiltersInitialized,
hasLanded,
loaded,
pathname,
selectedCompanies,
selectedLocations,
selectedQuestionAge,
selectedQuestionTypes,
]);
if (!loaded) { if (!loaded) {
return null; return null;
} }
const filterSidebar = (
return !hasLanded ? ( <div className="mt-2 divide-y divide-slate-200 px-4">
<LandingComponent onLanded={handleLandingQuery}></LandingComponent>
) : (
<main className="flex flex-1 flex-col items-stretch overflow-y-auto">
<div className="flex pt-4">
<aside className="w-[300px] border-r px-4">
<h2 className="text-xl font-semibold">Filter by</h2>
<div className="divide-y divide-slate-200">
<FilterSection <FilterSection
label="Company" label="Company"
options={companyFilterOptions} options={companyFilterOptions}
@ -170,9 +194,7 @@ export default function QuestionsHomePage() {
setSelectedCompanies([...selectedCompanies, optionValue]); setSelectedCompanies([...selectedCompanies, optionValue]);
} else { } else {
setSelectedCompanies( setSelectedCompanies(
selectedCompanies.filter( selectedCompanies.filter((company) => company !== optionValue),
(company) => company !== optionValue,
),
); );
} }
}} }}
@ -183,10 +205,7 @@ export default function QuestionsHomePage() {
showAll={true} showAll={true}
onOptionChange={(optionValue, checked) => { onOptionChange={(optionValue, checked) => {
if (checked) { if (checked) {
setSelectedQuestionTypes([ setSelectedQuestionTypes([...selectedQuestionTypes, optionValue]);
...selectedQuestionTypes,
optionValue,
]);
} else { } else {
setSelectedQuestionTypes( setSelectedQuestionTypes(
selectedQuestionTypes.filter( selectedQuestionTypes.filter(
@ -214,18 +233,22 @@ export default function QuestionsHomePage() {
setSelectedLocations([...selectedLocations, optionValue]); setSelectedLocations([...selectedLocations, optionValue]);
} else { } else {
setSelectedLocations( setSelectedLocations(
selectedLocations.filter( selectedLocations.filter((location) => location !== optionValue),
(location) => location !== optionValue,
),
); );
} }
}} }}
/> />
</div> </div>
</aside> );
<section className="flex min-h-0 flex-1 flex-col items-center overflow-auto pt-4">
<div className="flex min-h-0 max-w-3xl flex-1"> return !hasLanded ? (
<div className="flex flex-1 flex-col items-stretch justify-start gap-4 pb-4"> <LandingComponent onLanded={handleLandingQuery}></LandingComponent>
) : (
<main className="flex flex-1 flex-col items-stretch">
<div className="flex h-full flex-1">
<section className="flex min-h-0 flex-1 flex-col items-center overflow-auto">
<div className="flex min-h-0 max-w-3xl flex-1 p-4">
<div className="flex flex-1 flex-col items-stretch justify-start gap-4">
<ContributeQuestionCard <ContributeQuestionCard
onSubmit={(data) => { onSubmit={(data) => {
createQuestion({ createQuestion({
@ -250,6 +273,9 @@ export default function QuestionsHomePage() {
}, },
]} ]}
sortValue="most-recent" sortValue="most-recent"
onFilterOptionsToggle={() => {
setFilterDrawerOpen(!filterDrawerOpen);
}}
onSortChange={(value) => { onSortChange={(value) => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(value); console.log(value);
@ -257,24 +283,49 @@ export default function QuestionsHomePage() {
/> />
{(questions ?? []).map((question) => ( {(questions ?? []).map((question) => (
<QuestionOverviewCard <QuestionOverviewCard
// eslint-disable-next-line react/no-array-index-key
key={question.id} key={question.id}
answerCount={question.numAnswers} answerCount={question.numAnswers}
company={question.company}
content={question.content} content={question.content}
href={`/questions/${question.id}/${createSlug( href={`/questions/${question.id}/${createSlug(
question.content, question.content,
)}`} )}`}
location={question.location} location={question.location}
receivedCount={0} // TODO: Implement received count questionId={question.id}
receivedCount={0}
role={question.role} role={question.role}
timestamp={question.seenAt.toLocaleDateString()} timestamp={question.seenAt.toLocaleDateString(undefined, {
type={question.type} month: 'short',
year: 'numeric',
})}
type={question.type} // TODO: Implement received count
upvoteCount={question.numVotes} upvoteCount={question.numVotes}
/> />
))} ))}
{questions?.length === 0 && (
<div className="flex w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-slate-200 p-4 text-slate-600">
<NoSymbolIcon className="h-6 w-6" />
<p>Nothing found. Try changing your search filters.</p>
</div>
)}
</div> </div>
</div> </div>
</section> </section>
<aside className="hidden w-[300px] overflow-y-auto border-l bg-white py-4 lg:block">
<h2 className="px-4 text-xl font-semibold">Filter by</h2>
{filterSidebar}
</aside>
<SlideOut
className="lg:hidden"
enterFrom="end"
isShown={filterDrawerOpen}
size="sm"
title="Filter by"
onClose={() => {
setFilterDrawerOpen(false);
}}>
{filterSidebar}
</SlideOut>
</div> </div>
</main> </main>
); );

View File

@ -17,6 +17,7 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
include: { include: {
user: { user: {
select: { select: {
image: true,
name: true, name: true,
}, },
}, },
@ -54,6 +55,7 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
numVotes: votes, numVotes: votes,
updatedAt: data.updatedAt, updatedAt: data.updatedAt,
user: data.user?.name ?? '', user: data.user?.name ?? '',
userImage: data.user?.image ?? '',
}; };
return answerComment; return answerComment;
}); });
@ -182,7 +184,7 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
}, },
}); });
if (voteToUpdate?.id !== userId) { if (voteToUpdate?.userId !== userId) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'User have no authorization to record.', message: 'User have no authorization to record.',
@ -213,7 +215,7 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
}, },
}); });
if (voteToDelete?.id !== userId) { if (voteToDelete?.userId !== userId) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'User have no authorization to record.', message: 'User have no authorization to record.',

View File

@ -21,6 +21,7 @@ export const questionsAnswerRouter = createProtectedRouter()
}, },
user: { user: {
select: { select: {
image: true,
name: true, name: true,
}, },
}, },
@ -58,6 +59,7 @@ export const questionsAnswerRouter = createProtectedRouter()
numComments: data._count.comments, numComments: data._count.comments,
numVotes: votes, numVotes: votes,
user: data.user?.name ?? '', user: data.user?.name ?? '',
userImage: data.user?.image ?? '',
}; };
return answer; return answer;
}); });
@ -77,6 +79,7 @@ export const questionsAnswerRouter = createProtectedRouter()
}, },
user: { user: {
select: { select: {
image: true,
name: true, name: true,
}, },
}, },
@ -116,6 +119,7 @@ export const questionsAnswerRouter = createProtectedRouter()
numComments: answerData._count.comments, numComments: answerData._count.comments,
numVotes: votes, numVotes: votes,
user: answerData.user?.name ?? '', user: answerData.user?.name ?? '',
userImage: answerData.user?.image ?? '',
}; };
return answer; return answer;
}, },
@ -241,7 +245,7 @@ export const questionsAnswerRouter = createProtectedRouter()
}, },
}); });
if (voteToUpdate?.id !== userId) { if (voteToUpdate?.userId !== userId) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'User have no authorization to record.', message: 'User have no authorization to record.',
@ -271,7 +275,7 @@ export const questionsAnswerRouter = createProtectedRouter()
}, },
}); });
if (voteToDelete?.id !== userId) { if (voteToDelete?.userId !== userId) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'User have no authorization to record.', message: 'User have no authorization to record.',

View File

@ -17,6 +17,7 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
include: { include: {
user: { user: {
select: { select: {
image: true,
name: true, name: true,
}, },
}, },
@ -53,6 +54,7 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
id: data.id, id: data.id,
numVotes: votes, numVotes: votes,
user: data.user?.name ?? '', user: data.user?.name ?? '',
userImage: data.user?.image ?? '',
}; };
return questionComment; return questionComment;
}); });
@ -181,7 +183,7 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
}, },
}); });
if (voteToUpdate?.id !== userId) { if (voteToUpdate?.userId !== userId) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'User have no authorization to record.', message: 'User have no authorization to record.',
@ -212,7 +214,7 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
}, },
}); });
if (voteToDelete?.id !== userId) { if (voteToDelete?.userId !== userId) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'User have no authorization to record.', message: 'User have no authorization to record.',

View File

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

View File

@ -21,6 +21,7 @@ export type AnswerComment = {
numVotes: number; numVotes: number;
updatedAt: Date; updatedAt: Date;
user: string; user: string;
userImage: string;
}; };
export type Answer = { export type Answer = {
@ -30,6 +31,7 @@ export type Answer = {
numComments: number; numComments: number;
numVotes: number; numVotes: number;
user: string; user: string;
userImage: string;
}; };
export type QuestionComment = { export type QuestionComment = {
@ -38,4 +40,5 @@ export type QuestionComment = {
id: string; id: string;
numVotes: number; numVotes: number;
user: string; user: string;
userImage: string;
}; };

View File

@ -27,13 +27,6 @@ export const useSearchFilter = <Value extends string = string>(
if (localStorageValue !== null) { if (localStorageValue !== null) {
const loadedFilters = JSON.parse(localStorageValue); const loadedFilters = JSON.parse(localStorageValue);
setFilters(loadedFilters); setFilters(loadedFilters);
router.replace({
pathname: router.pathname,
query: {
...router.query,
[name]: loadedFilters,
},
});
} }
} }
setIsInitialized(true); setIsInitialized(true);
@ -44,15 +37,8 @@ export const useSearchFilter = <Value extends string = string>(
(newFilters: Array<Value>) => { (newFilters: Array<Value>) => {
setFilters(newFilters); setFilters(newFilters);
localStorage.setItem(name, JSON.stringify(newFilters)); localStorage.setItem(name, JSON.stringify(newFilters));
router.replace({
pathname: router.pathname,
query: {
...router.query,
[name]: newFilters,
}, },
}); [name],
},
[name, router],
); );
return [filters, setFiltersCallback, isInitialized] as const; return [filters, setFiltersCallback, isInitialized] as const;
@ -73,9 +59,7 @@ export const useSearchFilterSingle = <Value extends string = string>(
return [ return [
filters[0], filters[0],
(value: Value) => { (value: Value) => setFilters([value]),
setFilters([value]);
},
isInitialized, isInitialized,
] as const; ] as const;
}; };

View File

@ -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 };
};