mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2026-04-29 13:02:23 +08:00
[questions][fix] ui fixes and enhancements (#514)
Co-authored-by: wlren <weilinwork99@gmail.com>
This commit is contained in:
@@ -3,22 +3,52 @@ import type { PropsWithChildren } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Fragment, useRef, useState } from 'react';
|
||||
import { Menu, Transition } from '@headlessui/react';
|
||||
import { CheckIcon, HeartIcon } from '@heroicons/react/20/solid';
|
||||
import { CheckIcon, HeartIcon, PlusIcon } from '@heroicons/react/20/solid';
|
||||
|
||||
import {
|
||||
useAddQuestionToListAsync,
|
||||
useCreateListAsync,
|
||||
useRemoveQuestionFromListAsync,
|
||||
} from '~/utils/questions/mutations';
|
||||
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
import CreateListDialog from './CreateListDialog';
|
||||
|
||||
export type AddToListDropdownProps = {
|
||||
questionId: string;
|
||||
};
|
||||
|
||||
export type DropdownButtonProps = PropsWithChildren<{
|
||||
onClick: () => void;
|
||||
}>;
|
||||
|
||||
function DropdownButton({ onClick, children }: DropdownButtonProps) {
|
||||
return (
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
className={clsx(
|
||||
active ? 'bg-slate-100 text-slate-900' : 'text-slate-700',
|
||||
'flex w-full items-center px-4 py-2 text-sm',
|
||||
)}
|
||||
type="button"
|
||||
onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AddToListDropdown({
|
||||
questionId,
|
||||
}: AddToListDropdownProps) {
|
||||
const [menuOpened, setMenuOpened] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const createListAsync = useCreateListAsync();
|
||||
const { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']);
|
||||
|
||||
const listsWithQuestionData = useMemo(() => {
|
||||
@@ -30,25 +60,8 @@ export default function AddToListDropdown({
|
||||
}));
|
||||
}, [lists, questionId]);
|
||||
|
||||
const { mutateAsync: addQuestionToList } = trpc.useMutation(
|
||||
'questions.lists.createQuestionEntry',
|
||||
{
|
||||
// TODO: Add optimistic update
|
||||
onSuccess: () => {
|
||||
utils.invalidateQueries(['questions.lists.getListsByUser']);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync: removeQuestionFromList } = trpc.useMutation(
|
||||
'questions.lists.deleteQuestionEntry',
|
||||
{
|
||||
// TODO: Add optimistic update
|
||||
onSuccess: () => {
|
||||
utils.invalidateQueries(['questions.lists.getListsByUser']);
|
||||
},
|
||||
},
|
||||
);
|
||||
const addQuestionToList = useAddQuestionToListAsync();
|
||||
const removeQuestionFromList = useRemoveQuestionFromListAsync();
|
||||
|
||||
const addClickOutsideListener = () => {
|
||||
document.addEventListener('click', handleClickOutside, true);
|
||||
@@ -101,63 +114,79 @@ export default function AddToListDropdown({
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu ref={ref} as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<Menu.Button as={CustomMenuButton}>
|
||||
<HeartIcon aria-hidden="true" className="-ml-1 mr-2 h-5 w-5" />
|
||||
Add to List
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
show={menuOpened}>
|
||||
<Menu.Items
|
||||
className="absolute right-0 z-10 mt-2 w-56 origin-top-right divide-y divide-slate-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
static={true}>
|
||||
{menuOpened && (
|
||||
<>
|
||||
{(listsWithQuestionData ?? []).map((list) => (
|
||||
<div key={list.id} className="py-1">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
className={clsx(
|
||||
active
|
||||
? 'bg-slate-100 text-slate-900'
|
||||
: 'text-slate-700',
|
||||
'group flex w-full items-center px-4 py-2 text-sm',
|
||||
)}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (list.hasQuestion) {
|
||||
handleDeleteFromList(list.id);
|
||||
} else {
|
||||
handleAddToList(list.id);
|
||||
}
|
||||
}}>
|
||||
<div>
|
||||
<Menu ref={ref} as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<Menu.Button as={CustomMenuButton}>
|
||||
<HeartIcon aria-hidden="true" className="-ml-1 mr-2 h-5 w-5" />
|
||||
Add to list
|
||||
</Menu.Button>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
show={menuOpened}>
|
||||
<Menu.Items
|
||||
className="absolute right-0 z-10 mt-2 w-56 origin-top-right divide-y divide-slate-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
static={true}>
|
||||
{menuOpened && (
|
||||
<>
|
||||
{(listsWithQuestionData ?? []).map((list) => (
|
||||
<div key={list.id} className="py-1">
|
||||
<DropdownButton
|
||||
onClick={() => {
|
||||
if (list.hasQuestion) {
|
||||
handleDeleteFromList(list.id);
|
||||
} else {
|
||||
handleAddToList(list.id);
|
||||
}
|
||||
}}>
|
||||
<div className="flex w-full flex-1 justify-between">
|
||||
<span className="flex-1 overflow-hidden text-ellipsis text-start">
|
||||
{list.name}
|
||||
</span>
|
||||
{list.hasQuestion && (
|
||||
<CheckIcon
|
||||
aria-hidden="true"
|
||||
className="mr-3 h-5 w-5 text-slate-400 group-hover:text-slate-500"
|
||||
className="h-5 w-5 text-slate-400"
|
||||
/>
|
||||
)}
|
||||
{list.name}
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
</DropdownButton>
|
||||
</div>
|
||||
))}
|
||||
<DropdownButton
|
||||
onClick={() => {
|
||||
setShow(true);
|
||||
}}>
|
||||
<PlusIcon
|
||||
aria-hidden="true"
|
||||
className="mr-3 h-5 w-5 text-slate-500"
|
||||
/>
|
||||
<span className="font-semibold text-slate-500">
|
||||
Create new list
|
||||
</span>
|
||||
</DropdownButton>
|
||||
</>
|
||||
)}
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
<CreateListDialog
|
||||
show={show}
|
||||
onCancel={() => {
|
||||
setShow(false);
|
||||
}}
|
||||
onSubmit={async (data) => {
|
||||
await createListAsync(data);
|
||||
setShow(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
BuildingOffice2Icon,
|
||||
CalendarDaysIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { TextInput } from '@tih/ui';
|
||||
|
||||
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
|
||||
@@ -32,44 +27,19 @@ export default function ContributeQuestionCard({
|
||||
return (
|
||||
<div className="w-full">
|
||||
<button
|
||||
className="flex w-full flex-col items-stretch justify-center gap-2 rounded-md border border-slate-300 bg-white p-4 text-left hover:bg-slate-100"
|
||||
className="flex w-full flex-1 justify-between gap-2 rounded-md border border-slate-300 bg-white p-4 text-left hover:bg-slate-100"
|
||||
type="button"
|
||||
onClick={handleOpenContribute}>
|
||||
<TextInput
|
||||
disabled={true}
|
||||
isLabelHidden={true}
|
||||
label="Question"
|
||||
placeholder="Contribute a question"
|
||||
onChange={handleOpenContribute}
|
||||
/>
|
||||
<div className="w-full">
|
||||
<TextInput
|
||||
disabled={true}
|
||||
isLabelHidden={true}
|
||||
label="Question"
|
||||
placeholder="Contribute a question"
|
||||
onChange={handleOpenContribute}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end justify-start gap-2">
|
||||
<div className="min-w-[150px] flex-1">
|
||||
<TextInput
|
||||
disabled={true}
|
||||
label="Company"
|
||||
startAddOn={BuildingOffice2Icon}
|
||||
startAddOnType="icon"
|
||||
onChange={handleOpenContribute}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[150px] flex-1">
|
||||
<TextInput
|
||||
disabled={true}
|
||||
label="Question type"
|
||||
startAddOn={QuestionMarkCircleIcon}
|
||||
startAddOnType="icon"
|
||||
onChange={handleOpenContribute}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[150px] flex-1">
|
||||
<TextInput
|
||||
disabled={true}
|
||||
label="Date"
|
||||
startAddOn={CalendarDaysIcon}
|
||||
startAddOnType="icon"
|
||||
onChange={handleOpenContribute}
|
||||
/>
|
||||
</div>
|
||||
<h1 className="bg-primary-600 hover:bg-primary-700 rounded-full px-3 py-2 text-white">
|
||||
Contribute
|
||||
</h1>
|
||||
|
||||
@@ -8,16 +8,15 @@ export type SortOption<Value> = {
|
||||
value: Value;
|
||||
};
|
||||
|
||||
const sortTypeOptions = SORT_TYPES;
|
||||
const sortOrderOptions = SORT_ORDERS;
|
||||
|
||||
type SortOrderProps<Order> = {
|
||||
onSortOrderChange?: (sortValue: Order) => void;
|
||||
sortOrderOptions?: Array<SortOption<Order>>;
|
||||
sortOrderValue: Order;
|
||||
};
|
||||
|
||||
type SortTypeProps<Type> = {
|
||||
onSortTypeChange?: (sortType: Type) => void;
|
||||
sortTypeOptions?: Array<SortOption<Type>>;
|
||||
sortTypeValue: Type;
|
||||
};
|
||||
|
||||
@@ -29,17 +28,22 @@ export default function SortOptionsSelect({
|
||||
sortOrderValue,
|
||||
onSortTypeChange,
|
||||
sortTypeValue,
|
||||
sortOrderOptions,
|
||||
sortTypeOptions,
|
||||
}: SortOptionsSelectProps) {
|
||||
const sortTypes = sortTypeOptions ?? SORT_TYPES;
|
||||
const sortOrders = sortOrderOptions ?? SORT_ORDERS;
|
||||
|
||||
return (
|
||||
<div className="flex items-end justify-end gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
display="inline"
|
||||
label="Sort by"
|
||||
options={sortTypeOptions}
|
||||
options={sortTypes}
|
||||
value={sortTypeValue}
|
||||
onChange={(value) => {
|
||||
const chosenOption = sortTypeOptions.find(
|
||||
const chosenOption = sortTypes.find(
|
||||
(option) => String(option.value) === value,
|
||||
);
|
||||
if (chosenOption) {
|
||||
@@ -52,10 +56,10 @@ export default function SortOptionsSelect({
|
||||
<Select
|
||||
display="inline"
|
||||
label="Order by"
|
||||
options={sortOrderOptions}
|
||||
options={sortOrders}
|
||||
value={sortOrderValue}
|
||||
onChange={(value) => {
|
||||
const chosenOption = sortOrderOptions.find(
|
||||
const chosenOption = sortOrders.find(
|
||||
(option) => String(option.value) === value,
|
||||
);
|
||||
if (chosenOption) {
|
||||
|
||||
@@ -90,7 +90,7 @@ type ReceivedStatisticsProps =
|
||||
type CreateEncounterProps =
|
||||
| {
|
||||
createEncounterButtonText: string;
|
||||
onReceivedSubmit: (data: CreateQuestionEncounterData) => void;
|
||||
onReceivedSubmit: (data: CreateQuestionEncounterData) => Promise<void>;
|
||||
showCreateEncounterButton: true;
|
||||
}
|
||||
| {
|
||||
@@ -185,7 +185,7 @@ export default function BaseQuestionCard({
|
||||
)}
|
||||
<div className="flex flex-1 flex-col items-start gap-2">
|
||||
<div className="flex items-baseline justify-between self-stretch">
|
||||
<div className="flex items-center gap-2 text-slate-500">
|
||||
<div className="z-10 flex items-center gap-2 text-slate-500">
|
||||
{showAggregateStatistics && (
|
||||
<>
|
||||
<QuestionTypeBadge type={type} />
|
||||
@@ -263,9 +263,8 @@ export default function BaseQuestionCard({
|
||||
onCancel={() => {
|
||||
setShowReceivedForm(false);
|
||||
}}
|
||||
onSubmit={(data) => {
|
||||
onReceivedSubmit?.(data);
|
||||
setShowReceivedForm(false);
|
||||
onSubmit={async (data) => {
|
||||
await onReceivedSubmit?.(data);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ArrowPathIcon } from '@heroicons/react/20/solid';
|
||||
import type { QuestionsQuestionType } from '@prisma/client';
|
||||
import type { TypeaheadOption } from '@tih/ui';
|
||||
import { CheckboxInput } from '@tih/ui';
|
||||
import { Button, HorizontalDivider, Select, TextArea } from '@tih/ui';
|
||||
import { Button, Select, TextArea } from '@tih/ui';
|
||||
|
||||
import { QUESTION_TYPES } from '~/utils/questions/constants';
|
||||
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
|
||||
@@ -187,11 +187,9 @@ export default function ContributeQuestionForm({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<HorizontalDivider />
|
||||
</div>
|
||||
|
||||
<h2
|
||||
className="text-primary-900 mb-3
|
||||
className="text-primary-900
|
||||
text-lg font-semibold
|
||||
">
|
||||
Are these questions the same as yours?
|
||||
@@ -243,11 +241,13 @@ export default function ContributeQuestionForm({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{similarQuestions?.length === 0 && (
|
||||
<p className="font-semibold text-slate-900">
|
||||
No similar questions found.
|
||||
</p>
|
||||
)}
|
||||
{similarQuestions?.length === 0 &&
|
||||
contentToCheck?.length !== 0 &&
|
||||
questionContent === contentToCheck && (
|
||||
<p className="font-semibold text-slate-900">
|
||||
No similar questions found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="bg-primary-50 flex w-full flex-col gap-y-2 py-3 shadow-[0_0_0_100vmax_theme(colors.primary.50)] sm:flex-row sm:justify-between"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { startOfMonth } from 'date-fns';
|
||||
import { useState } from 'react';
|
||||
import { CheckIcon } from '@heroicons/react/20/solid';
|
||||
import { Button } from '@tih/ui';
|
||||
|
||||
import type { Month } from '~/components/shared/MonthYearPicker';
|
||||
@@ -22,7 +23,7 @@ export type CreateQuestionEncounterData = {
|
||||
|
||||
export type CreateQuestionEncounterFormProps = {
|
||||
onCancel: () => void;
|
||||
onSubmit: (data: CreateQuestionEncounterData) => void;
|
||||
onSubmit: (data: CreateQuestionEncounterData) => Promise<void>;
|
||||
};
|
||||
|
||||
export default function CreateQuestionEncounterForm({
|
||||
@@ -30,6 +31,8 @@ export default function CreateQuestionEncounterForm({
|
||||
onSubmit,
|
||||
}: CreateQuestionEncounterFormProps) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
|
||||
const [selectedLocation, setSelectedLocation] = useState<Location | null>(
|
||||
@@ -40,9 +43,18 @@ export default function CreateQuestionEncounterForm({
|
||||
startOfMonth(new Date()),
|
||||
);
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="font-md flex items-center gap-1 rounded-full border bg-slate-50 py-1 pl-2 pr-3 text-sm text-slate-500">
|
||||
<CheckIcon className="h-5 w-5" />
|
||||
<p>Thank you for your response</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-md text-md text-slate-600">
|
||||
<p className="text-md text-slate-600">
|
||||
I saw this question {step <= 1 ? 'at' : step === 2 ? 'for' : 'on'}
|
||||
</p>
|
||||
{step === 0 && (
|
||||
@@ -128,9 +140,10 @@ export default function CreateQuestionEncounterForm({
|
||||
)}
|
||||
{step === 3 && (
|
||||
<Button
|
||||
isLoading={loading}
|
||||
label="Submit"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
if (
|
||||
selectedCompany &&
|
||||
selectedLocation &&
|
||||
@@ -138,14 +151,20 @@ export default function CreateQuestionEncounterForm({
|
||||
selectedDate
|
||||
) {
|
||||
const { cityId, stateId, countryId } = selectedLocation;
|
||||
onSubmit({
|
||||
cityId,
|
||||
company: selectedCompany,
|
||||
countryId,
|
||||
role: selectedRole,
|
||||
seenAt: selectedDate,
|
||||
stateId,
|
||||
});
|
||||
setLoading(true);
|
||||
try {
|
||||
await onSubmit({
|
||||
cityId,
|
||||
company: selectedCompany,
|
||||
countryId,
|
||||
role: selectedRole,
|
||||
seenAt: selectedDate,
|
||||
stateId,
|
||||
});
|
||||
setSubmitted(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user