mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2026-04-02 02:21:00 +08:00
[questions][feat] Add question lists (#438)
Co-authored-by: wlren <weilinwork99@gmail.com>
This commit is contained in:
160
apps/portal/src/components/questions/AddToListDropdown.tsx
Normal file
160
apps/portal/src/components/questions/AddToListDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
75
apps/portal/src/components/questions/CreateListDialog.tsx
Normal file
75
apps/portal/src/components/questions/CreateListDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
apps/portal/src/components/questions/DeleteListDialog.tsx
Normal file
29
apps/portal/src/components/questions/DeleteListDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.',
|
||||
@@ -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({
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user