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

Co-authored-by: wlren <weilinwork99@gmail.com>
pull/439/head
Jeff Sieu 2 years ago committed by GitHub
parent 839eb31d65
commit 87aa16929b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

@ -61,7 +61,7 @@ export default function ContributeQuestionDialog({
leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"> 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"> <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="flex flex-1 items-stretch">
<div className="mt-3 w-full sm:mt-0 sm:text-left"> <div className="mt-3 w-full sm:mt-0 sm:text-left">
<Dialog.Title <Dialog.Title

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

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

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

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

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

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

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

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

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

@ -1,14 +1,7 @@
import { startOfMonth } from 'date-fns'; import { startOfMonth } from 'date-fns';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import type { QuestionsQuestionType } from '@prisma/client'; import type { QuestionsQuestionType } from '@prisma/client';
import { import { Button, HorizontalDivider, Select, TextArea } from '@tih/ui';
Button,
CheckboxInput,
HorizontalDivider,
Select,
TextArea,
} from '@tih/ui';
import { LOCATIONS, QUESTION_TYPES, ROLES } from '~/utils/questions/constants'; import { LOCATIONS, QUESTION_TYPES, ROLES } from '~/utils/questions/constants';
import { import {
@ -50,155 +43,130 @@ export default function ContributeQuestionForm({
date: startOfMonth(new Date()), date: startOfMonth(new Date()),
}, },
}); });
const register = useFormRegister(formRegister); const register = useFormRegister(formRegister);
const selectRegister = useSelectRegister(formRegister); const selectRegister = useSelectRegister(formRegister);
const [canSubmit, setCanSubmit] = useState<boolean>(false);
const handleCheckSimilarQuestions = (checked: boolean) => {
setCanSubmit(checked);
};
return ( return (
<form <div className="flex flex-col justify-between gap-4">
className="flex flex-1 flex-col items-stretch justify-center gap-y-4" <form
onSubmit={handleSubmit(onSubmit)}> className="flex flex-1 flex-col items-stretch justify-center gap-y-4"
<div className="min-w-[113px] max-w-[113px] flex-1"> onSubmit={handleSubmit(onSubmit)}>
<Select <div className="min-w-[113px] max-w-[113px] flex-1">
defaultValue="coding" <Select
label="Type" defaultValue="coding"
options={QUESTION_TYPES} label="Type"
required={true} options={QUESTION_TYPES}
{...selectRegister('questionType')} required={true}
/> {...selectRegister('questionType')}
</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>
<div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]"> <TextArea
<Controller label="Question Prompt"
control={control} placeholder="Contribute a question"
name="date" required={true}
render={({ field }) => ( rows={5}
<MonthYearPicker {...register('questionContent')}
monthRequired={true} />
value={{ <HorizontalDivider />
month: ((field.value.getMonth() as number) + 1) as Month, <h2 className="text-md text-primary-800 font-semibold">
year: field.value.getFullYear(), Additional information
}} </h2>
yearRequired={true} <div className="flex flex-col flex-wrap items-stretch gap-2 sm:flex-row sm:items-end">
onChange={({ month, year }) => <div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]">
field.onChange(startOfMonth(new Date(year, month - 1))) <Controller
} control={control}
/> name="location"
)} render={({ field }) => (
/> <LocationTypeahead
</div> required={true}
</div> onSelect={(option) => {
<div className="flex flex-col flex-wrap items-stretch gap-2 sm:flex-row sm:items-end"> field.onChange(option.value);
<div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]"> }}
<Controller {...field}
control={control} value={LOCATIONS.find(
name="company" (location) => location.value === field.value,
render={({ field }) => ( )}
<CompanyTypeahead />
required={true} )}
onSelect={({ id }) => { />
field.onChange(id); </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>
<div className="flex-1 sm:min-w-[150px] sm:max-w-[200px]"> <div className="flex flex-col flex-wrap items-stretch gap-2 sm:flex-row sm:items-end">
<Controller <div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]">
control={control} <Controller
name="role" control={control}
render={({ field }) => ( name="company"
<RoleTypeahead render={({ field }) => (
required={true} <CompanyTypeahead
onSelect={(option) => { required={true}
field.onChange(option.value); onSelect={({ id }) => {
}} field.onChange(id);
{...field} }}
value={ROLES.find((role) => role.value === field.value)} />
/> )}
)} />
/> </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>
</div> <div className="w-full">
{/* <div className="w-full"> <HorizontalDivider />
<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}
/>
</div> </div>
<div className="flex gap-x-2"> <div
<button className="bg-primary-50 flex w-full justify-end gap-y-2 py-3 shadow-[0_0_0_100vmax_theme(colors.primary.50)]"
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" style={{
type="button" // Hack to make the background bleed outside the container
onClick={onDiscard}> clipPath: 'inset(0 -100vmax)',
Discard }}>
</button> <div className="flex gap-x-2">
<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" 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"
disabled={!canSubmit} type="button"
label="Contribute" onClick={onDiscard}>
type="submit" Discard
variant="primary"></Button> </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> </div>
</div> </form>
</form> </div>
); );
} }

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

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

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

@ -1,6 +1,8 @@
import { z } from 'zod'; import { z } from 'zod';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import createQuestionWithAggregateData from '~/utils/questions/server/createQuestionWithAggregateData';
import { createProtectedRouter } from './context'; import { createProtectedRouter } from './context';
export const questionListRouter = createProtectedRouter() export const questionListRouter = createProtectedRouter()
@ -8,11 +10,35 @@ export const questionListRouter = createProtectedRouter()
async resolve({ ctx }) { async resolve({ ctx }) {
const userId = ctx.session?.user?.id; 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: { include: {
questionEntries: { questionEntries: {
include: { 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', createdAt: 'asc',
}, },
where: { where: {
id: userId, userId,
}, },
}); });
const lists = questionsLists.map((list) => ({
...list,
questionEntries: list.questionEntries.map((entry) => ({
...entry,
question: createQuestionWithAggregateData(entry.question),
})),
}));
return lists;
}, },
}) })
.query('getListById', { .query('getListById', {
input: z.object({ input: z.object({
listId: z.string(), listId: z.string(),
}), }),
async resolve({ ctx }) { async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
const { listId } = input;
return await ctx.prisma.questionsList.findMany({ const questionList = await ctx.prisma.questionsList.findFirst({
include: { include: {
questionEntries: { questionEntries: {
include: { 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', createdAt: 'asc',
}, },
where: { 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', { .mutation('create', {
@ -111,7 +187,7 @@ export const questionListRouter = createProtectedRouter()
}, },
}); });
if (listToDelete?.id !== userId) { if (listToDelete?.userId !== userId) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'User have no authorization to record.', message: 'User have no authorization to record.',
@ -139,7 +215,7 @@ export const questionListRouter = createProtectedRouter()
}, },
}); });
if (listToAugment?.id !== userId) { if (listToAugment?.userId !== userId) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'User have no authorization to record.', message: 'User have no authorization to record.',
@ -170,10 +246,10 @@ export const questionListRouter = createProtectedRouter()
}, },
}); });
if (entryToDelete?.id !== userId) { if (entryToDelete === null) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'NOT_FOUND',
message: 'User have no authorization to record.', message: 'Entry not found.',
}); });
} }
@ -183,7 +259,7 @@ export const questionListRouter = createProtectedRouter()
}, },
}); });
if (listToAugment?.id !== userId) { if (listToAugment?.userId !== userId) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'User have no authorization to record.', message: 'User have no authorization to record.',

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

@ -2,9 +2,10 @@ import { z } from 'zod';
import { QuestionsQuestionType, Vote } from '@prisma/client'; import { QuestionsQuestionType, Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import createQuestionWithAggregateData from '~/utils/questions/server/createQuestionWithAggregateData';
import { createProtectedRouter } from './context'; import { createProtectedRouter } from './context';
import type { Question } from '~/types/questions';
import { SortOrder, SortType } from '~/types/questions.d'; import { SortOrder, SortType } from '~/types/questions.d';
export const questionsQuestionRouter = createProtectedRouter() export const questionsQuestionRouter = createProtectedRouter()
@ -122,72 +123,9 @@ export const questionsQuestionRouter = createProtectedRouter()
}, },
}); });
const processedQuestionsData = questionsData.map((data) => { const processedQuestionsData = questionsData.map(
const votes: number = data.votes.reduce( createQuestionWithAggregateData,
(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;
});
let nextCursor: typeof cursor | undefined = undefined; let nextCursor: typeof cursor | undefined = undefined;
@ -252,68 +190,8 @@ export const questionsQuestionRouter = createProtectedRouter()
message: 'Question not found', 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; return createQuestionWithAggregateData(questionData);
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;
}, },
}) })
.mutation('create', { .mutation('create', {

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

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

Loading…
Cancel
Save