mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-29 13:13:54 +08:00
[question][ui] integrate backend voting (#355)
Co-authored-by: wlren <weilinwork99@gmail.com>
This commit is contained in:
@ -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>
|
@ -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}
|
||||||
|
@ -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">
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
17
apps/portal/src/components/questions/QuestionTypeBadge.tsx
Normal file
17
apps/portal/src/components/questions/QuestionTypeBadge.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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;
|
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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,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>
|
||||||
|
@ -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);
|
||||||
}}
|
}}
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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}
|
||||||
|
@ -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}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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.',
|
||||||
|
@ -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.',
|
||||||
|
@ -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.',
|
||||||
|
@ -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.',
|
||||||
|
3
apps/portal/src/types/questions.d.ts
vendored
3
apps/portal/src/types/questions.d.ts
vendored
@ -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;
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
};
|
};
|
||||||
|
175
apps/portal/src/utils/questions/useVote.ts
Normal file
175
apps/portal/src/utils/questions/useVote.ts
Normal 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 };
|
||||||
|
};
|
Reference in New Issue
Block a user