mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-14 18:05:55 +08:00
[questions][feat] add similar questions check (#468)
Co-authored-by: wlren <weilinwork99@gmail.com>
This commit is contained in:
@ -1,6 +1,6 @@
|
|||||||
import { Fragment, useState } from 'react';
|
import { Fragment, useState } from 'react';
|
||||||
import { Dialog, Transition } from '@headlessui/react';
|
import { Dialog, Transition } from '@headlessui/react';
|
||||||
import { HorizontalDivider } from '@tih/ui';
|
import { HorizontalDivider, useToast } from '@tih/ui';
|
||||||
|
|
||||||
import DiscardDraftDialog from './DiscardDraftDialog';
|
import DiscardDraftDialog from './DiscardDraftDialog';
|
||||||
import type { ContributeQuestionFormProps } from './forms/ContributeQuestionForm';
|
import type { ContributeQuestionFormProps } from './forms/ContributeQuestionForm';
|
||||||
@ -21,6 +21,8 @@ export default function ContributeQuestionDialog({
|
|||||||
}: ContributeQuestionDialogProps) {
|
}: ContributeQuestionDialogProps) {
|
||||||
const [showDiscardDialog, setShowDiscardDialog] = useState(false);
|
const [showDiscardDialog, setShowDiscardDialog] = useState(false);
|
||||||
|
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
const handleDraftDiscard = () => {
|
const handleDraftDiscard = () => {
|
||||||
setShowDiscardDialog(false);
|
setShowDiscardDialog(false);
|
||||||
onCancel();
|
onCancel();
|
||||||
@ -75,6 +77,14 @@ export default function ContributeQuestionDialog({
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<ContributeQuestionForm
|
<ContributeQuestionForm
|
||||||
onDiscard={() => setShowDiscardDialog(true)}
|
onDiscard={() => setShowDiscardDialog(true)}
|
||||||
|
onSimilarQuestionFound={() => {
|
||||||
|
onCancel();
|
||||||
|
showToast({
|
||||||
|
title:
|
||||||
|
'Your response has been recorded. Draft discarded.',
|
||||||
|
variant: 'success',
|
||||||
|
});
|
||||||
|
}}
|
||||||
onSubmit={(data) => {
|
onSubmit={(data) => {
|
||||||
onSubmit(data);
|
onSubmit(data);
|
||||||
onCancel();
|
onCancel();
|
||||||
|
@ -88,10 +88,12 @@ type ReceivedStatisticsProps =
|
|||||||
|
|
||||||
type CreateEncounterProps =
|
type CreateEncounterProps =
|
||||||
| {
|
| {
|
||||||
|
createEncounterButtonText: string;
|
||||||
onReceivedSubmit: (data: CreateQuestionEncounterData) => void;
|
onReceivedSubmit: (data: CreateQuestionEncounterData) => void;
|
||||||
showCreateEncounterButton: true;
|
showCreateEncounterButton: true;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
createEncounterButtonText?: never;
|
||||||
onReceivedSubmit?: never;
|
onReceivedSubmit?: never;
|
||||||
showCreateEncounterButton?: false;
|
showCreateEncounterButton?: false;
|
||||||
};
|
};
|
||||||
@ -132,6 +134,7 @@ export default function BaseQuestionCard({
|
|||||||
showAnswerStatistics,
|
showAnswerStatistics,
|
||||||
showReceivedStatistics,
|
showReceivedStatistics,
|
||||||
showCreateEncounterButton,
|
showCreateEncounterButton,
|
||||||
|
createEncounterButtonText,
|
||||||
showActionButton,
|
showActionButton,
|
||||||
actionButtonLabel,
|
actionButtonLabel,
|
||||||
onActionButtonClick,
|
onActionButtonClick,
|
||||||
@ -238,7 +241,7 @@ export default function BaseQuestionCard({
|
|||||||
<Button
|
<Button
|
||||||
addonPosition="start"
|
addonPosition="start"
|
||||||
icon={CheckIcon}
|
icon={CheckIcon}
|
||||||
label="I received this too"
|
label={createEncounterButtonText}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
|
@ -3,10 +3,10 @@ import BaseQuestionCard from './BaseQuestionCard';
|
|||||||
|
|
||||||
export type SimilarQuestionCardProps = Omit<
|
export type SimilarQuestionCardProps = Omit<
|
||||||
BaseQuestionCardProps & {
|
BaseQuestionCardProps & {
|
||||||
showActionButton: true;
|
showActionButton: false;
|
||||||
showAggregateStatistics: false;
|
showAggregateStatistics: true;
|
||||||
showAnswerStatistics: false;
|
showAnswerStatistics: false;
|
||||||
showCreateEncounterButton: false;
|
showCreateEncounterButton: true;
|
||||||
showDeleteButton: false;
|
showDeleteButton: false;
|
||||||
showHover: true;
|
showHover: true;
|
||||||
showReceivedStatistics: false;
|
showReceivedStatistics: false;
|
||||||
@ -22,26 +22,20 @@ export type SimilarQuestionCardProps = Omit<
|
|||||||
| 'showHover'
|
| 'showHover'
|
||||||
| 'showReceivedStatistics'
|
| 'showReceivedStatistics'
|
||||||
| 'showVoteButtons'
|
| 'showVoteButtons'
|
||||||
> & {
|
>;
|
||||||
onSimilarQuestionClick: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SimilarQuestionCard(props: SimilarQuestionCardProps) {
|
export default function SimilarQuestionCard(props: SimilarQuestionCardProps) {
|
||||||
const { onSimilarQuestionClick, ...rest } = props;
|
|
||||||
return (
|
return (
|
||||||
<BaseQuestionCard
|
<BaseQuestionCard
|
||||||
actionButtonLabel="Yes, this is my question"
|
showActionButton={false}
|
||||||
showActionButton={true}
|
showAggregateStatistics={true}
|
||||||
showAggregateStatistics={false}
|
|
||||||
showAnswerStatistics={false}
|
showAnswerStatistics={false}
|
||||||
showCreateEncounterButton={false}
|
showCreateEncounterButton={true}
|
||||||
showDeleteButton={false}
|
showDeleteButton={false}
|
||||||
showHover={true}
|
showHover={true}
|
||||||
showReceivedStatistics={false}
|
showReceivedStatistics={false}
|
||||||
showVoteButtons={false}
|
showVoteButtons={false}
|
||||||
onActionButtonClick={onSimilarQuestionClick}
|
{...props}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
{...(rest as any)}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,15 +1,21 @@
|
|||||||
import { startOfMonth } from 'date-fns';
|
import { startOfMonth } from 'date-fns';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
|
import { ArrowPathIcon } from '@heroicons/react/20/solid';
|
||||||
import type { QuestionsQuestionType } from '@prisma/client';
|
import type { QuestionsQuestionType } from '@prisma/client';
|
||||||
import type { TypeaheadOption } from '@tih/ui';
|
import type { TypeaheadOption } from '@tih/ui';
|
||||||
|
import { CheckboxInput } from '@tih/ui';
|
||||||
import { Button, HorizontalDivider, Select, TextArea } from '@tih/ui';
|
import { Button, HorizontalDivider, Select, TextArea } from '@tih/ui';
|
||||||
|
|
||||||
import { QUESTION_TYPES } from '~/utils/questions/constants';
|
import { QUESTION_TYPES } from '~/utils/questions/constants';
|
||||||
|
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
|
||||||
import {
|
import {
|
||||||
useFormRegister,
|
useFormRegister,
|
||||||
useSelectRegister,
|
useSelectRegister,
|
||||||
} from '~/utils/questions/useFormRegister';
|
} from '~/utils/questions/useFormRegister';
|
||||||
|
import { trpc } from '~/utils/trpc';
|
||||||
|
|
||||||
|
import SimilarQuestionCard from '../card/question/SimilarQuestionCard';
|
||||||
import CompanyTypeahead from '../typeahead/CompanyTypeahead';
|
import CompanyTypeahead from '../typeahead/CompanyTypeahead';
|
||||||
import LocationTypeahead from '../typeahead/LocationTypeahead';
|
import LocationTypeahead from '../typeahead/LocationTypeahead';
|
||||||
import RoleTypeahead from '../typeahead/RoleTypeahead';
|
import RoleTypeahead from '../typeahead/RoleTypeahead';
|
||||||
@ -30,26 +36,64 @@ export type ContributeQuestionData = {
|
|||||||
|
|
||||||
export type ContributeQuestionFormProps = {
|
export type ContributeQuestionFormProps = {
|
||||||
onDiscard: () => void;
|
onDiscard: () => void;
|
||||||
|
onSimilarQuestionFound: () => void;
|
||||||
onSubmit: (data: ContributeQuestionData) => void;
|
onSubmit: (data: ContributeQuestionData) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ContributeQuestionForm({
|
export default function ContributeQuestionForm({
|
||||||
onSubmit,
|
|
||||||
onDiscard,
|
onDiscard,
|
||||||
|
onSimilarQuestionFound,
|
||||||
|
onSubmit,
|
||||||
}: ContributeQuestionFormProps) {
|
}: ContributeQuestionFormProps) {
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
register: formRegister,
|
register: formRegister,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
watch,
|
||||||
} = useForm<ContributeQuestionData>({
|
} = useForm<ContributeQuestionData>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
date: startOfMonth(new Date()),
|
date: startOfMonth(new Date()),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [contentToCheck, setContentToCheck] = useState('');
|
||||||
|
|
||||||
|
const { data: similarQuestions } = trpc.useQuery(
|
||||||
|
['questions.questions.getRelatedQuestions', { content: contentToCheck }],
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const utils = trpc.useContext();
|
||||||
|
|
||||||
|
const { mutateAsync: addEncounterAsync } = trpc.useMutation(
|
||||||
|
'questions.questions.encounters.user.create',
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.invalidateQueries(
|
||||||
|
'questions.questions.encounters.getAggregatedEncounters',
|
||||||
|
);
|
||||||
|
utils.invalidateQueries('questions.questions.getQuestionById');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const questionContent = watch('questionContent');
|
||||||
const register = useFormRegister(formRegister);
|
const register = useFormRegister(formRegister);
|
||||||
const selectRegister = useSelectRegister(formRegister);
|
const selectRegister = useSelectRegister(formRegister);
|
||||||
|
|
||||||
|
const [checkedSimilar, setCheckedSimilar] = useState<boolean>(false);
|
||||||
|
const handleCheckSimilarQuestions = (checked: boolean) => {
|
||||||
|
setCheckedSimilar(checked);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (questionContent !== contentToCheck) {
|
||||||
|
setCheckedSimilar(false);
|
||||||
|
}
|
||||||
|
}, [questionContent, contentToCheck]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col justify-between gap-4">
|
<div className="flex flex-col justify-between gap-4">
|
||||||
<form
|
<form
|
||||||
@ -149,12 +193,83 @@ export default function ContributeQuestionForm({
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<HorizontalDivider />
|
<HorizontalDivider />
|
||||||
</div>
|
</div>
|
||||||
|
<h2
|
||||||
|
className="text-primary-900 mb-3
|
||||||
|
text-lg font-semibold
|
||||||
|
">
|
||||||
|
Are these questions the same as yours?
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
addonPosition="start"
|
||||||
|
disabled={questionContent === contentToCheck}
|
||||||
|
icon={ArrowPathIcon}
|
||||||
|
label="Refresh similar questions"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => {
|
||||||
|
setContentToCheck(questionContent);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-y-2">
|
||||||
|
{similarQuestions?.map((question) => {
|
||||||
|
const { companyCounts, countryCounts, roleCounts } =
|
||||||
|
relabelQuestionAggregates(question.aggregatedQuestionEncounters);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SimilarQuestionCard
|
||||||
|
key={question.id}
|
||||||
|
companies={companyCounts}
|
||||||
|
content={question.content}
|
||||||
|
countries={countryCounts}
|
||||||
|
createEncounterButtonText="Yes, this is my question"
|
||||||
|
questionId={question.id}
|
||||||
|
roles={roleCounts}
|
||||||
|
timestamp={
|
||||||
|
question.seenAt.toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
}) ?? null
|
||||||
|
}
|
||||||
|
type={question.type}
|
||||||
|
onReceivedSubmit={async (data) => {
|
||||||
|
await addEncounterAsync({
|
||||||
|
cityId: data.cityId,
|
||||||
|
companyId: data.company,
|
||||||
|
countryId: data.countryId,
|
||||||
|
questionId: question.id,
|
||||||
|
role: data.role,
|
||||||
|
seenAt: data.seenAt,
|
||||||
|
stateId: data.stateId,
|
||||||
|
});
|
||||||
|
|
||||||
|
onSimilarQuestionFound();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{similarQuestions?.length === 0 && (
|
||||||
|
<p className="font-semibold text-slate-900">
|
||||||
|
No similar questions found.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<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)]"
|
className="bg-primary-50 flex w-full justify-between gap-y-2 py-3 shadow-[0_0_0_100vmax_theme(colors.primary.50)]"
|
||||||
style={{
|
style={{
|
||||||
// Hack to make the background bleed outside the container
|
// Hack to make the background bleed outside the container
|
||||||
clipPath: 'inset(0 -100vmax)',
|
clipPath: 'inset(0 -100vmax)',
|
||||||
}}>
|
}}>
|
||||||
|
<div className="my-2 flex items-center sm:my-0">
|
||||||
|
<CheckboxInput
|
||||||
|
disabled={questionContent !== contentToCheck}
|
||||||
|
label={
|
||||||
|
questionContent !== contentToCheck
|
||||||
|
? 'I have checked that my question is new (Refresh similar questions to proceed)'
|
||||||
|
: 'I have checked that my question is new'
|
||||||
|
}
|
||||||
|
value={checkedSimilar}
|
||||||
|
onChange={handleCheckSimilarQuestions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex gap-x-2">
|
<div className="flex gap-x-2">
|
||||||
<button
|
<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"
|
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"
|
||||||
|
@ -193,6 +193,7 @@ export default function QuestionPage() {
|
|||||||
{...question}
|
{...question}
|
||||||
companies={relabeledAggregatedEncounters?.companyCounts ?? {}}
|
companies={relabeledAggregatedEncounters?.companyCounts ?? {}}
|
||||||
countries={relabeledAggregatedEncounters?.countryCounts ?? {}}
|
countries={relabeledAggregatedEncounters?.countryCounts ?? {}}
|
||||||
|
createEncounterButtonText="I received this too"
|
||||||
questionId={question.id}
|
questionId={question.id}
|
||||||
receivedCount={undefined}
|
receivedCount={undefined}
|
||||||
roles={relabeledAggregatedEncounters?.roleCounts ?? {}}
|
roles={relabeledAggregatedEncounters?.roleCounts ?? {}}
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
import { JobTitleLabels } from '~/components/shared/JobTitles';
|
|
||||||
|
|
||||||
import { createProtectedRouter } from '../context';
|
import { createProtectedRouter } from '../context';
|
||||||
|
|
||||||
import { SortOrder } from '~/types/questions.d';
|
import { SortOrder } from '~/types/questions.d';
|
||||||
@ -14,7 +12,7 @@ export const questionsQuestionEncounterUserRouter = createProtectedRouter()
|
|||||||
companyId: z.string(),
|
companyId: z.string(),
|
||||||
countryId: z.string(),
|
countryId: z.string(),
|
||||||
questionId: z.string(),
|
questionId: z.string(),
|
||||||
role: z.nativeEnum(JobTitleLabels),
|
role: z.string(),
|
||||||
seenAt: z.date(),
|
seenAt: z.date(),
|
||||||
stateId: z.string().nullish(),
|
stateId: z.string().nullish(),
|
||||||
}),
|
}),
|
||||||
|
Reference in New Issue
Block a user