[questions][feat] Add question lists (#438)

Co-authored-by: wlren <weilinwork99@gmail.com>
This commit is contained in:
Jeff Sieu
2022-10-26 18:53:09 +08:00
committed by GitHub
parent 839eb31d65
commit 87aa16929b
20 changed files with 815 additions and 441 deletions

View File

@@ -0,0 +1,160 @@
import clsx from 'clsx';
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 { trpc } from '~/utils/trpc';
export type AddToListDropdownProps = {
questionId: string;
};
export default function AddToListDropdown({
questionId,
}: AddToListDropdownProps) {
const [menuOpened, setMenuOpened] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const utils = trpc.useContext();
const { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']);
const listsWithQuestionData = useMemo(() => {
return lists?.map((list) => ({
...list,
hasQuestion: list.questionEntries.some(
(entry) => entry.question.id === questionId,
),
}));
}, [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 addClickOutsideListener = () => {
document.addEventListener('click', handleClickOutside, true);
};
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
setMenuOpened(false);
document.removeEventListener('click', handleClickOutside, true);
}
};
const handleAddToList = async (listId: string) => {
await addQuestionToList({
listId,
questionId,
});
};
const handleDeleteFromList = async (listId: string) => {
const list = listsWithQuestionData?.find(
(listWithQuestion) => listWithQuestion.id === listId,
);
if (!list) {
return;
}
const entry = list.questionEntries.find(
(questionEntry) => questionEntry.question.id === questionId,
);
if (!entry) {
return;
}
await removeQuestionFromList({
id: entry.id,
});
};
const CustomMenuButton = ({ children }: PropsWithChildren<unknown>) => (
<button
className="focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-100"
type="button"
onClick={() => {
addClickOutsideListener();
setMenuOpened(!menuOpened);
}}>
{children}
</button>
);
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);
}
}}>
{list.hasQuestion && (
<CheckIcon
aria-hidden="true"
className="mr-3 h-5 w-5 text-slate-400 group-hover:text-slate-500"
/>
)}
{list.name}
</button>
)}
</Menu.Item>
</div>
))}
</>
)}
</Menu.Items>
</Transition>
</Menu>
);
}

View File

@@ -61,7 +61,7 @@ export default function ContributeQuestionDialog({
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<Dialog.Panel className="relative w-full max-w-5xl transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8">
<div className="bg-white p-6 pt-5 sm:pb-4">
<div className="bg-white px-6 pt-5">
<div className="flex flex-1 items-stretch">
<div className="mt-3 w-full sm:mt-0 sm:text-left">
<Dialog.Title

View File

@@ -0,0 +1,75 @@
import { useForm } from 'react-hook-form';
import { Button, Dialog, TextInput } from '@tih/ui';
import { useFormRegister } from '~/utils/questions/useFormRegister';
export type CreateListFormData = {
name: string;
};
export type CreateListDialogProps = {
onCancel: () => void;
onSubmit: (data: CreateListFormData) => Promise<void>;
show: boolean;
};
export default function CreateListDialog({
show,
onCancel,
onSubmit,
}: CreateListDialogProps) {
const {
register: formRegister,
handleSubmit,
formState: { isSubmitting },
reset,
} = useForm<CreateListFormData>();
const register = useFormRegister(formRegister);
const handleDialogCancel = () => {
onCancel();
reset();
};
return (
<Dialog
isShown={show}
primaryButton={undefined}
title="Create question list"
onClose={handleDialogCancel}>
<form
className="mt-5 gap-2 sm:flex sm:items-center"
onSubmit={handleSubmit(async (data) => {
await onSubmit(data);
reset();
})}>
<div className="w-full sm:max-w-xs">
<TextInput
id="listName"
isLabelHidden={true}
{...register('name')}
autoComplete="off"
label="Name"
placeholder="List name"
type="text"
/>
</div>
<Button
display="inline"
label="Cancel"
size="md"
variant="tertiary"
onClick={handleDialogCancel}
/>
<Button
display="inline"
isLoading={isSubmitting}
label="Create"
size="md"
type="submit"
variant="primary"
/>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,29 @@
import { Button, Dialog } from '@tih/ui';
export type DeleteListDialogProps = {
onCancel: () => void;
onDelete: () => void;
show: boolean;
};
export default function DeleteListDialog({
show,
onCancel,
onDelete,
}: DeleteListDialogProps) {
return (
<Dialog
isShown={show}
primaryButton={
<Button label="Delete" variant="primary" onClick={onDelete} />
}
secondaryButton={
<Button label="Cancel" variant="tertiary" onClick={onCancel} />
}
title="Delete List"
onClose={onCancel}>
<p>
Are you sure you want to delete this list? This action cannot be undone.
</p>
</Dialog>
);
}

View File

@@ -118,7 +118,7 @@ export default function LandingComponent({
onClick={() => {
if (company !== undefined && location !== undefined) {
return handleLandingQuery({
company: company.value,
company: company.label,
location: location.value,
questionType,
});

View File

@@ -3,8 +3,8 @@ import type { ProductNavigationItems } from '~/components/global/ProductNavigati
const navigation: ProductNavigationItems = [
{ href: '/questions/browse', name: 'Browse' },
{ href: '/questions/lists', name: 'My Lists' },
{ href: '/questions/my-questions', name: 'My Questions' },
{ href: '/questions/history', name: 'History' },
// { href: '/questions/my-questions', name: 'My Questions' },
// { href: '/questions/history', name: 'History' },
];
const config = {

View File

@@ -11,6 +11,7 @@ import { Button } from '@tih/ui';
import { useQuestionVote } from '~/utils/questions/useVote';
import AddToListDropdown from '../../AddToListDropdown';
import type { CreateQuestionEncounterData } from '../../forms/CreateQuestionEncounterForm';
import CreateQuestionEncounterForm from '../../forms/CreateQuestionEncounterForm';
import QuestionAggregateBadge from '../../QuestionAggregateBadge';
@@ -47,6 +48,20 @@ type AnswerStatisticsProps =
showAnswerStatistics?: false;
};
type AggregateStatisticsProps =
| {
companies: Record<string, number>;
locations: Record<string, number>;
roles: Record<string, number>;
showAggregateStatistics: true;
}
| {
companies?: never;
locations?: never;
roles?: never;
showAggregateStatistics?: false;
};
type ActionButtonProps =
| {
actionButtonLabel: string;
@@ -79,19 +94,26 @@ type CreateEncounterProps =
showCreateEncounterButton?: false;
};
type AddToListProps =
| {
showAddToList: true;
}
| {
showAddToList?: false;
};
export type BaseQuestionCardProps = ActionButtonProps &
AddToListProps &
AggregateStatisticsProps &
AnswerStatisticsProps &
CreateEncounterProps &
DeleteProps &
ReceivedStatisticsProps &
UpvoteProps & {
companies: Record<string, number>;
content: string;
locations: Record<string, number>;
questionId: string;
roles: Record<string, number>;
showHover?: boolean;
timestamp: string;
timestamp: string | null;
truncateContent?: boolean;
type: QuestionsQuestionType;
};
@@ -104,6 +126,7 @@ export default function BaseQuestionCard({
receivedCount,
type,
showVoteButtons,
showAggregateStatistics,
showAnswerStatistics,
showReceivedStatistics,
showCreateEncounterButton,
@@ -117,6 +140,7 @@ export default function BaseQuestionCard({
showHover,
onReceivedSubmit,
showDeleteButton,
showAddToList,
onDelete,
truncateContent = true,
}: BaseQuestionCardProps) {
@@ -133,20 +157,35 @@ export default function BaseQuestionCard({
onUpvote={handleUpvote}
/>
)}
<div className="flex flex-col items-start gap-2">
<div className="flex items-baseline justify-between">
<div className="flex items-baseline gap-2 text-slate-500">
<QuestionTypeBadge type={type} />
<QuestionAggregateBadge statistics={companies} variant="primary" />
<QuestionAggregateBadge statistics={locations} variant="success" />
<QuestionAggregateBadge statistics={roles} variant="danger" />
<p className="text-xs">{timestamp}</p>
<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">
{showAggregateStatistics && (
<>
<QuestionTypeBadge type={type} />
<QuestionAggregateBadge
statistics={companies}
variant="primary"
/>
<QuestionAggregateBadge
statistics={locations}
variant="success"
/>
<QuestionAggregateBadge statistics={roles} variant="danger" />
</>
)}
{timestamp !== null && <p className="text-xs">{timestamp}</p>}
{showAddToList && (
<div className="pl-4">
<AddToListDropdown questionId={questionId} />
</div>
)}
</div>
{showActionButton && (
<Button
label={actionButtonLabel}
size="sm"
variant="tertiary"
variant="secondary"
onClick={onActionButtonClick}
/>
)}

View File

@@ -4,6 +4,8 @@ import BaseQuestionCard from './BaseQuestionCard';
export type QuestionOverviewCardProps = Omit<
BaseQuestionCardProps & {
showActionButton: false;
showAddToList: true;
showAggregateStatistics: true;
showAnswerStatistics: false;
showCreateEncounterButton: true;
showDeleteButton: false;
@@ -13,6 +15,8 @@ export type QuestionOverviewCardProps = Omit<
| 'actionButtonLabel'
| 'onActionButtonClick'
| 'showActionButton'
| 'showAddToList'
| 'showAggregateStatistics'
| 'showAnswerStatistics'
| 'showCreateEncounterButton'
| 'showDeleteButton'
@@ -25,6 +29,8 @@ export default function FullQuestionCard(props: QuestionOverviewCardProps) {
<BaseQuestionCard
{...props}
showActionButton={false}
showAddToList={true}
showAggregateStatistics={true}
showAnswerStatistics={false}
showCreateEncounterButton={true}
showReceivedStatistics={false}

View File

@@ -6,6 +6,7 @@ import BaseQuestionCard from './BaseQuestionCard';
export type QuestionListCardProps = Omit<
BaseQuestionCardProps & {
showActionButton: false;
showAggregateStatistics: true;
showAnswerStatistics: false;
showDeleteButton: true;
showVoteButtons: false;
@@ -13,6 +14,7 @@ export type QuestionListCardProps = Omit<
| 'actionButtonLabel'
| 'onActionButtonClick'
| 'showActionButton'
| 'showAggregateStatistics'
| 'showAnswerStatistics'
| 'showDeleteButton'
| 'showVoteButtons'
@@ -24,6 +26,7 @@ function QuestionListCardWithoutHref(props: QuestionListCardProps) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{...(props as any)}
showActionButton={false}
showAggregateStatistics={true}
showAnswerStatistics={false}
showDeleteButton={true}
showHover={true}

View File

@@ -6,6 +6,7 @@ import BaseQuestionCard from './BaseQuestionCard';
export type QuestionOverviewCardProps = Omit<
BaseQuestionCardProps & {
showActionButton: false;
showAggregateStatistics: true;
showAnswerStatistics: true;
showCreateEncounterButton: false;
showDeleteButton: false;
@@ -16,6 +17,7 @@ export type QuestionOverviewCardProps = Omit<
| 'onActionButtonClick'
| 'onDelete'
| 'showActionButton'
| 'showAggregateStatistics'
| 'showAnswerStatistics'
| 'showCreateEncounterButton'
| 'showDeleteButton'
@@ -28,6 +30,7 @@ function QuestionOverviewCardWithoutHref(props: QuestionOverviewCardProps) {
<BaseQuestionCard
{...props}
showActionButton={false}
showAggregateStatistics={true}
showAnswerStatistics={true}
showCreateEncounterButton={false}
showDeleteButton={false}

View File

@@ -4,7 +4,8 @@ import BaseQuestionCard from './BaseQuestionCard';
export type SimilarQuestionCardProps = Omit<
BaseQuestionCardProps & {
showActionButton: true;
showAnswerStatistics: true;
showAggregateStatistics: false;
showAnswerStatistics: false;
showCreateEncounterButton: false;
showDeleteButton: false;
showHover: true;
@@ -14,6 +15,7 @@ export type SimilarQuestionCardProps = Omit<
| 'actionButtonLabel'
| 'onActionButtonClick'
| 'showActionButton'
| 'showAggregateStatistics'
| 'showAnswerStatistics'
| 'showCreateEncounterButton'
| 'showDeleteButton'
@@ -30,12 +32,13 @@ export default function SimilarQuestionCard(props: SimilarQuestionCardProps) {
<BaseQuestionCard
actionButtonLabel="Yes, this is my question"
showActionButton={true}
showAnswerStatistics={true}
showAggregateStatistics={false}
showAnswerStatistics={false}
showCreateEncounterButton={false}
showDeleteButton={false}
showHover={true}
showReceivedStatistics={true}
showVoteButtons={true}
showReceivedStatistics={false}
showVoteButtons={false}
onActionButtonClick={onSimilarQuestionClick}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{...(rest as any)}

View File

@@ -1,14 +1,7 @@
import { startOfMonth } from 'date-fns';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import type { QuestionsQuestionType } from '@prisma/client';
import {
Button,
CheckboxInput,
HorizontalDivider,
Select,
TextArea,
} from '@tih/ui';
import { Button, HorizontalDivider, Select, TextArea } from '@tih/ui';
import { LOCATIONS, QUESTION_TYPES, ROLES } from '~/utils/questions/constants';
import {
@@ -50,155 +43,130 @@ export default function ContributeQuestionForm({
date: startOfMonth(new Date()),
},
});
const register = useFormRegister(formRegister);
const selectRegister = useSelectRegister(formRegister);
const [canSubmit, setCanSubmit] = useState<boolean>(false);
const handleCheckSimilarQuestions = (checked: boolean) => {
setCanSubmit(checked);
};
return (
<form
className="flex flex-1 flex-col items-stretch justify-center gap-y-4"
onSubmit={handleSubmit(onSubmit)}>
<div className="min-w-[113px] max-w-[113px] flex-1">
<Select
defaultValue="coding"
label="Type"
options={QUESTION_TYPES}
<div className="flex flex-col justify-between gap-4">
<form
className="flex flex-1 flex-col items-stretch justify-center gap-y-4"
onSubmit={handleSubmit(onSubmit)}>
<div className="min-w-[113px] max-w-[113px] flex-1">
<Select
defaultValue="coding"
label="Type"
options={QUESTION_TYPES}
required={true}
{...selectRegister('questionType')}
/>
</div>
<TextArea
label="Question Prompt"
placeholder="Contribute a question"
required={true}
{...selectRegister('questionType')}
rows={5}
{...register('questionContent')}
/>
</div>
<TextArea
label="Question Prompt"
placeholder="Contribute a question"
required={true}
rows={5}
{...register('questionContent')}
/>
<HorizontalDivider />
<h2 className="text-md text-primary-800 font-semibold">
Additional information
</h2>
<div className="flex flex-col flex-wrap items-stretch gap-2 sm:flex-row sm:items-end">
<div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]">
<Controller
control={control}
name="location"
render={({ field }) => (
<LocationTypeahead
required={true}
onSelect={(option) => {
field.onChange(option.value);
}}
{...field}
value={LOCATIONS.find(
(location) => location.value === field.value,
)}
/>
)}
/>
</div>
<div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]">
<Controller
control={control}
name="date"
render={({ field }) => (
<MonthYearPicker
monthRequired={true}
value={{
month: ((field.value.getMonth() as number) + 1) as Month,
year: field.value.getFullYear(),
}}
yearRequired={true}
onChange={({ month, year }) =>
field.onChange(startOfMonth(new Date(year, month - 1)))
}
/>
)}
/>
</div>
</div>
<div className="flex flex-col flex-wrap items-stretch gap-2 sm:flex-row sm:items-end">
<div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]">
<Controller
control={control}
name="company"
render={({ field }) => (
<CompanyTypeahead
required={true}
onSelect={({ id }) => {
field.onChange(id);
}}
/>
)}
/>
</div>
<div className="flex-1 sm:min-w-[150px] sm:max-w-[200px]">
<Controller
control={control}
name="role"
render={({ field }) => (
<RoleTypeahead
required={true}
onSelect={(option) => {
field.onChange(option.value);
}}
{...field}
value={ROLES.find((role) => role.value === field.value)}
/>
)}
/>
</div>
</div>
{/* <div className="w-full">
<HorizontalDivider />
</div>
<h1 className="mb-3">
Are these questions the same as yours? TODO:Change to list
</h1>
<div>
<SimilarQuestionCard
content="Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices"
location="Menlo Park, CA"
receivedCount={0}
role="Senior Engineering Manager"
timestamp="Today"
onSimilarQuestionClick={() => {
// eslint-disable-next-line no-console
console.log('hi!');
}}
/>
</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"
style={{
// Hack to make the background bleed outside the container
clipPath: 'inset(0 -100vmax)',
}}>
<div className="my-2 flex sm:my-0">
<CheckboxInput
label="I have checked that my question is new"
value={canSubmit}
onChange={handleCheckSimilarQuestions}
/>
<h2 className="text-md text-primary-800 font-semibold">
Additional information
</h2>
<div className="flex flex-col flex-wrap items-stretch gap-2 sm:flex-row sm:items-end">
<div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]">
<Controller
control={control}
name="location"
render={({ field }) => (
<LocationTypeahead
required={true}
onSelect={(option) => {
field.onChange(option.value);
}}
{...field}
value={LOCATIONS.find(
(location) => location.value === field.value,
)}
/>
)}
/>
</div>
<div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]">
<Controller
control={control}
name="date"
render={({ field }) => (
<MonthYearPicker
monthRequired={true}
value={{
month: ((field.value.getMonth() as number) + 1) as Month,
year: field.value.getFullYear(),
}}
yearRequired={true}
onChange={({ month, year }) =>
field.onChange(startOfMonth(new Date(year, month - 1)))
}
/>
)}
/>
</div>
</div>
<div className="flex gap-x-2">
<button
className="focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-base font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
type="button"
onClick={onDiscard}>
Discard
</button>
<Button
className="bg-primary-600 hover:bg-primary-700 focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-transparent px-4 py-2 text-base font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:bg-slate-400 sm:ml-3 sm:w-auto sm:text-sm"
disabled={!canSubmit}
label="Contribute"
type="submit"
variant="primary"></Button>
<div className="flex flex-col flex-wrap items-stretch gap-2 sm:flex-row sm:items-end">
<div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]">
<Controller
control={control}
name="company"
render={({ field }) => (
<CompanyTypeahead
required={true}
onSelect={({ id }) => {
field.onChange(id);
}}
/>
)}
/>
</div>
<div className="flex-1 sm:min-w-[150px] sm:max-w-[200px]">
<Controller
control={control}
name="role"
render={({ field }) => (
<RoleTypeahead
required={true}
onSelect={(option) => {
field.onChange(option.value);
}}
{...field}
value={ROLES.find((role) => role.value === field.value)}
/>
)}
/>
</div>
</div>
</div>
</form>
<div className="w-full">
<HorizontalDivider />
</div>
<div
className="bg-primary-50 flex w-full justify-end gap-y-2 py-3 shadow-[0_0_0_100vmax_theme(colors.primary.50)]"
style={{
// Hack to make the background bleed outside the container
clipPath: 'inset(0 -100vmax)',
}}>
<div className="flex gap-x-2">
<button
className="focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-base font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
type="button"
onClick={onDiscard}>
Discard
</button>
<Button
className="bg-primary-600 hover:bg-primary-700 focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-transparent px-4 py-2 text-base font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:bg-slate-400 sm:ml-3 sm:w-auto sm:text-sm"
label="Contribute"
type="submit"
variant="primary"></Button>
</div>
</div>
</form>
</div>
);
}

View File

@@ -9,7 +9,6 @@ import { Button, SlideOut } from '@tih/ui';
import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard';
import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
import type { FilterOption } from '~/components/questions/filter/FilterSection';
import FilterSection from '~/components/questions/filter/FilterSection';
import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
import CompanyTypeahead from '~/components/questions/typeahead/CompanyTypeahead';
@@ -195,18 +194,6 @@ export default function QuestionsBrowsePage() {
const [loaded, setLoaded] = useState(false);
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const [selectedCompanyOptions, setSelectedCompanyOptions] = useState<
Array<FilterOption>
>([]);
const [selectedRoleOptions, setSelectedRoleOptions] = useState<
Array<FilterOption>
>([]);
const [selectedLocationOptions, setSelectedLocationOptions] = useState<
Array<FilterOption>
>([]);
const questionTypeFilterOptions = useMemo(() => {
return QUESTION_TYPES.map((questionType) => ({
...questionType,
@@ -275,9 +262,37 @@ export default function QuestionsBrowsePage() {
sortType,
]);
const selectedCompanyOptions = useMemo(() => {
return selectedCompanies.map((company) => ({
checked: true,
id: company,
label: company,
value: company,
}));
}, [selectedCompanies]);
const selectedRoleOptions = useMemo(() => {
return selectedRoles.map((role) => ({
checked: true,
id: role,
label: role,
value: role,
}));
}, [selectedRoles]);
const selectedLocationOptions = useMemo(() => {
return selectedLocations.map((location) => ({
checked: true,
id: location,
label: location,
value: location,
}));
}, [selectedLocations]);
if (!loaded) {
return null;
}
const filterSidebar = (
<div className="divide-y divide-slate-200 px-4">
<Button
@@ -293,9 +308,6 @@ export default function QuestionsBrowsePage() {
setSelectedQuestionAge('all');
setSelectedRoles([]);
setSelectedLocations([]);
setSelectedCompanyOptions([]);
setSelectedRoleOptions([]);
setSelectedLocationOptions([]);
}}
/>
<FilterSection
@@ -306,8 +318,8 @@ export default function QuestionsBrowsePage() {
{...field}
clearOnSelect={true}
filterOption={(option) => {
return !selectedCompanyOptions.some((selectedOption) => {
return selectedOption.value === option.value;
return !selectedCompanies.some((company) => {
return company === option.value;
});
}}
isLabelHidden={true}
@@ -323,19 +335,10 @@ export default function QuestionsBrowsePage() {
onOptionChange={(option) => {
if (option.checked) {
setSelectedCompanies([...selectedCompanies, option.label]);
setSelectedCompanyOptions((prevOptions) => [
...prevOptions,
{ ...option, checked: true },
]);
} else {
setSelectedCompanies(
selectedCompanies.filter((company) => company !== option.label),
);
setSelectedCompanyOptions((prevOptions) =>
prevOptions.filter(
(prevOption) => prevOption.label !== option.label,
),
);
}
}}
/>
@@ -347,8 +350,8 @@ export default function QuestionsBrowsePage() {
{...field}
clearOnSelect={true}
filterOption={(option) => {
return !selectedRoleOptions.some((selectedOption) => {
return selectedOption.value === option.value;
return !selectedRoles.some((role) => {
return role === option.value;
});
}}
isLabelHidden={true}
@@ -364,19 +367,10 @@ export default function QuestionsBrowsePage() {
onOptionChange={(option) => {
if (option.checked) {
setSelectedRoles([...selectedRoles, option.value]);
setSelectedRoleOptions((prevOptions) => [
...prevOptions,
{ ...option, checked: true },
]);
} else {
setSelectedRoles(
selectedCompanies.filter((role) => role !== option.value),
);
setSelectedRoleOptions((prevOptions) =>
prevOptions.filter(
(prevOption) => prevOption.value !== option.value,
),
);
}
}}
/>
@@ -413,8 +407,8 @@ export default function QuestionsBrowsePage() {
{...field}
clearOnSelect={true}
filterOption={(option) => {
return !selectedLocationOptions.some((selectedOption) => {
return selectedOption.value === option.value;
return !selectedLocations.some((location) => {
return location === option.value;
});
}}
isLabelHidden={true}
@@ -430,19 +424,10 @@ export default function QuestionsBrowsePage() {
onOptionChange={(option) => {
if (option.checked) {
setSelectedLocations([...selectedLocations, option.value]);
setSelectedLocationOptions((prevOptions) => [
...prevOptions,
{ ...option, checked: true },
]);
} else {
setSelectedLocations(
selectedLocations.filter((role) => role !== option.value),
);
setSelectedLocationOptions((prevOptions) =>
prevOptions.filter(
(prevOption) => prevOption.value !== option.value,
),
);
}
}}
/>

View File

@@ -8,60 +8,90 @@ import {
} from '@heroicons/react/24/outline';
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 { SAMPLE_QUESTION } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
import { trpc } from '~/utils/trpc';
export default function ListPage() {
const questions = [
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
];
const lists = [
{ id: 1, name: 'list 1', questions },
{ id: 2, name: 'list 2', questions },
{ id: 3, name: 'list 3', questions },
{ id: 4, name: 'list 4', questions },
{ id: 5, name: 'list 5', questions },
];
const [selectedList, setSelectedList] = useState(
(lists ?? []).length > 0 ? lists[0].id : '',
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 { mutateAsync: deleteQuestionEntry } = trpc.useMutation(
'questions.lists.deleteQuestionEntry',
{
onSuccess: () => {
// TODO: Add optimistic update
utils.invalidateQueries(['questions.lists.getListsByUser']);
},
},
);
const [selectedListIndex, setSelectedListIndex] = useState(0);
const [showDeleteListDialog, setShowDeleteListDialog] = useState(false);
const [showCreateListDialog, setShowCreateListDialog] = useState(false);
const [listIdToDelete, setListIdToDelete] = useState('');
const handleDeleteList = async (listId: string) => {
await deleteList({
id: listId,
});
setShowDeleteListDialog(false);
};
const handleDeleteListCancel = () => {
setShowDeleteListDialog(false);
};
const handleCreateList = async (data: CreateListFormData) => {
await createList({
name: data.name,
});
setShowCreateListDialog(false);
};
const handleCreateListCancel = () => {
setShowCreateListDialog(false);
};
const listOptions = (
<>
<ul className="flex flex-1 flex-col divide-y divide-solid divide-slate-200">
{lists.map((list) => (
{(lists ?? []).map((list, index) => (
<li
key={list.id}
className={`flex items-center hover:bg-slate-50 ${
selectedList === list.id ? 'bg-primary-100' : ''
selectedListIndex === index ? 'bg-primary-100' : ''
}`}>
<button
className="flex w-full flex-1 justify-between "
type="button"
onClick={() => {
setSelectedList(list.id);
// eslint-disable-next-line no-console
console.log(selectedList);
setSelectedListIndex(index);
}}>
<p className="text-primary-700 text-md p-3 font-medium">
<p className="text-primary-700 text-md p-3 pl-6 font-medium">
{list.name}
</p>
</button>
@@ -85,7 +115,11 @@ export default function ListPage() {
? 'bg-violet-500 text-white'
: 'text-slate-900'
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
type="button">
type="button"
onClick={() => {
setShowDeleteListDialog(true);
setListIdToDelete(list.id);
}}>
Delete
</button>
)}
@@ -104,6 +138,7 @@ export default function ListPage() {
)}
</>
);
return (
<>
<Head>
@@ -111,7 +146,7 @@ export default function ListPage() {
</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-l bg-white py-4 lg:block">
<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">
@@ -124,6 +159,7 @@ export default function ListPage() {
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowCreateListDialog(true);
}}
/>
</div>
@@ -133,44 +169,63 @@ export default function ListPage() {
<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">
{selectedList && (
{lists?.[selectedListIndex] && (
<div className="flex flex-col gap-4 pb-4">
{(questions ?? []).map((question) => (
<QuestionListCard
key={question.id}
companies={question.companies}
content={question.content}
href={`/questions/${question.id}/${createSlug(
question.content,
)}`}
locations={question.locations}
questionId={question.id}
receivedCount={0}
roles={question.roles}
timestamp={question.seenAt.toLocaleDateString(
undefined,
{
month: 'short',
year: 'numeric',
},
)}
type={question.type}
onDelete={() => {
// eslint-disable-next-line no-console
console.log('delete');
}}
/>
))}
{questions?.length === 0 && (
{lists[selectedListIndex].questionEntries.map(
({ question, id: entryId }) => (
<QuestionListCard
key={question.id}
companies={
question.aggregatedQuestionEncounters.companyCounts
}
content={question.content}
href={`/questions/${question.id}/${createSlug(
question.content,
)}`}
locations={
question.aggregatedQuestionEncounters.locationCounts
}
questionId={question.id}
receivedCount={question.receivedCount}
roles={
question.aggregatedQuestionEncounters.roleCounts
}
timestamp={question.seenAt.toLocaleDateString(
undefined,
{
month: 'short',
year: 'numeric',
},
)}
type={question.type}
onDelete={() => {
deleteQuestionEntry({ id: entryId });
}}
/>
),
)}
{lists[selectedListIndex].questionEntries?.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>You have no added any questions to your list yet.</p>
<p>
You have not added any questions to your list yet.
</p>
</div>
)}
</div>
)}
</div>
</div>
<DeleteListDialog
show={showDeleteListDialog}
onCancel={handleDeleteListCancel}
onDelete={() => {
handleDeleteList(listIdToDelete);
}}></DeleteListDialog>
<CreateListDialog
show={showCreateListDialog}
onCancel={handleCreateListCancel}
onSubmit={handleCreateList}></CreateListDialog>
</section>
</div>
</main>

View File

@@ -9,6 +9,7 @@ import { offersProfileRouter } from './offers/offers-profile-router';
import { protectedExampleRouter } from './protected-example-router';
import { questionsAnswerCommentRouter } from './questions-answer-comment-router';
import { questionsAnswerRouter } from './questions-answer-router';
import { questionListRouter } from './questions-list-router';
import { questionsQuestionCommentRouter } from './questions-question-comment-router';
import { questionsQuestionEncounterRouter } from './questions-question-encounter-router';
import { questionsQuestionRouter } from './questions-question-router';
@@ -40,6 +41,7 @@ export const appRouter = createRouter()
.merge('resumes.comments.votes.user.', resumesCommentsVotesUserRouter)
.merge('questions.answers.comments.', questionsAnswerCommentRouter)
.merge('questions.answers.', questionsAnswerRouter)
.merge('questions.lists.', questionListRouter)
.merge('questions.questions.comments.', questionsQuestionCommentRouter)
.merge('questions.questions.encounters.', questionsQuestionEncounterRouter)
.merge('questions.questions.', questionsQuestionRouter)

View File

@@ -1,6 +1,8 @@
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import createQuestionWithAggregateData from '~/utils/questions/server/createQuestionWithAggregateData';
import { createProtectedRouter } from './context';
export const questionListRouter = createProtectedRouter()
@@ -8,11 +10,35 @@ export const questionListRouter = createProtectedRouter()
async resolve({ ctx }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsList.findMany({
// TODO: Optimize by not returning question entries
const questionsLists = await ctx.prisma.questionsList.findMany({
include: {
questionEntries: {
include: {
question: true,
question: {
include: {
_count: {
select: {
answers: true,
comments: true,
},
},
encounters: {
select: {
company: true,
location: true,
role: true,
seenAt: true,
},
},
user: {
select: {
name: true,
},
},
votes: true,
},
},
},
},
},
@@ -20,23 +46,57 @@ export const questionListRouter = createProtectedRouter()
createdAt: 'asc',
},
where: {
id: userId,
userId,
},
});
const lists = questionsLists.map((list) => ({
...list,
questionEntries: list.questionEntries.map((entry) => ({
...entry,
question: createQuestionWithAggregateData(entry.question),
})),
}));
return lists;
},
})
.query('getListById', {
input: z.object({
listId: z.string(),
}),
async resolve({ ctx }) {
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { listId } = input;
return await ctx.prisma.questionsList.findMany({
const questionList = await ctx.prisma.questionsList.findFirst({
include: {
questionEntries: {
include: {
question: true,
question: {
include: {
_count: {
select: {
answers: true,
comments: true,
},
},
encounters: {
select: {
company: true,
location: true,
role: true,
seenAt: true,
},
},
user: {
select: {
name: true,
},
},
votes: true,
},
},
},
},
},
@@ -44,9 +104,25 @@ export const questionListRouter = createProtectedRouter()
createdAt: 'asc',
},
where: {
id: userId,
id: listId,
userId,
},
});
if (!questionList) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Question list not found',
});
}
return {
...questionList,
questionEntries: questionList.questionEntries.map((questionEntry) => ({
...questionEntry,
question: createQuestionWithAggregateData(questionEntry.question),
})),
};
},
})
.mutation('create', {
@@ -111,7 +187,7 @@ export const questionListRouter = createProtectedRouter()
},
});
if (listToDelete?.id !== userId) {
if (listToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@@ -139,7 +215,7 @@ export const questionListRouter = createProtectedRouter()
},
});
if (listToAugment?.id !== userId) {
if (listToAugment?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@@ -170,10 +246,10 @@ export const questionListRouter = createProtectedRouter()
},
});
if (entryToDelete?.id !== userId) {
if (entryToDelete === null) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
code: 'NOT_FOUND',
message: 'Entry not found.',
});
}
@@ -183,7 +259,7 @@ export const questionListRouter = createProtectedRouter()
},
});
if (listToAugment?.id !== userId) {
if (listToAugment?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',

View File

@@ -35,17 +35,17 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 1;
companyCounts[encounter.company!.name] = 0;
}
companyCounts[encounter.company!.name] += 1;
if (!(encounter.location in locationCounts)) {
locationCounts[encounter.location] = 1;
locationCounts[encounter.location] = 0;
}
locationCounts[encounter.location] += 1;
if (!(encounter.role in roleCounts)) {
roleCounts[encounter.role] = 1;
roleCounts[encounter.role] = 0;
}
roleCounts[encounter.role] += 1;
}
@@ -93,7 +93,7 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
}
if (
!questionToUpdate.lastSeenAt ||
questionToUpdate.lastSeenAt === null ||
questionToUpdate.lastSeenAt < input.seenAt
) {
await tx.questionsQuestion.update({

View File

@@ -2,9 +2,10 @@ import { z } from 'zod';
import { QuestionsQuestionType, Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import createQuestionWithAggregateData from '~/utils/questions/server/createQuestionWithAggregateData';
import { createProtectedRouter } from './context';
import type { Question } from '~/types/questions';
import { SortOrder, SortType } from '~/types/questions.d';
export const questionsQuestionRouter = createProtectedRouter()
@@ -122,72 +123,9 @@ export const questionsQuestionRouter = createProtectedRouter()
},
});
const processedQuestionsData = questionsData.map((data) => {
const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0,
);
const companyCounts: Record<string, number> = {};
const locationCounts: Record<string, number> = {};
const roleCounts: Record<string, number> = {};
let latestSeenAt = data.encounters[0].seenAt;
for (let i = 0; i < data.encounters.length; i++) {
const encounter = data.encounters[i];
latestSeenAt =
latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 1;
}
companyCounts[encounter.company!.name] += 1;
if (!(encounter.location in locationCounts)) {
locationCounts[encounter.location] = 1;
}
locationCounts[encounter.location] += 1;
if (!(encounter.role in roleCounts)) {
roleCounts[encounter.role] = 1;
}
roleCounts[encounter.role] += 1;
}
const question: Question = {
aggregatedQuestionEncounters: {
companyCounts,
latestSeenAt,
locationCounts,
roleCounts,
},
content: data.content,
id: data.id,
numAnswers: data._count.answers,
numComments: data._count.comments,
numVotes: votes,
receivedCount: data.encounters.length,
seenAt: latestSeenAt,
type: data.questionType,
updatedAt: data.updatedAt,
user: data.user?.name ?? '',
};
return question;
});
const processedQuestionsData = questionsData.map(
createQuestionWithAggregateData,
);
let nextCursor: typeof cursor | undefined = undefined;
@@ -252,68 +190,8 @@ export const questionsQuestionRouter = createProtectedRouter()
message: 'Question not found',
});
}
const votes: number = questionData.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0,
);
const companyCounts: Record<string, number> = {};
const locationCounts: Record<string, number> = {};
const roleCounts: Record<string, number> = {};
let latestSeenAt = questionData.encounters[0].seenAt;
for (const encounter of questionData.encounters) {
latestSeenAt =
latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 1;
}
companyCounts[encounter.company!.name] += 1;
if (!(encounter.location in locationCounts)) {
locationCounts[encounter.location] = 1;
}
locationCounts[encounter.location] += 1;
if (!(encounter.role in roleCounts)) {
roleCounts[encounter.role] = 1;
}
roleCounts[encounter.role] += 1;
}
const question: Question = {
aggregatedQuestionEncounters: {
companyCounts,
latestSeenAt,
locationCounts,
roleCounts,
},
content: questionData.content,
id: questionData.id,
numAnswers: questionData._count.answers,
numComments: questionData._count.comments,
numVotes: votes,
receivedCount: questionData.encounters.length,
seenAt: questionData.encounters[0].seenAt,
type: questionData.questionType,
updatedAt: questionData.updatedAt,
user: questionData.user?.name ?? '',
};
return question;
return createQuestionWithAggregateData(questionData);
},
})
.mutation('create', {

View File

@@ -0,0 +1,92 @@
import type {
Company,
QuestionsQuestion,
QuestionsQuestionVote,
} from '@prisma/client';
import { Vote } from '@prisma/client';
import type { Question } from '~/types/questions';
type QuestionWithAggregatableData = QuestionsQuestion & {
_count: {
answers: number;
comments: number;
};
encounters: Array<{
company: Company | null;
location: string;
role: string;
seenAt: Date;
}>;
user: {
name: string | null;
} | null;
votes: Array<QuestionsQuestionVote>;
};
export default function createQuestionWithAggregateData(
data: QuestionWithAggregatableData,
): Question {
const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0,
);
const companyCounts: Record<string, number> = {};
const locationCounts: Record<string, number> = {};
const roleCounts: Record<string, number> = {};
let latestSeenAt = data.encounters[0].seenAt;
for (const encounter of data.encounters) {
latestSeenAt =
latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 0;
}
companyCounts[encounter.company!.name] += 1;
if (!(encounter.location in locationCounts)) {
locationCounts[encounter.location] = 0;
}
locationCounts[encounter.location] += 1;
if (!(encounter.role in roleCounts)) {
roleCounts[encounter.role] = 0;
}
roleCounts[encounter.role] += 1;
}
const question: Question = {
aggregatedQuestionEncounters: {
companyCounts,
latestSeenAt,
locationCounts,
roleCounts,
},
content: data.content,
id: data.id,
numAnswers: data._count.answers,
numComments: data._count.comments,
numVotes: votes,
receivedCount: data.encounters.length,
seenAt: data.encounters[0].seenAt,
type: data.questionType,
updatedAt: data.updatedAt,
user: data.user?.name ?? '',
};
return question;
}

View File

@@ -25,7 +25,7 @@ export const useSearchParam = <Value = string>(
const [isInitialized, setIsInitialized] = useState(false);
const router = useRouter();
const [filters, setFilters] = useState<Array<Value>>(defaultValues || []);
const [params, setParams] = useState<Array<Value>>(defaultValues || []);
useEffect(() => {
if (router.isReady && !isInitialized) {
@@ -33,7 +33,7 @@ export const useSearchParam = <Value = string>(
const query = router.query[name];
if (query) {
const queryValues = Array.isArray(query) ? query : [query];
setFilters(
setParams(
queryValues
.map(stringToParam)
.filter((value) => value !== null) as Array<Value>,
@@ -43,27 +43,27 @@ export const useSearchParam = <Value = string>(
const localStorageValue = localStorage.getItem(name);
if (localStorageValue !== null) {
const loadedFilters = JSON.parse(localStorageValue);
setFilters(loadedFilters);
setParams(loadedFilters);
}
}
setIsInitialized(true);
}
}, [isInitialized, name, stringToParam, router]);
const setFiltersCallback = useCallback(
(newFilters: Array<Value>) => {
setFilters(newFilters);
const setParamsCallback = useCallback(
(newParams: Array<Value>) => {
setParams(newParams);
localStorage.setItem(
name,
JSON.stringify(
newFilters.map(valueToQueryParam).filter((param) => param !== null),
newParams.map(valueToQueryParam).filter((param) => param !== null),
),
);
},
[name, valueToQueryParam],
);
return [filters, setFiltersCallback, isInitialized] as const;
return [params, setParamsCallback, isInitialized] as const;
};
export const useSearchParamSingle = <Value = string>(
@@ -73,14 +73,14 @@ export const useSearchParamSingle = <Value = string>(
},
) => {
const { defaultValue, ...restOpts } = opts ?? {};
const [filters, setFilters, isInitialized] = useSearchParam<Value>(name, {
const [params, setParams, isInitialized] = useSearchParam<Value>(name, {
defaultValues: defaultValue !== undefined ? [defaultValue] : undefined,
...restOpts,
} as SearchParamOptions<Value>);
return [
filters[0],
(value: Value) => setFilters([value]),
params[0],
(value: Value) => setParams([value]),
isInitialized,
] as const;
};