mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2026-02-10 22:15:06 +08:00
[questions][fix] ui fixes and enhancements (#514)
Co-authored-by: wlren <weilinwork99@gmail.com>
This commit is contained in:
@@ -3,22 +3,52 @@ import type { PropsWithChildren } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Fragment, useRef, useState } from 'react';
|
||||
import { Menu, Transition } from '@headlessui/react';
|
||||
import { CheckIcon, HeartIcon } from '@heroicons/react/20/solid';
|
||||
import { CheckIcon, HeartIcon, PlusIcon } from '@heroicons/react/20/solid';
|
||||
|
||||
import {
|
||||
useAddQuestionToListAsync,
|
||||
useCreateListAsync,
|
||||
useRemoveQuestionFromListAsync,
|
||||
} from '~/utils/questions/mutations';
|
||||
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
import CreateListDialog from './CreateListDialog';
|
||||
|
||||
export type AddToListDropdownProps = {
|
||||
questionId: string;
|
||||
};
|
||||
|
||||
export type DropdownButtonProps = PropsWithChildren<{
|
||||
onClick: () => void;
|
||||
}>;
|
||||
|
||||
function DropdownButton({ onClick, children }: DropdownButtonProps) {
|
||||
return (
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
className={clsx(
|
||||
active ? 'bg-slate-100 text-slate-900' : 'text-slate-700',
|
||||
'flex w-full items-center px-4 py-2 text-sm',
|
||||
)}
|
||||
type="button"
|
||||
onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AddToListDropdown({
|
||||
questionId,
|
||||
}: AddToListDropdownProps) {
|
||||
const [menuOpened, setMenuOpened] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const createListAsync = useCreateListAsync();
|
||||
const { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']);
|
||||
|
||||
const listsWithQuestionData = useMemo(() => {
|
||||
@@ -30,25 +60,8 @@ export default function AddToListDropdown({
|
||||
}));
|
||||
}, [lists, questionId]);
|
||||
|
||||
const { mutateAsync: addQuestionToList } = trpc.useMutation(
|
||||
'questions.lists.createQuestionEntry',
|
||||
{
|
||||
// TODO: Add optimistic update
|
||||
onSuccess: () => {
|
||||
utils.invalidateQueries(['questions.lists.getListsByUser']);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync: removeQuestionFromList } = trpc.useMutation(
|
||||
'questions.lists.deleteQuestionEntry',
|
||||
{
|
||||
// TODO: Add optimistic update
|
||||
onSuccess: () => {
|
||||
utils.invalidateQueries(['questions.lists.getListsByUser']);
|
||||
},
|
||||
},
|
||||
);
|
||||
const addQuestionToList = useAddQuestionToListAsync();
|
||||
const removeQuestionFromList = useRemoveQuestionFromListAsync();
|
||||
|
||||
const addClickOutsideListener = () => {
|
||||
document.addEventListener('click', handleClickOutside, true);
|
||||
@@ -101,63 +114,79 @@ export default function AddToListDropdown({
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu ref={ref} as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<Menu.Button as={CustomMenuButton}>
|
||||
<HeartIcon aria-hidden="true" className="-ml-1 mr-2 h-5 w-5" />
|
||||
Add to List
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
show={menuOpened}>
|
||||
<Menu.Items
|
||||
className="absolute right-0 z-10 mt-2 w-56 origin-top-right divide-y divide-slate-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
static={true}>
|
||||
{menuOpened && (
|
||||
<>
|
||||
{(listsWithQuestionData ?? []).map((list) => (
|
||||
<div key={list.id} className="py-1">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
className={clsx(
|
||||
active
|
||||
? 'bg-slate-100 text-slate-900'
|
||||
: 'text-slate-700',
|
||||
'group flex w-full items-center px-4 py-2 text-sm',
|
||||
)}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (list.hasQuestion) {
|
||||
handleDeleteFromList(list.id);
|
||||
} else {
|
||||
handleAddToList(list.id);
|
||||
}
|
||||
}}>
|
||||
<div>
|
||||
<Menu ref={ref} as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<Menu.Button as={CustomMenuButton}>
|
||||
<HeartIcon aria-hidden="true" className="-ml-1 mr-2 h-5 w-5" />
|
||||
Add to list
|
||||
</Menu.Button>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
show={menuOpened}>
|
||||
<Menu.Items
|
||||
className="absolute right-0 z-10 mt-2 w-56 origin-top-right divide-y divide-slate-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
static={true}>
|
||||
{menuOpened && (
|
||||
<>
|
||||
{(listsWithQuestionData ?? []).map((list) => (
|
||||
<div key={list.id} className="py-1">
|
||||
<DropdownButton
|
||||
onClick={() => {
|
||||
if (list.hasQuestion) {
|
||||
handleDeleteFromList(list.id);
|
||||
} else {
|
||||
handleAddToList(list.id);
|
||||
}
|
||||
}}>
|
||||
<div className="flex w-full flex-1 justify-between">
|
||||
<span className="flex-1 overflow-hidden text-ellipsis text-start">
|
||||
{list.name}
|
||||
</span>
|
||||
{list.hasQuestion && (
|
||||
<CheckIcon
|
||||
aria-hidden="true"
|
||||
className="mr-3 h-5 w-5 text-slate-400 group-hover:text-slate-500"
|
||||
className="h-5 w-5 text-slate-400"
|
||||
/>
|
||||
)}
|
||||
{list.name}
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
</DropdownButton>
|
||||
</div>
|
||||
))}
|
||||
<DropdownButton
|
||||
onClick={() => {
|
||||
setShow(true);
|
||||
}}>
|
||||
<PlusIcon
|
||||
aria-hidden="true"
|
||||
className="mr-3 h-5 w-5 text-slate-500"
|
||||
/>
|
||||
<span className="font-semibold text-slate-500">
|
||||
Create new list
|
||||
</span>
|
||||
</DropdownButton>
|
||||
</>
|
||||
)}
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
<CreateListDialog
|
||||
show={show}
|
||||
onCancel={() => {
|
||||
setShow(false);
|
||||
}}
|
||||
onSubmit={async (data) => {
|
||||
await createListAsync(data);
|
||||
setShow(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
BuildingOffice2Icon,
|
||||
CalendarDaysIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { TextInput } from '@tih/ui';
|
||||
|
||||
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
|
||||
@@ -32,44 +27,19 @@ export default function ContributeQuestionCard({
|
||||
return (
|
||||
<div className="w-full">
|
||||
<button
|
||||
className="flex w-full flex-col items-stretch justify-center gap-2 rounded-md border border-slate-300 bg-white p-4 text-left hover:bg-slate-100"
|
||||
className="flex w-full flex-1 justify-between gap-2 rounded-md border border-slate-300 bg-white p-4 text-left hover:bg-slate-100"
|
||||
type="button"
|
||||
onClick={handleOpenContribute}>
|
||||
<TextInput
|
||||
disabled={true}
|
||||
isLabelHidden={true}
|
||||
label="Question"
|
||||
placeholder="Contribute a question"
|
||||
onChange={handleOpenContribute}
|
||||
/>
|
||||
<div className="w-full">
|
||||
<TextInput
|
||||
disabled={true}
|
||||
isLabelHidden={true}
|
||||
label="Question"
|
||||
placeholder="Contribute a question"
|
||||
onChange={handleOpenContribute}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end justify-start gap-2">
|
||||
<div className="min-w-[150px] flex-1">
|
||||
<TextInput
|
||||
disabled={true}
|
||||
label="Company"
|
||||
startAddOn={BuildingOffice2Icon}
|
||||
startAddOnType="icon"
|
||||
onChange={handleOpenContribute}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[150px] flex-1">
|
||||
<TextInput
|
||||
disabled={true}
|
||||
label="Question type"
|
||||
startAddOn={QuestionMarkCircleIcon}
|
||||
startAddOnType="icon"
|
||||
onChange={handleOpenContribute}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[150px] flex-1">
|
||||
<TextInput
|
||||
disabled={true}
|
||||
label="Date"
|
||||
startAddOn={CalendarDaysIcon}
|
||||
startAddOnType="icon"
|
||||
onChange={handleOpenContribute}
|
||||
/>
|
||||
</div>
|
||||
<h1 className="bg-primary-600 hover:bg-primary-700 rounded-full px-3 py-2 text-white">
|
||||
Contribute
|
||||
</h1>
|
||||
|
||||
@@ -8,16 +8,15 @@ export type SortOption<Value> = {
|
||||
value: Value;
|
||||
};
|
||||
|
||||
const sortTypeOptions = SORT_TYPES;
|
||||
const sortOrderOptions = SORT_ORDERS;
|
||||
|
||||
type SortOrderProps<Order> = {
|
||||
onSortOrderChange?: (sortValue: Order) => void;
|
||||
sortOrderOptions?: Array<SortOption<Order>>;
|
||||
sortOrderValue: Order;
|
||||
};
|
||||
|
||||
type SortTypeProps<Type> = {
|
||||
onSortTypeChange?: (sortType: Type) => void;
|
||||
sortTypeOptions?: Array<SortOption<Type>>;
|
||||
sortTypeValue: Type;
|
||||
};
|
||||
|
||||
@@ -29,17 +28,22 @@ export default function SortOptionsSelect({
|
||||
sortOrderValue,
|
||||
onSortTypeChange,
|
||||
sortTypeValue,
|
||||
sortOrderOptions,
|
||||
sortTypeOptions,
|
||||
}: SortOptionsSelectProps) {
|
||||
const sortTypes = sortTypeOptions ?? SORT_TYPES;
|
||||
const sortOrders = sortOrderOptions ?? SORT_ORDERS;
|
||||
|
||||
return (
|
||||
<div className="flex items-end justify-end gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
display="inline"
|
||||
label="Sort by"
|
||||
options={sortTypeOptions}
|
||||
options={sortTypes}
|
||||
value={sortTypeValue}
|
||||
onChange={(value) => {
|
||||
const chosenOption = sortTypeOptions.find(
|
||||
const chosenOption = sortTypes.find(
|
||||
(option) => String(option.value) === value,
|
||||
);
|
||||
if (chosenOption) {
|
||||
@@ -52,10 +56,10 @@ export default function SortOptionsSelect({
|
||||
<Select
|
||||
display="inline"
|
||||
label="Order by"
|
||||
options={sortOrderOptions}
|
||||
options={sortOrders}
|
||||
value={sortOrderValue}
|
||||
onChange={(value) => {
|
||||
const chosenOption = sortOrderOptions.find(
|
||||
const chosenOption = sortOrders.find(
|
||||
(option) => String(option.value) === value,
|
||||
);
|
||||
if (chosenOption) {
|
||||
|
||||
@@ -90,7 +90,7 @@ type ReceivedStatisticsProps =
|
||||
type CreateEncounterProps =
|
||||
| {
|
||||
createEncounterButtonText: string;
|
||||
onReceivedSubmit: (data: CreateQuestionEncounterData) => void;
|
||||
onReceivedSubmit: (data: CreateQuestionEncounterData) => Promise<void>;
|
||||
showCreateEncounterButton: true;
|
||||
}
|
||||
| {
|
||||
@@ -185,7 +185,7 @@ export default function BaseQuestionCard({
|
||||
)}
|
||||
<div className="flex flex-1 flex-col items-start gap-2">
|
||||
<div className="flex items-baseline justify-between self-stretch">
|
||||
<div className="flex items-center gap-2 text-slate-500">
|
||||
<div className="z-10 flex items-center gap-2 text-slate-500">
|
||||
{showAggregateStatistics && (
|
||||
<>
|
||||
<QuestionTypeBadge type={type} />
|
||||
@@ -263,9 +263,8 @@ export default function BaseQuestionCard({
|
||||
onCancel={() => {
|
||||
setShowReceivedForm(false);
|
||||
}}
|
||||
onSubmit={(data) => {
|
||||
onReceivedSubmit?.(data);
|
||||
setShowReceivedForm(false);
|
||||
onSubmit={async (data) => {
|
||||
await onReceivedSubmit?.(data);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ArrowPathIcon } from '@heroicons/react/20/solid';
|
||||
import type { QuestionsQuestionType } from '@prisma/client';
|
||||
import type { TypeaheadOption } from '@tih/ui';
|
||||
import { CheckboxInput } from '@tih/ui';
|
||||
import { Button, HorizontalDivider, Select, TextArea } from '@tih/ui';
|
||||
import { Button, Select, TextArea } from '@tih/ui';
|
||||
|
||||
import { QUESTION_TYPES } from '~/utils/questions/constants';
|
||||
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
|
||||
@@ -187,11 +187,9 @@ export default function ContributeQuestionForm({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<HorizontalDivider />
|
||||
</div>
|
||||
|
||||
<h2
|
||||
className="text-primary-900 mb-3
|
||||
className="text-primary-900
|
||||
text-lg font-semibold
|
||||
">
|
||||
Are these questions the same as yours?
|
||||
@@ -243,11 +241,13 @@ export default function ContributeQuestionForm({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{similarQuestions?.length === 0 && (
|
||||
<p className="font-semibold text-slate-900">
|
||||
No similar questions found.
|
||||
</p>
|
||||
)}
|
||||
{similarQuestions?.length === 0 &&
|
||||
contentToCheck?.length !== 0 &&
|
||||
questionContent === contentToCheck && (
|
||||
<p className="font-semibold text-slate-900">
|
||||
No similar questions found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="bg-primary-50 flex w-full flex-col gap-y-2 py-3 shadow-[0_0_0_100vmax_theme(colors.primary.50)] sm:flex-row sm:justify-between"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { startOfMonth } from 'date-fns';
|
||||
import { useState } from 'react';
|
||||
import { CheckIcon } from '@heroicons/react/20/solid';
|
||||
import { Button } from '@tih/ui';
|
||||
|
||||
import type { Month } from '~/components/shared/MonthYearPicker';
|
||||
@@ -22,7 +23,7 @@ export type CreateQuestionEncounterData = {
|
||||
|
||||
export type CreateQuestionEncounterFormProps = {
|
||||
onCancel: () => void;
|
||||
onSubmit: (data: CreateQuestionEncounterData) => void;
|
||||
onSubmit: (data: CreateQuestionEncounterData) => Promise<void>;
|
||||
};
|
||||
|
||||
export default function CreateQuestionEncounterForm({
|
||||
@@ -30,6 +31,8 @@ export default function CreateQuestionEncounterForm({
|
||||
onSubmit,
|
||||
}: CreateQuestionEncounterFormProps) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
|
||||
const [selectedLocation, setSelectedLocation] = useState<Location | null>(
|
||||
@@ -40,9 +43,18 @@ export default function CreateQuestionEncounterForm({
|
||||
startOfMonth(new Date()),
|
||||
);
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="font-md flex items-center gap-1 rounded-full border bg-slate-50 py-1 pl-2 pr-3 text-sm text-slate-500">
|
||||
<CheckIcon className="h-5 w-5" />
|
||||
<p>Thank you for your response</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-md text-md text-slate-600">
|
||||
<p className="text-md text-slate-600">
|
||||
I saw this question {step <= 1 ? 'at' : step === 2 ? 'for' : 'on'}
|
||||
</p>
|
||||
{step === 0 && (
|
||||
@@ -128,9 +140,10 @@ export default function CreateQuestionEncounterForm({
|
||||
)}
|
||||
{step === 3 && (
|
||||
<Button
|
||||
isLoading={loading}
|
||||
label="Submit"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
if (
|
||||
selectedCompany &&
|
||||
selectedLocation &&
|
||||
@@ -138,14 +151,20 @@ export default function CreateQuestionEncounterForm({
|
||||
selectedDate
|
||||
) {
|
||||
const { cityId, stateId, countryId } = selectedLocation;
|
||||
onSubmit({
|
||||
cityId,
|
||||
company: selectedCompany,
|
||||
countryId,
|
||||
role: selectedRole,
|
||||
seenAt: selectedDate,
|
||||
stateId,
|
||||
});
|
||||
setLoading(true);
|
||||
try {
|
||||
await onSubmit({
|
||||
cityId,
|
||||
company: selectedCompany,
|
||||
countryId,
|
||||
role: selectedRole,
|
||||
seenAt: selectedDate,
|
||||
stateId,
|
||||
});
|
||||
setSubmitted(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -139,7 +139,7 @@ export default function QuestionPage() {
|
||||
},
|
||||
);
|
||||
|
||||
const { mutate: addEncounter } = trpc.useMutation(
|
||||
const { mutateAsync: addEncounterAsync } = trpc.useMutation(
|
||||
'questions.questions.encounters.user.create',
|
||||
{
|
||||
onSuccess: () => {
|
||||
@@ -208,8 +208,8 @@ export default function QuestionPage() {
|
||||
year: 'numeric',
|
||||
})}
|
||||
upvoteCount={question.numVotes}
|
||||
onReceivedSubmit={(data) => {
|
||||
addEncounter({
|
||||
onReceivedSubmit={async (data) => {
|
||||
await addEncounterAsync({
|
||||
cityId: data.cityId,
|
||||
companyId: data.company,
|
||||
countryId: data.countryId,
|
||||
@@ -221,7 +221,7 @@ export default function QuestionPage() {
|
||||
}}
|
||||
/>
|
||||
<div className="mx-2">
|
||||
<Collapsible label={`${question.numComments} comment(s)`}>
|
||||
<Collapsible label={`View ${question.numComments} comment(s)`}>
|
||||
<div className="mt-4 px-4">
|
||||
<form
|
||||
className="mb-2"
|
||||
@@ -246,7 +246,7 @@ export default function QuestionPage() {
|
||||
</div>
|
||||
</form>
|
||||
{/* TODO: Add button to load more */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2 text-black">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-lg">Comments</p>
|
||||
<div className="flex items-end gap-2">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Bars3BottomLeftIcon } from '@heroicons/react/20/solid';
|
||||
import { NoSymbolIcon } from '@heroicons/react/24/outline';
|
||||
import type { QuestionsQuestionType } from '@prisma/client';
|
||||
import type { TypeaheadOption } from '@tih/ui';
|
||||
import { useToast } from '@tih/ui';
|
||||
import { Button, SlideOut } from '@tih/ui';
|
||||
|
||||
import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard';
|
||||
@@ -19,6 +20,7 @@ import RoleTypeahead from '~/components/questions/typeahead/RoleTypeahead';
|
||||
import { JobTitleLabels } from '~/components/shared/JobTitles';
|
||||
|
||||
import type { QuestionAge } from '~/utils/questions/constants';
|
||||
import { QUESTION_SORT_TYPES } from '~/utils/questions/constants';
|
||||
import { APP_TITLE } from '~/utils/questions/constants';
|
||||
import { QUESTION_AGES, QUESTION_TYPES } from '~/utils/questions/constants';
|
||||
import createSlug from '~/utils/questions/createSlug';
|
||||
@@ -34,6 +36,30 @@ import type { Location } from '~/types/questions.d';
|
||||
import { SortType } from '~/types/questions.d';
|
||||
import { SortOrder } from '~/types/questions.d';
|
||||
|
||||
function sortOrderToString(value: SortOrder): string | null {
|
||||
switch (value) {
|
||||
case SortOrder.ASC:
|
||||
return 'ASC';
|
||||
case SortOrder.DESC:
|
||||
return 'DESC';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sortTypeToString(value: SortType): string | null {
|
||||
switch (value) {
|
||||
case SortType.TOP:
|
||||
return 'TOP';
|
||||
case SortType.NEW:
|
||||
return 'NEW';
|
||||
case SortType.ENCOUNTERS:
|
||||
return 'ENCOUNTERS';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function QuestionsBrowsePage() {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -88,15 +114,7 @@ export default function QuestionsBrowsePage() {
|
||||
const [sortOrder, setSortOrder, isSortOrderInitialized] =
|
||||
useSearchParamSingle<SortOrder>('sortOrder', {
|
||||
defaultValue: SortOrder.DESC,
|
||||
paramToString: (value) => {
|
||||
if (value === SortOrder.ASC) {
|
||||
return 'ASC';
|
||||
}
|
||||
if (value === SortOrder.DESC) {
|
||||
return 'DESC';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
paramToString: sortOrderToString,
|
||||
stringToParam: (param) => {
|
||||
const uppercaseParam = param.toUpperCase();
|
||||
if (uppercaseParam === 'ASC') {
|
||||
@@ -112,15 +130,7 @@ export default function QuestionsBrowsePage() {
|
||||
const [sortType, setSortType, isSortTypeInitialized] =
|
||||
useSearchParamSingle<SortType>('sortType', {
|
||||
defaultValue: SortType.TOP,
|
||||
paramToString: (value) => {
|
||||
if (value === SortType.NEW) {
|
||||
return 'NEW';
|
||||
}
|
||||
if (value === SortType.TOP) {
|
||||
return 'TOP';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
paramToString: sortTypeToString,
|
||||
stringToParam: (param) => {
|
||||
const uppercaseParam = param.toUpperCase();
|
||||
if (uppercaseParam === 'NEW') {
|
||||
@@ -129,6 +139,9 @@ export default function QuestionsBrowsePage() {
|
||||
if (uppercaseParam === 'TOP') {
|
||||
return SortType.TOP;
|
||||
}
|
||||
if (uppercaseParam === 'ENCOUNTERS') {
|
||||
return SortType.ENCOUNTERS;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
@@ -205,6 +218,11 @@ export default function QuestionsBrowsePage() {
|
||||
{
|
||||
onSuccess: () => {
|
||||
utils.invalidateQueries('questions.questions.getQuestionsByFilter');
|
||||
showToast({
|
||||
// Duration: 10000 (optional)
|
||||
title: `Thank you for submitting your question!`,
|
||||
variant: 'success',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -260,8 +278,8 @@ export default function QuestionsBrowsePage() {
|
||||
questionAge: selectedQuestionAge,
|
||||
questionTypes: selectedQuestionTypes,
|
||||
roles: selectedRoles,
|
||||
sortOrder: sortOrder === SortOrder.ASC ? 'ASC' : 'DESC',
|
||||
sortType: sortType === SortType.TOP ? 'TOP' : 'NEW',
|
||||
sortOrder: sortOrderToString(sortOrder),
|
||||
sortType: sortTypeToString(sortType),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -280,6 +298,8 @@ export default function QuestionsBrowsePage() {
|
||||
sortType,
|
||||
]);
|
||||
|
||||
const { showToast } = useToast();
|
||||
|
||||
const selectedCompanyOptions = useMemo(() => {
|
||||
return selectedCompanySlugs.map((company) => {
|
||||
const [id, label] = company.split('_');
|
||||
@@ -473,7 +493,7 @@ export default function QuestionsBrowsePage() {
|
||||
<Head>
|
||||
<title>Home - {APP_TITLE}</title>
|
||||
</Head>
|
||||
<main className="flex flex-1 flex-col items-stretch">
|
||||
<main className="flex h-[calc(100vh_-_64px)] flex-1 flex-col items-stretch">
|
||||
<div className="flex h-full flex-1">
|
||||
<section className="min-h-0 flex-1 overflow-auto">
|
||||
<div className="my-4 mx-auto flex max-w-3xl flex-col items-stretch justify-start gap-6">
|
||||
@@ -497,6 +517,7 @@ export default function QuestionsBrowsePage() {
|
||||
<QuestionSearchBar
|
||||
query={query}
|
||||
sortOrderValue={sortOrder}
|
||||
sortTypeOptions={QUESTION_SORT_TYPES}
|
||||
sortTypeValue={sortType}
|
||||
onFilterOptionsToggle={() => {
|
||||
setFilterDrawerOpen(!filterDrawerOpen);
|
||||
|
||||
@@ -5,16 +5,21 @@ import {
|
||||
EllipsisVerticalIcon,
|
||||
NoSymbolIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { Button, Select } from '@tih/ui';
|
||||
|
||||
import QuestionListCard from '~/components/questions/card/question/QuestionListCard';
|
||||
import type { CreateListFormData } from '~/components/questions/CreateListDialog';
|
||||
import CreateListDialog from '~/components/questions/CreateListDialog';
|
||||
import DeleteListDialog from '~/components/questions/DeleteListDialog';
|
||||
|
||||
import { Button } from '~/../../../packages/ui/dist';
|
||||
import { APP_TITLE } from '~/utils/questions/constants';
|
||||
import createSlug from '~/utils/questions/createSlug';
|
||||
import {
|
||||
useCreateListAsync,
|
||||
useDeleteListAsync,
|
||||
} from '~/utils/questions/mutations';
|
||||
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
|
||||
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
|
||||
import { trpc } from '~/utils/trpc';
|
||||
@@ -22,24 +27,10 @@ import { trpc } from '~/utils/trpc';
|
||||
export default function ListPage() {
|
||||
const utils = trpc.useContext();
|
||||
const { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']);
|
||||
const { mutateAsync: createList } = trpc.useMutation(
|
||||
'questions.lists.create',
|
||||
{
|
||||
onSuccess: () => {
|
||||
// TODO: Add optimistic update
|
||||
utils.invalidateQueries(['questions.lists.getListsByUser']);
|
||||
},
|
||||
},
|
||||
);
|
||||
const { mutateAsync: deleteList } = trpc.useMutation(
|
||||
'questions.lists.delete',
|
||||
{
|
||||
onSuccess: () => {
|
||||
// TODO: Add optimistic update
|
||||
utils.invalidateQueries(['questions.lists.getListsByUser']);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const createListAsync = useCreateListAsync();
|
||||
const deleteListAsync = useDeleteListAsync();
|
||||
|
||||
const { mutateAsync: deleteQuestionEntry } = trpc.useMutation(
|
||||
'questions.lists.deleteQuestionEntry',
|
||||
{
|
||||
@@ -57,7 +48,7 @@ export default function ListPage() {
|
||||
const [listIdToDelete, setListIdToDelete] = useState('');
|
||||
|
||||
const handleDeleteList = async (listId: string) => {
|
||||
await deleteList({
|
||||
await deleteListAsync({
|
||||
id: listId,
|
||||
});
|
||||
setShowDeleteListDialog(false);
|
||||
@@ -68,7 +59,7 @@ export default function ListPage() {
|
||||
};
|
||||
|
||||
const handleCreateList = async (data: CreateListFormData) => {
|
||||
await createList({
|
||||
await createListAsync({
|
||||
name: data.name,
|
||||
});
|
||||
setShowCreateListDialog(false);
|
||||
@@ -92,7 +83,7 @@ export default function ListPage() {
|
||||
selectedListIndex === index ? 'bg-primary-100' : ''
|
||||
}`}>
|
||||
<button
|
||||
className="flex w-full flex-1 justify-between "
|
||||
className="flex w-full flex-1 justify-between"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedListIndex(index);
|
||||
@@ -145,36 +136,69 @@ export default function ListPage() {
|
||||
</>
|
||||
);
|
||||
|
||||
const createButton = (
|
||||
<Button
|
||||
icon={PlusIcon}
|
||||
isLabelHidden={true}
|
||||
label="Create"
|
||||
size="md"
|
||||
variant="tertiary"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleAddClick();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>My Lists - {APP_TITLE}</title>
|
||||
</Head>
|
||||
<main className="flex flex-1 flex-col items-stretch">
|
||||
<main className="flex h-[calc(100vh_-_64px)] flex-1 flex-col items-stretch">
|
||||
<div className="flex h-full flex-1">
|
||||
<aside className="w-[300px] overflow-y-auto border-r bg-white py-4 lg:block">
|
||||
<aside className="hidden w-[300px] overflow-y-auto border-r bg-white py-4 lg:block">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="px-4 text-xl font-semibold">My Lists</h2>
|
||||
<div className="px-4">
|
||||
<Button
|
||||
icon={PlusIcon}
|
||||
isLabelHidden={true}
|
||||
label="Create"
|
||||
size="md"
|
||||
variant="tertiary"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleAddClick();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4">{createButton}</div>
|
||||
</div>
|
||||
{listOptions}
|
||||
</aside>
|
||||
<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">
|
||||
<div className="flex items-end gap-2 lg:hidden">
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
label="My Lists"
|
||||
options={
|
||||
lists?.map((list) => ({
|
||||
label: list.name,
|
||||
value: list.id,
|
||||
})) ?? []
|
||||
}
|
||||
value={lists?.[selectedListIndex]?.id ?? ''}
|
||||
onChange={(value) => {
|
||||
setSelectedListIndex(
|
||||
lists?.findIndex((list) => list.id === value) ?? 0,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={TrashIcon}
|
||||
isLabelHidden={true}
|
||||
label="Delete"
|
||||
size="md"
|
||||
variant="tertiary"
|
||||
onClick={() => {
|
||||
setShowDeleteListDialog(true);
|
||||
setListIdToDelete(lists?.[selectedListIndex]?.id ?? '');
|
||||
}}
|
||||
/>
|
||||
{createButton}
|
||||
</div>
|
||||
{lists?.[selectedListIndex] && (
|
||||
<div className="flex flex-col gap-4 pb-4">
|
||||
{lists[selectedListIndex].questionEntries.map(
|
||||
|
||||
@@ -85,6 +85,21 @@ export const SORT_TYPES = [
|
||||
},
|
||||
];
|
||||
|
||||
export const QUESTION_SORT_TYPES = [
|
||||
{
|
||||
label: 'New',
|
||||
value: SortType.NEW,
|
||||
},
|
||||
{
|
||||
label: 'Top',
|
||||
value: SortType.TOP,
|
||||
},
|
||||
{
|
||||
label: 'Encounters',
|
||||
value: SortType.ENCOUNTERS,
|
||||
},
|
||||
];
|
||||
|
||||
export const SAMPLE_QUESTION = {
|
||||
answerCount: 10,
|
||||
commentCount: 10,
|
||||
|
||||
60
apps/portal/src/utils/questions/mutations.ts
Normal file
60
apps/portal/src/utils/questions/mutations.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { trpc } from '../trpc';
|
||||
|
||||
export function useAddQuestionToListAsync() {
|
||||
const utils = trpc.useContext();
|
||||
const { mutateAsync: addQuestionToListAsync } = trpc.useMutation(
|
||||
'questions.lists.createQuestionEntry',
|
||||
{
|
||||
// TODO: Add optimistic update
|
||||
onSuccess: () => {
|
||||
utils.invalidateQueries(['questions.lists.getListsByUser']);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return addQuestionToListAsync;
|
||||
}
|
||||
|
||||
export function useRemoveQuestionFromListAsync() {
|
||||
const utils = trpc.useContext();
|
||||
const { mutateAsync: removeQuestionFromListAsync } = trpc.useMutation(
|
||||
'questions.lists.deleteQuestionEntry',
|
||||
{
|
||||
// TODO: Add optimistic update
|
||||
onSuccess: () => {
|
||||
utils.invalidateQueries(['questions.lists.getListsByUser']);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return removeQuestionFromListAsync;
|
||||
}
|
||||
|
||||
export function useCreateListAsync() {
|
||||
const utils = trpc.useContext();
|
||||
const { mutateAsync: createListAsync } = trpc.useMutation(
|
||||
'questions.lists.create',
|
||||
{
|
||||
onSuccess: () => {
|
||||
// TODO: Add optimistic update
|
||||
utils.invalidateQueries(['questions.lists.getListsByUser']);
|
||||
},
|
||||
},
|
||||
);
|
||||
return createListAsync;
|
||||
}
|
||||
|
||||
export function useDeleteListAsync() {
|
||||
const utils = trpc.useContext();
|
||||
const { mutateAsync: deleteListAsync } = trpc.useMutation(
|
||||
'questions.lists.delete',
|
||||
{
|
||||
onSuccess: () => {
|
||||
// TODO: Add optimistic update
|
||||
utils.invalidateQueries(['questions.lists.getListsByUser']);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return deleteListAsync;
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { useCallback } from 'react';
|
||||
import type { Vote } from '@prisma/client';
|
||||
import type { InfiniteData } from 'react-query';
|
||||
import { Vote } from '@prisma/client';
|
||||
|
||||
import { trpc } from '../trpc';
|
||||
|
||||
import type { Question } from '~/types/questions';
|
||||
|
||||
type UseVoteOptions = {
|
||||
setDownVote: () => void;
|
||||
setNoVote: () => void;
|
||||
@@ -46,12 +49,78 @@ type MutationKey = Parameters<typeof trpc.useMutation>[0];
|
||||
type QueryKey = Parameters<typeof trpc.useQuery>[0][0];
|
||||
|
||||
export const useQuestionVote = (id: string) => {
|
||||
const utils = trpc.useContext();
|
||||
|
||||
return useVote(id, {
|
||||
idKey: 'questionId',
|
||||
invalidateKeys: [
|
||||
'questions.questions.getQuestionsByFilter',
|
||||
'questions.questions.getQuestionById',
|
||||
// 'questions.questions.getQuestionById',
|
||||
// 'questions.questions.getQuestionsByFilterAndContent',
|
||||
],
|
||||
onMutate: async (previousVote, currentVote) => {
|
||||
const questionQueries = utils.queryClient.getQueriesData([
|
||||
'questions.questions.getQuestionsByFilterAndContent',
|
||||
]);
|
||||
|
||||
const getVoteValue = (vote: Vote | null) => {
|
||||
if (vote === Vote.UPVOTE) {
|
||||
return 1;
|
||||
}
|
||||
if (vote === Vote.DOWNVOTE) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const voteValueChange =
|
||||
getVoteValue(currentVote) - getVoteValue(previousVote);
|
||||
|
||||
for (const [key, query] of questionQueries) {
|
||||
if (query === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { pages, ...restQuery } = query as InfiniteData<{
|
||||
data: Array<Question>;
|
||||
}>;
|
||||
|
||||
const newQuery = {
|
||||
pages: pages.map(({ data, ...restPage }) => ({
|
||||
data: data.map((question) => {
|
||||
if (question.id === id) {
|
||||
const { numVotes, ...restQuestion } = question;
|
||||
return {
|
||||
numVotes: numVotes + voteValueChange,
|
||||
...restQuestion,
|
||||
};
|
||||
}
|
||||
return question;
|
||||
}),
|
||||
...restPage,
|
||||
})),
|
||||
...restQuery,
|
||||
};
|
||||
|
||||
utils.queryClient.setQueryData(key, newQuery);
|
||||
}
|
||||
|
||||
const prevQuestion = utils.queryClient.getQueryData([
|
||||
'questions.questions.getQuestionById',
|
||||
{
|
||||
id,
|
||||
},
|
||||
]) as Question;
|
||||
|
||||
const newQuestion = {
|
||||
...prevQuestion,
|
||||
numVotes: prevQuestion.numVotes + voteValueChange,
|
||||
};
|
||||
|
||||
utils.queryClient.setQueryData(
|
||||
['questions.questions.getQuestionById', { id }],
|
||||
newQuestion,
|
||||
);
|
||||
},
|
||||
query: 'questions.questions.user.getVote',
|
||||
setDownVoteKey: 'questions.questions.user.setDownVote',
|
||||
setNoVoteKey: 'questions.questions.user.setNoVote',
|
||||
@@ -63,8 +132,8 @@ export const useAnswerVote = (id: string) => {
|
||||
return useVote(id, {
|
||||
idKey: 'answerId',
|
||||
invalidateKeys: [
|
||||
'questions.answers.getAnswers',
|
||||
'questions.answers.getAnswerById',
|
||||
'questions.answers.getAnswers',
|
||||
],
|
||||
query: 'questions.answers.user.getVote',
|
||||
setDownVoteKey: 'questions.answers.user.setDownVote',
|
||||
@@ -95,9 +164,17 @@ export const useAnswerCommentVote = (id: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
type InvalidateFunction = (
|
||||
previousVote: Vote | null,
|
||||
currentVote: Vote | null,
|
||||
) => Promise<void>;
|
||||
|
||||
type VoteProps<VoteQueryKey extends QueryKey = QueryKey> = {
|
||||
idKey: string;
|
||||
invalidateKeys: Array<VoteQueryKey>;
|
||||
invalidateKeys: Array<QueryKey>;
|
||||
onMutate?: InvalidateFunction;
|
||||
|
||||
// Invalidate: Partial<Record<QueryKey, InvalidateFunction | null>>;
|
||||
query: VoteQueryKey;
|
||||
setDownVoteKey: MutationKey;
|
||||
setNoVoteKey: MutationKey;
|
||||
@@ -116,6 +193,7 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
|
||||
const {
|
||||
idKey,
|
||||
invalidateKeys,
|
||||
onMutate,
|
||||
query,
|
||||
setDownVoteKey,
|
||||
setNoVoteKey,
|
||||
@@ -125,11 +203,16 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
|
||||
|
||||
const onVoteUpdate = useCallback(() => {
|
||||
// TODO: Optimise query invalidation
|
||||
utils.invalidateQueries([query, { [idKey]: id } as any]);
|
||||
// utils.invalidateQueries([query, { [idKey]: id } as any]);
|
||||
for (const invalidateKey of invalidateKeys) {
|
||||
utils.invalidateQueries([invalidateKey]);
|
||||
utils.invalidateQueries(invalidateKey);
|
||||
// If (invalidateFunction === null) {
|
||||
// utils.invalidateQueries([invalidateKey as QueryKey]);
|
||||
// } else {
|
||||
// invalidateFunction(utils, previousVote, currentVote);
|
||||
// }
|
||||
}
|
||||
}, [id, idKey, utils, query, invalidateKeys]);
|
||||
}, [utils, invalidateKeys]);
|
||||
|
||||
const { data } = trpc.useQuery([
|
||||
query,
|
||||
@@ -143,7 +226,7 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
|
||||
const { mutate: setUpVote } = trpc.useMutation<any, UseVoteMutationContext>(
|
||||
setUpVoteKey,
|
||||
{
|
||||
onError: (err, variables, context) => {
|
||||
onError: (_error, _variables, context) => {
|
||||
if (context !== undefined) {
|
||||
utils.setQueryData([query], context.previousData);
|
||||
}
|
||||
@@ -154,6 +237,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
|
||||
[query, { [idKey]: id } as any],
|
||||
);
|
||||
|
||||
const currentData = {
|
||||
...(vote as any),
|
||||
vote: Vote.UPVOTE,
|
||||
} as BackendVote;
|
||||
|
||||
utils.setQueryData(
|
||||
[
|
||||
query,
|
||||
@@ -161,9 +249,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
|
||||
[idKey]: id,
|
||||
} as any,
|
||||
],
|
||||
vote as any,
|
||||
currentData as any,
|
||||
);
|
||||
return { currentData: vote, previousData };
|
||||
|
||||
await onMutate?.(previousData?.vote ?? null, currentData?.vote ?? null);
|
||||
return { currentData, previousData };
|
||||
},
|
||||
onSettled: onVoteUpdate,
|
||||
},
|
||||
@@ -171,7 +261,7 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
|
||||
const { mutate: setDownVote } = trpc.useMutation<any, UseVoteMutationContext>(
|
||||
setDownVoteKey,
|
||||
{
|
||||
onError: (error, variables, context) => {
|
||||
onError: (_error, _variables, context) => {
|
||||
if (context !== undefined) {
|
||||
utils.setQueryData([query], context.previousData);
|
||||
}
|
||||
@@ -182,6 +272,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
|
||||
[query, { [idKey]: id } as any],
|
||||
);
|
||||
|
||||
const currentData = {
|
||||
...vote,
|
||||
vote: Vote.DOWNVOTE,
|
||||
} as BackendVote;
|
||||
|
||||
utils.setQueryData(
|
||||
[
|
||||
query,
|
||||
@@ -189,9 +284,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
|
||||
[idKey]: id,
|
||||
} as any,
|
||||
],
|
||||
vote,
|
||||
currentData as any,
|
||||
);
|
||||
return { currentData: vote, previousData };
|
||||
|
||||
await onMutate?.(previousData?.vote ?? null, currentData?.vote ?? null);
|
||||
return { currentData, previousData };
|
||||
},
|
||||
onSettled: onVoteUpdate,
|
||||
},
|
||||
@@ -200,23 +297,31 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
|
||||
const { mutate: setNoVote } = trpc.useMutation<any, UseVoteMutationContext>(
|
||||
setNoVoteKey,
|
||||
{
|
||||
onError: (err, variables, context) => {
|
||||
onError: (_error, _variables, context) => {
|
||||
if (context !== undefined) {
|
||||
utils.setQueryData([query], context.previousData);
|
||||
}
|
||||
},
|
||||
onMutate: async (vote) => {
|
||||
onMutate: async () => {
|
||||
await utils.queryClient.cancelQueries([query, { [idKey]: id } as any]);
|
||||
utils.setQueryData(
|
||||
const previousData = utils.queryClient.getQueryData<BackendVote | null>(
|
||||
[query, { [idKey]: id } as any],
|
||||
);
|
||||
const currentData: BackendVote | null = null;
|
||||
|
||||
utils.queryClient.setQueryData<BackendVote | null>(
|
||||
[
|
||||
query,
|
||||
{
|
||||
[idKey]: id,
|
||||
} as any,
|
||||
],
|
||||
null as any,
|
||||
currentData,
|
||||
);
|
||||
return { currentData: null, previousData: vote };
|
||||
|
||||
await onMutate?.(previousData?.vote ?? null, null);
|
||||
|
||||
return { currentData, previousData };
|
||||
},
|
||||
onSettled: onVoteUpdate,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user