[questions][fix] ui fixes and enhancements (#514)

Co-authored-by: wlren <weilinwork99@gmail.com>
pull/519/head
Jeff Sieu 2 years ago committed by GitHub
parent 8f4246da6d
commit 075f7bfba8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -3,22 +3,52 @@ import type { PropsWithChildren } from 'react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Fragment, useRef, useState } from 'react'; import { Fragment, useRef, useState } from 'react';
import { Menu, Transition } from '@headlessui/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 { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import CreateListDialog from './CreateListDialog';
export type AddToListDropdownProps = { export type AddToListDropdownProps = {
questionId: string; 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({ export default function AddToListDropdown({
questionId, questionId,
}: AddToListDropdownProps) { }: AddToListDropdownProps) {
const [menuOpened, setMenuOpened] = useState(false); const [menuOpened, setMenuOpened] = useState(false);
const ref = useRef<HTMLDivElement>(null); 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 { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']);
const listsWithQuestionData = useMemo(() => { const listsWithQuestionData = useMemo(() => {
@ -30,25 +60,8 @@ export default function AddToListDropdown({
})); }));
}, [lists, questionId]); }, [lists, questionId]);
const { mutateAsync: addQuestionToList } = trpc.useMutation( const addQuestionToList = useAddQuestionToListAsync();
'questions.lists.createQuestionEntry', const removeQuestionFromList = useRemoveQuestionFromListAsync();
{
// 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 addClickOutsideListener = () => { const addClickOutsideListener = () => {
document.addEventListener('click', handleClickOutside, true); document.addEventListener('click', handleClickOutside, true);
@ -101,14 +114,14 @@ export default function AddToListDropdown({
); );
return ( return (
<div>
<Menu ref={ref} as="div" className="relative inline-block text-left"> <Menu ref={ref} as="div" className="relative inline-block text-left">
<div> <div>
<Menu.Button as={CustomMenuButton}> <Menu.Button as={CustomMenuButton}>
<HeartIcon aria-hidden="true" className="-ml-1 mr-2 h-5 w-5" /> <HeartIcon aria-hidden="true" className="-ml-1 mr-2 h-5 w-5" />
Add to List Add to list
</Menu.Button> </Menu.Button>
</div> </div>
<Transition <Transition
as={Fragment} as={Fragment}
enter="transition ease-out duration-100" enter="transition ease-out duration-100"
@ -125,16 +138,7 @@ export default function AddToListDropdown({
<> <>
{(listsWithQuestionData ?? []).map((list) => ( {(listsWithQuestionData ?? []).map((list) => (
<div key={list.id} className="py-1"> <div key={list.id} className="py-1">
<Menu.Item> <DropdownButton
{({ 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={() => { onClick={() => {
if (list.hasQuestion) { if (list.hasQuestion) {
handleDeleteFromList(list.id); handleDeleteFromList(list.id);
@ -142,22 +146,47 @@ export default function AddToListDropdown({
handleAddToList(list.id); 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 && ( {list.hasQuestion && (
<CheckIcon <CheckIcon
aria-hidden="true" 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} </div>
</button> </DropdownButton>
)}
</Menu.Item>
</div> </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> </Menu.Items>
</Transition> </Transition>
</Menu> </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 { useState } from 'react';
import {
BuildingOffice2Icon,
CalendarDaysIcon,
QuestionMarkCircleIcon,
} from '@heroicons/react/24/outline';
import { TextInput } from '@tih/ui'; import { TextInput } from '@tih/ui';
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback'; import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
@ -32,9 +27,10 @@ export default function ContributeQuestionCard({
return ( return (
<div className="w-full"> <div className="w-full">
<button <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" type="button"
onClick={handleOpenContribute}> onClick={handleOpenContribute}>
<div className="w-full">
<TextInput <TextInput
disabled={true} disabled={true}
isLabelHidden={true} isLabelHidden={true}
@ -42,34 +38,8 @@ export default function ContributeQuestionCard({
placeholder="Contribute a question" placeholder="Contribute a question"
onChange={handleOpenContribute} onChange={handleOpenContribute}
/> />
<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> </div>
<div className="flex flex-wrap items-end justify-start gap-2">
<h1 className="bg-primary-600 hover:bg-primary-700 rounded-full px-3 py-2 text-white"> <h1 className="bg-primary-600 hover:bg-primary-700 rounded-full px-3 py-2 text-white">
Contribute Contribute
</h1> </h1>

@ -8,16 +8,15 @@ export type SortOption<Value> = {
value: Value; value: Value;
}; };
const sortTypeOptions = SORT_TYPES;
const sortOrderOptions = SORT_ORDERS;
type SortOrderProps<Order> = { type SortOrderProps<Order> = {
onSortOrderChange?: (sortValue: Order) => void; onSortOrderChange?: (sortValue: Order) => void;
sortOrderOptions?: Array<SortOption<Order>>;
sortOrderValue: Order; sortOrderValue: Order;
}; };
type SortTypeProps<Type> = { type SortTypeProps<Type> = {
onSortTypeChange?: (sortType: Type) => void; onSortTypeChange?: (sortType: Type) => void;
sortTypeOptions?: Array<SortOption<Type>>;
sortTypeValue: Type; sortTypeValue: Type;
}; };
@ -29,17 +28,22 @@ export default function SortOptionsSelect({
sortOrderValue, sortOrderValue,
onSortTypeChange, onSortTypeChange,
sortTypeValue, sortTypeValue,
sortOrderOptions,
sortTypeOptions,
}: SortOptionsSelectProps) { }: SortOptionsSelectProps) {
const sortTypes = sortTypeOptions ?? SORT_TYPES;
const sortOrders = sortOrderOptions ?? SORT_ORDERS;
return ( return (
<div className="flex items-end justify-end gap-4"> <div className="flex items-end justify-end gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Select <Select
display="inline" display="inline"
label="Sort by" label="Sort by"
options={sortTypeOptions} options={sortTypes}
value={sortTypeValue} value={sortTypeValue}
onChange={(value) => { onChange={(value) => {
const chosenOption = sortTypeOptions.find( const chosenOption = sortTypes.find(
(option) => String(option.value) === value, (option) => String(option.value) === value,
); );
if (chosenOption) { if (chosenOption) {
@ -52,10 +56,10 @@ export default function SortOptionsSelect({
<Select <Select
display="inline" display="inline"
label="Order by" label="Order by"
options={sortOrderOptions} options={sortOrders}
value={sortOrderValue} value={sortOrderValue}
onChange={(value) => { onChange={(value) => {
const chosenOption = sortOrderOptions.find( const chosenOption = sortOrders.find(
(option) => String(option.value) === value, (option) => String(option.value) === value,
); );
if (chosenOption) { if (chosenOption) {

@ -90,7 +90,7 @@ type ReceivedStatisticsProps =
type CreateEncounterProps = type CreateEncounterProps =
| { | {
createEncounterButtonText: string; createEncounterButtonText: string;
onReceivedSubmit: (data: CreateQuestionEncounterData) => void; onReceivedSubmit: (data: CreateQuestionEncounterData) => Promise<void>;
showCreateEncounterButton: true; showCreateEncounterButton: true;
} }
| { | {
@ -185,7 +185,7 @@ export default function BaseQuestionCard({
)} )}
<div className="flex flex-1 flex-col items-start gap-2"> <div className="flex flex-1 flex-col items-start gap-2">
<div className="flex items-baseline justify-between self-stretch"> <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 && ( {showAggregateStatistics && (
<> <>
<QuestionTypeBadge type={type} /> <QuestionTypeBadge type={type} />
@ -263,9 +263,8 @@ export default function BaseQuestionCard({
onCancel={() => { onCancel={() => {
setShowReceivedForm(false); setShowReceivedForm(false);
}} }}
onSubmit={(data) => { onSubmit={async (data) => {
onReceivedSubmit?.(data); await onReceivedSubmit?.(data);
setShowReceivedForm(false);
}} }}
/> />
)} )}

@ -5,7 +5,7 @@ import { ArrowPathIcon } from '@heroicons/react/20/solid';
import type { QuestionsQuestionType } from '@prisma/client'; import type { QuestionsQuestionType } from '@prisma/client';
import type { TypeaheadOption } from '@tih/ui'; import type { TypeaheadOption } from '@tih/ui';
import { CheckboxInput } 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 { QUESTION_TYPES } from '~/utils/questions/constants';
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates'; import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
@ -187,11 +187,9 @@ export default function ContributeQuestionForm({
/> />
</div> </div>
</div> </div>
<div className="w-full">
<HorizontalDivider />
</div>
<h2 <h2
className="text-primary-900 mb-3 className="text-primary-900
text-lg font-semibold text-lg font-semibold
"> ">
Are these questions the same as yours? Are these questions the same as yours?
@ -243,7 +241,9 @@ export default function ContributeQuestionForm({
/> />
); );
})} })}
{similarQuestions?.length === 0 && ( {similarQuestions?.length === 0 &&
contentToCheck?.length !== 0 &&
questionContent === contentToCheck && (
<p className="font-semibold text-slate-900"> <p className="font-semibold text-slate-900">
No similar questions found. No similar questions found.
</p> </p>

@ -1,5 +1,6 @@
import { startOfMonth } from 'date-fns'; import { startOfMonth } from 'date-fns';
import { useState } from 'react'; import { useState } from 'react';
import { CheckIcon } from '@heroicons/react/20/solid';
import { Button } from '@tih/ui'; import { Button } from '@tih/ui';
import type { Month } from '~/components/shared/MonthYearPicker'; import type { Month } from '~/components/shared/MonthYearPicker';
@ -22,7 +23,7 @@ export type CreateQuestionEncounterData = {
export type CreateQuestionEncounterFormProps = { export type CreateQuestionEncounterFormProps = {
onCancel: () => void; onCancel: () => void;
onSubmit: (data: CreateQuestionEncounterData) => void; onSubmit: (data: CreateQuestionEncounterData) => Promise<void>;
}; };
export default function CreateQuestionEncounterForm({ export default function CreateQuestionEncounterForm({
@ -30,6 +31,8 @@ export default function CreateQuestionEncounterForm({
onSubmit, onSubmit,
}: CreateQuestionEncounterFormProps) { }: CreateQuestionEncounterFormProps) {
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [selectedCompany, setSelectedCompany] = useState<string | null>(null); const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
const [selectedLocation, setSelectedLocation] = useState<Location | null>( const [selectedLocation, setSelectedLocation] = useState<Location | null>(
@ -40,9 +43,18 @@ export default function CreateQuestionEncounterForm({
startOfMonth(new Date()), 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 ( return (
<div className="flex items-center gap-2"> <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'} I saw this question {step <= 1 ? 'at' : step === 2 ? 'for' : 'on'}
</p> </p>
{step === 0 && ( {step === 0 && (
@ -128,9 +140,10 @@ export default function CreateQuestionEncounterForm({
)} )}
{step === 3 && ( {step === 3 && (
<Button <Button
isLoading={loading}
label="Submit" label="Submit"
variant="primary" variant="primary"
onClick={() => { onClick={async () => {
if ( if (
selectedCompany && selectedCompany &&
selectedLocation && selectedLocation &&
@ -138,7 +151,9 @@ export default function CreateQuestionEncounterForm({
selectedDate selectedDate
) { ) {
const { cityId, stateId, countryId } = selectedLocation; const { cityId, stateId, countryId } = selectedLocation;
onSubmit({ setLoading(true);
try {
await onSubmit({
cityId, cityId,
company: selectedCompany, company: selectedCompany,
countryId, countryId,
@ -146,6 +161,10 @@ export default function CreateQuestionEncounterForm({
seenAt: selectedDate, seenAt: selectedDate,
stateId, 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', 'questions.questions.encounters.user.create',
{ {
onSuccess: () => { onSuccess: () => {
@ -208,8 +208,8 @@ export default function QuestionPage() {
year: 'numeric', year: 'numeric',
})} })}
upvoteCount={question.numVotes} upvoteCount={question.numVotes}
onReceivedSubmit={(data) => { onReceivedSubmit={async (data) => {
addEncounter({ await addEncounterAsync({
cityId: data.cityId, cityId: data.cityId,
companyId: data.company, companyId: data.company,
countryId: data.countryId, countryId: data.countryId,
@ -221,7 +221,7 @@ export default function QuestionPage() {
}} }}
/> />
<div className="mx-2"> <div className="mx-2">
<Collapsible label={`${question.numComments} comment(s)`}> <Collapsible label={`View ${question.numComments} comment(s)`}>
<div className="mt-4 px-4"> <div className="mt-4 px-4">
<form <form
className="mb-2" className="mb-2"
@ -246,7 +246,7 @@ export default function QuestionPage() {
</div> </div>
</form> </form>
{/* TODO: Add button to load more */} {/* 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"> <div className="flex items-center justify-between gap-2">
<p className="text-lg">Comments</p> <p className="text-lg">Comments</p>
<div className="flex items-end gap-2"> <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 { NoSymbolIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client'; import type { QuestionsQuestionType } from '@prisma/client';
import type { TypeaheadOption } from '@tih/ui'; import type { TypeaheadOption } from '@tih/ui';
import { useToast } from '@tih/ui';
import { Button, SlideOut } from '@tih/ui'; import { Button, SlideOut } from '@tih/ui';
import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard'; 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 { JobTitleLabels } from '~/components/shared/JobTitles';
import type { QuestionAge } from '~/utils/questions/constants'; import type { QuestionAge } from '~/utils/questions/constants';
import { QUESTION_SORT_TYPES } from '~/utils/questions/constants';
import { APP_TITLE } from '~/utils/questions/constants'; import { APP_TITLE } from '~/utils/questions/constants';
import { QUESTION_AGES, QUESTION_TYPES } from '~/utils/questions/constants'; import { QUESTION_AGES, QUESTION_TYPES } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug'; import createSlug from '~/utils/questions/createSlug';
@ -34,6 +36,30 @@ import type { Location } from '~/types/questions.d';
import { SortType } from '~/types/questions.d'; import { SortType } from '~/types/questions.d';
import { SortOrder } 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() { export default function QuestionsBrowsePage() {
const router = useRouter(); const router = useRouter();
@ -88,15 +114,7 @@ export default function QuestionsBrowsePage() {
const [sortOrder, setSortOrder, isSortOrderInitialized] = const [sortOrder, setSortOrder, isSortOrderInitialized] =
useSearchParamSingle<SortOrder>('sortOrder', { useSearchParamSingle<SortOrder>('sortOrder', {
defaultValue: SortOrder.DESC, defaultValue: SortOrder.DESC,
paramToString: (value) => { paramToString: sortOrderToString,
if (value === SortOrder.ASC) {
return 'ASC';
}
if (value === SortOrder.DESC) {
return 'DESC';
}
return null;
},
stringToParam: (param) => { stringToParam: (param) => {
const uppercaseParam = param.toUpperCase(); const uppercaseParam = param.toUpperCase();
if (uppercaseParam === 'ASC') { if (uppercaseParam === 'ASC') {
@ -112,15 +130,7 @@ export default function QuestionsBrowsePage() {
const [sortType, setSortType, isSortTypeInitialized] = const [sortType, setSortType, isSortTypeInitialized] =
useSearchParamSingle<SortType>('sortType', { useSearchParamSingle<SortType>('sortType', {
defaultValue: SortType.TOP, defaultValue: SortType.TOP,
paramToString: (value) => { paramToString: sortTypeToString,
if (value === SortType.NEW) {
return 'NEW';
}
if (value === SortType.TOP) {
return 'TOP';
}
return null;
},
stringToParam: (param) => { stringToParam: (param) => {
const uppercaseParam = param.toUpperCase(); const uppercaseParam = param.toUpperCase();
if (uppercaseParam === 'NEW') { if (uppercaseParam === 'NEW') {
@ -129,6 +139,9 @@ export default function QuestionsBrowsePage() {
if (uppercaseParam === 'TOP') { if (uppercaseParam === 'TOP') {
return SortType.TOP; return SortType.TOP;
} }
if (uppercaseParam === 'ENCOUNTERS') {
return SortType.ENCOUNTERS;
}
return null; return null;
}, },
}); });
@ -205,6 +218,11 @@ export default function QuestionsBrowsePage() {
{ {
onSuccess: () => { onSuccess: () => {
utils.invalidateQueries('questions.questions.getQuestionsByFilter'); 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, questionAge: selectedQuestionAge,
questionTypes: selectedQuestionTypes, questionTypes: selectedQuestionTypes,
roles: selectedRoles, roles: selectedRoles,
sortOrder: sortOrder === SortOrder.ASC ? 'ASC' : 'DESC', sortOrder: sortOrderToString(sortOrder),
sortType: sortType === SortType.TOP ? 'TOP' : 'NEW', sortType: sortTypeToString(sortType),
}, },
}); });
@ -280,6 +298,8 @@ export default function QuestionsBrowsePage() {
sortType, sortType,
]); ]);
const { showToast } = useToast();
const selectedCompanyOptions = useMemo(() => { const selectedCompanyOptions = useMemo(() => {
return selectedCompanySlugs.map((company) => { return selectedCompanySlugs.map((company) => {
const [id, label] = company.split('_'); const [id, label] = company.split('_');
@ -473,7 +493,7 @@ export default function QuestionsBrowsePage() {
<Head> <Head>
<title>Home - {APP_TITLE}</title> <title>Home - {APP_TITLE}</title>
</Head> </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"> <div className="flex h-full flex-1">
<section className="min-h-0 flex-1 overflow-auto"> <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"> <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 <QuestionSearchBar
query={query} query={query}
sortOrderValue={sortOrder} sortOrderValue={sortOrder}
sortTypeOptions={QUESTION_SORT_TYPES}
sortTypeValue={sortType} sortTypeValue={sortType}
onFilterOptionsToggle={() => { onFilterOptionsToggle={() => {
setFilterDrawerOpen(!filterDrawerOpen); setFilterDrawerOpen(!filterDrawerOpen);

@ -5,16 +5,21 @@ import {
EllipsisVerticalIcon, EllipsisVerticalIcon,
NoSymbolIcon, NoSymbolIcon,
PlusIcon, PlusIcon,
TrashIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { Button, Select } from '@tih/ui';
import QuestionListCard from '~/components/questions/card/question/QuestionListCard'; import QuestionListCard from '~/components/questions/card/question/QuestionListCard';
import type { CreateListFormData } from '~/components/questions/CreateListDialog'; import type { CreateListFormData } from '~/components/questions/CreateListDialog';
import CreateListDialog from '~/components/questions/CreateListDialog'; import CreateListDialog from '~/components/questions/CreateListDialog';
import DeleteListDialog from '~/components/questions/DeleteListDialog'; import DeleteListDialog from '~/components/questions/DeleteListDialog';
import { Button } from '~/../../../packages/ui/dist';
import { APP_TITLE } from '~/utils/questions/constants'; import { APP_TITLE } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug'; import createSlug from '~/utils/questions/createSlug';
import {
useCreateListAsync,
useDeleteListAsync,
} from '~/utils/questions/mutations';
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates'; import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback'; import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
@ -22,24 +27,10 @@ import { trpc } from '~/utils/trpc';
export default function ListPage() { export default function ListPage() {
const utils = trpc.useContext(); const utils = trpc.useContext();
const { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']); const { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']);
const { mutateAsync: createList } = trpc.useMutation(
'questions.lists.create', const createListAsync = useCreateListAsync();
{ const deleteListAsync = useDeleteListAsync();
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 { mutateAsync: deleteQuestionEntry } = trpc.useMutation( const { mutateAsync: deleteQuestionEntry } = trpc.useMutation(
'questions.lists.deleteQuestionEntry', 'questions.lists.deleteQuestionEntry',
{ {
@ -57,7 +48,7 @@ export default function ListPage() {
const [listIdToDelete, setListIdToDelete] = useState(''); const [listIdToDelete, setListIdToDelete] = useState('');
const handleDeleteList = async (listId: string) => { const handleDeleteList = async (listId: string) => {
await deleteList({ await deleteListAsync({
id: listId, id: listId,
}); });
setShowDeleteListDialog(false); setShowDeleteListDialog(false);
@ -68,7 +59,7 @@ export default function ListPage() {
}; };
const handleCreateList = async (data: CreateListFormData) => { const handleCreateList = async (data: CreateListFormData) => {
await createList({ await createListAsync({
name: data.name, name: data.name,
}); });
setShowCreateListDialog(false); setShowCreateListDialog(false);
@ -92,7 +83,7 @@ export default function ListPage() {
selectedListIndex === index ? 'bg-primary-100' : '' selectedListIndex === index ? 'bg-primary-100' : ''
}`}> }`}>
<button <button
className="flex w-full flex-1 justify-between " className="flex w-full flex-1 justify-between"
type="button" type="button"
onClick={() => { onClick={() => {
setSelectedListIndex(index); setSelectedListIndex(index);
@ -145,17 +136,7 @@ export default function ListPage() {
</> </>
); );
return ( const createButton = (
<>
<Head>
<title>My Lists - {APP_TITLE}</title>
</Head>
<main className="flex 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">
<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 <Button
icon={PlusIcon} icon={PlusIcon}
isLabelHidden={true} isLabelHidden={true}
@ -168,13 +149,56 @@ export default function ListPage() {
handleAddClick(); handleAddClick();
}} }}
/> />
</div> );
return (
<>
<Head>
<title>My Lists - {APP_TITLE}</title>
</Head>
<main className="flex h-[calc(100vh_-_64px)] flex-1 flex-col items-stretch">
<div className="flex h-full flex-1">
<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">{createButton}</div>
</div> </div>
{listOptions} {listOptions}
</aside> </aside>
<section className="flex min-h-0 flex-1 flex-col items-center overflow-auto"> <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 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 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] && ( {lists?.[selectedListIndex] && (
<div className="flex flex-col gap-4 pb-4"> <div className="flex flex-col gap-4 pb-4">
{lists[selectedListIndex].questionEntries.map( {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 = { export const SAMPLE_QUESTION = {
answerCount: 10, answerCount: 10,
commentCount: 10, commentCount: 10,

@ -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 */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { useCallback } from 'react'; 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 { trpc } from '../trpc';
import type { Question } from '~/types/questions';
type UseVoteOptions = { type UseVoteOptions = {
setDownVote: () => void; setDownVote: () => void;
setNoVote: () => void; setNoVote: () => void;
@ -46,12 +49,78 @@ type MutationKey = Parameters<typeof trpc.useMutation>[0];
type QueryKey = Parameters<typeof trpc.useQuery>[0][0]; type QueryKey = Parameters<typeof trpc.useQuery>[0][0];
export const useQuestionVote = (id: string) => { export const useQuestionVote = (id: string) => {
const utils = trpc.useContext();
return useVote(id, { return useVote(id, {
idKey: 'questionId', idKey: 'questionId',
invalidateKeys: [ 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', query: 'questions.questions.user.getVote',
setDownVoteKey: 'questions.questions.user.setDownVote', setDownVoteKey: 'questions.questions.user.setDownVote',
setNoVoteKey: 'questions.questions.user.setNoVote', setNoVoteKey: 'questions.questions.user.setNoVote',
@ -63,8 +132,8 @@ export const useAnswerVote = (id: string) => {
return useVote(id, { return useVote(id, {
idKey: 'answerId', idKey: 'answerId',
invalidateKeys: [ invalidateKeys: [
'questions.answers.getAnswers',
'questions.answers.getAnswerById', 'questions.answers.getAnswerById',
'questions.answers.getAnswers',
], ],
query: 'questions.answers.user.getVote', query: 'questions.answers.user.getVote',
setDownVoteKey: 'questions.answers.user.setDownVote', 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> = { type VoteProps<VoteQueryKey extends QueryKey = QueryKey> = {
idKey: string; idKey: string;
invalidateKeys: Array<VoteQueryKey>; invalidateKeys: Array<QueryKey>;
onMutate?: InvalidateFunction;
// Invalidate: Partial<Record<QueryKey, InvalidateFunction | null>>;
query: VoteQueryKey; query: VoteQueryKey;
setDownVoteKey: MutationKey; setDownVoteKey: MutationKey;
setNoVoteKey: MutationKey; setNoVoteKey: MutationKey;
@ -116,6 +193,7 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const { const {
idKey, idKey,
invalidateKeys, invalidateKeys,
onMutate,
query, query,
setDownVoteKey, setDownVoteKey,
setNoVoteKey, setNoVoteKey,
@ -125,11 +203,16 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const onVoteUpdate = useCallback(() => { const onVoteUpdate = useCallback(() => {
// TODO: Optimise query invalidation // TODO: Optimise query invalidation
utils.invalidateQueries([query, { [idKey]: id } as any]); // utils.invalidateQueries([query, { [idKey]: id } as any]);
for (const invalidateKey of invalidateKeys) { 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([ const { data } = trpc.useQuery([
query, query,
@ -143,7 +226,7 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const { mutate: setUpVote } = trpc.useMutation<any, UseVoteMutationContext>( const { mutate: setUpVote } = trpc.useMutation<any, UseVoteMutationContext>(
setUpVoteKey, setUpVoteKey,
{ {
onError: (err, variables, context) => { onError: (_error, _variables, context) => {
if (context !== undefined) { if (context !== undefined) {
utils.setQueryData([query], context.previousData); utils.setQueryData([query], context.previousData);
} }
@ -154,6 +237,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
[query, { [idKey]: id } as any], [query, { [idKey]: id } as any],
); );
const currentData = {
...(vote as any),
vote: Vote.UPVOTE,
} as BackendVote;
utils.setQueryData( utils.setQueryData(
[ [
query, query,
@ -161,9 +249,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
[idKey]: id, [idKey]: id,
} as any, } 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, onSettled: onVoteUpdate,
}, },
@ -171,7 +261,7 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const { mutate: setDownVote } = trpc.useMutation<any, UseVoteMutationContext>( const { mutate: setDownVote } = trpc.useMutation<any, UseVoteMutationContext>(
setDownVoteKey, setDownVoteKey,
{ {
onError: (error, variables, context) => { onError: (_error, _variables, context) => {
if (context !== undefined) { if (context !== undefined) {
utils.setQueryData([query], context.previousData); utils.setQueryData([query], context.previousData);
} }
@ -182,6 +272,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
[query, { [idKey]: id } as any], [query, { [idKey]: id } as any],
); );
const currentData = {
...vote,
vote: Vote.DOWNVOTE,
} as BackendVote;
utils.setQueryData( utils.setQueryData(
[ [
query, query,
@ -189,9 +284,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
[idKey]: id, [idKey]: id,
} as any, } as any,
], ],
vote, currentData as any,
); );
return { currentData: vote, previousData };
await onMutate?.(previousData?.vote ?? null, currentData?.vote ?? null);
return { currentData, previousData };
}, },
onSettled: onVoteUpdate, onSettled: onVoteUpdate,
}, },
@ -200,23 +297,31 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const { mutate: setNoVote } = trpc.useMutation<any, UseVoteMutationContext>( const { mutate: setNoVote } = trpc.useMutation<any, UseVoteMutationContext>(
setNoVoteKey, setNoVoteKey,
{ {
onError: (err, variables, context) => { onError: (_error, _variables, context) => {
if (context !== undefined) { if (context !== undefined) {
utils.setQueryData([query], context.previousData); utils.setQueryData([query], context.previousData);
} }
}, },
onMutate: async (vote) => { onMutate: async () => {
await utils.queryClient.cancelQueries([query, { [idKey]: id } as any]); 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, query,
{ {
[idKey]: id, [idKey]: id,
} as any, } as any,
], ],
null as any, currentData,
); );
return { currentData: null, previousData: vote };
await onMutate?.(previousData?.vote ?? null, null);
return { currentData, previousData };
}, },
onSettled: onVoteUpdate, onSettled: onVoteUpdate,
}, },

Loading…
Cancel
Save