mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-28 04:33:42 +08:00
[questions][ui] update roles typeahead, add sticky search bar (#451)
This commit is contained in:
@ -28,56 +28,54 @@ export default function ContributeQuestionCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
className="flex flex-col items-stretch justify-center 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="flex flex-wrap items-end justify-center gap-x-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>
|
||||
<button
|
||||
className="flex flex-col items-stretch justify-center 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="flex flex-wrap items-end justify-center gap-x-2">
|
||||
<div className="min-w-[150px] flex-1">
|
||||
<TextInput
|
||||
disabled={true}
|
||||
label="Company"
|
||||
startAddOn={BuildingOffice2Icon}
|
||||
startAddOnType="icon"
|
||||
onChange={handleOpenContribute}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
<ContributeQuestionDialog
|
||||
show={showDraftDialog}
|
||||
onCancel={handleDraftDialogCancel}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
@ -1,13 +1,21 @@
|
||||
import { ROLES } from '~/utils/questions/constants';
|
||||
import { JobTitleLabels } from '~/components/shared/JobTitles';
|
||||
|
||||
import type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
|
||||
import ExpandedTypeahead from './ExpandedTypeahead';
|
||||
import type { FilterChoices } from '../filter/FilterSection';
|
||||
|
||||
export type RoleTypeaheadProps = Omit<
|
||||
ExpandedTypeaheadProps,
|
||||
'label' | 'onQueryChange' | 'options'
|
||||
>;
|
||||
|
||||
const ROLES: FilterChoices = Object.entries(JobTitleLabels).map(
|
||||
([slug, label]) => ({
|
||||
id: slug,
|
||||
label,
|
||||
value: slug,
|
||||
}),
|
||||
);
|
||||
export default function RoleTypeahead(props: RoleTypeaheadProps) {
|
||||
return (
|
||||
<ExpandedTypeahead
|
||||
|
@ -1,5 +1,6 @@
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
|
||||
import { Button, Collapsible, Select, TextArea } from '@tih/ui';
|
||||
@ -11,6 +12,7 @@ import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
|
||||
|
||||
import { APP_TITLE } from '~/utils/questions/constants';
|
||||
import createSlug from '~/utils/questions/createSlug';
|
||||
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
|
||||
import { useFormRegister } from '~/utils/questions/useFormRegister';
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
@ -52,6 +54,14 @@ export default function QuestionPage() {
|
||||
{ questionId: questionId as string },
|
||||
]);
|
||||
|
||||
const relabeledAggregatedEncounters = useMemo(() => {
|
||||
if (!aggregatedEncounters) {
|
||||
return aggregatedEncounters;
|
||||
}
|
||||
|
||||
return relabelQuestionAggregates(aggregatedEncounters);
|
||||
}, [aggregatedEncounters]);
|
||||
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const { data: comments } = trpc.useQuery([
|
||||
@ -138,11 +148,11 @@ export default function QuestionPage() {
|
||||
<div className="flex max-w-7xl flex-1 flex-col gap-2">
|
||||
<FullQuestionCard
|
||||
{...question}
|
||||
companies={aggregatedEncounters?.companyCounts ?? {}}
|
||||
locations={aggregatedEncounters?.locationCounts ?? {}}
|
||||
companies={relabeledAggregatedEncounters?.companyCounts ?? {}}
|
||||
locations={relabeledAggregatedEncounters?.locationCounts ?? {}}
|
||||
questionId={question.id}
|
||||
receivedCount={undefined}
|
||||
roles={aggregatedEncounters?.roleCounts ?? {}}
|
||||
roles={relabeledAggregatedEncounters?.roleCounts ?? {}}
|
||||
timestamp={question.seenAt.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
|
@ -14,6 +14,7 @@ import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
|
||||
import CompanyTypeahead from '~/components/questions/typeahead/CompanyTypeahead';
|
||||
import LocationTypeahead from '~/components/questions/typeahead/LocationTypeahead';
|
||||
import RoleTypeahead from '~/components/questions/typeahead/RoleTypeahead';
|
||||
import { JobTitleLabels } from '~/components/shared/JobTitles';
|
||||
|
||||
import type { QuestionAge } from '~/utils/questions/constants';
|
||||
import { SORT_TYPES } from '~/utils/questions/constants';
|
||||
@ -21,6 +22,7 @@ import { SORT_ORDERS } from '~/utils/questions/constants';
|
||||
import { APP_TITLE } from '~/utils/questions/constants';
|
||||
import { QUESTION_AGES, QUESTION_TYPES } from '~/utils/questions/constants';
|
||||
import createSlug from '~/utils/questions/createSlug';
|
||||
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
|
||||
import {
|
||||
useSearchParam,
|
||||
useSearchParamSingle,
|
||||
@ -176,7 +178,7 @@ export default function QuestionsBrowsePage() {
|
||||
return undefined;
|
||||
}
|
||||
return questionsQueryData.pages.reduce(
|
||||
(acc, page) => acc + page.data.length,
|
||||
(acc, page) => acc + (page.data.length as number),
|
||||
0,
|
||||
);
|
||||
}, [questionsQueryData]);
|
||||
@ -275,7 +277,7 @@ export default function QuestionsBrowsePage() {
|
||||
return selectedRoles.map((role) => ({
|
||||
checked: true,
|
||||
id: role,
|
||||
label: role,
|
||||
label: JobTitleLabels[role as keyof typeof JobTitleLabels],
|
||||
value: role,
|
||||
}));
|
||||
}, [selectedRoles]);
|
||||
@ -371,7 +373,7 @@ export default function QuestionsBrowsePage() {
|
||||
setSelectedRoles([...selectedRoles, option.value]);
|
||||
} else {
|
||||
setSelectedRoles(
|
||||
selectedCompanies.filter((role) => role !== option.value),
|
||||
selectedRoles.filter((role) => role !== option.value),
|
||||
);
|
||||
}
|
||||
}}
|
||||
@ -445,20 +447,20 @@ export default function QuestionsBrowsePage() {
|
||||
<main className="flex flex-1 flex-col items-stretch">
|
||||
<div className="flex h-full flex-1">
|
||||
<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-8">
|
||||
<ContributeQuestionCard
|
||||
onSubmit={(data) => {
|
||||
createQuestion({
|
||||
companyId: data.company,
|
||||
content: data.questionContent,
|
||||
location: data.location,
|
||||
questionType: data.questionType,
|
||||
role: data.role,
|
||||
seenAt: data.date,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="m-4 flex max-w-3xl flex-1 flex-col items-stretch justify-start gap-8">
|
||||
<ContributeQuestionCard
|
||||
onSubmit={(data) => {
|
||||
createQuestion({
|
||||
companyId: data.company,
|
||||
content: data.questionContent,
|
||||
location: data.location,
|
||||
questionType: data.questionType,
|
||||
role: data.role,
|
||||
seenAt: data.date,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="sticky top-0 border-b border-slate-300 bg-slate-50 py-4">
|
||||
<QuestionSearchBar
|
||||
sortOrderOptions={SORT_ORDERS}
|
||||
sortOrderValue={sortOrder}
|
||||
@ -470,28 +472,29 @@ export default function QuestionsBrowsePage() {
|
||||
onSortOrderChange={setSortOrder}
|
||||
onSortTypeChange={setSortType}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 pb-4">
|
||||
{(questionsQueryData?.pages ?? []).flatMap(
|
||||
({ data: questions }) =>
|
||||
questions.map((question) => (
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 pb-4">
|
||||
{(questionsQueryData?.pages ?? []).flatMap(
|
||||
({ data: questions }) =>
|
||||
questions.map((question) => {
|
||||
const { companyCounts, locationCounts, roleCounts } =
|
||||
relabelQuestionAggregates(
|
||||
question.aggregatedQuestionEncounters,
|
||||
);
|
||||
|
||||
return (
|
||||
<QuestionOverviewCard
|
||||
key={question.id}
|
||||
answerCount={question.numAnswers}
|
||||
companies={
|
||||
question.aggregatedQuestionEncounters.companyCounts
|
||||
}
|
||||
companies={companyCounts}
|
||||
content={question.content}
|
||||
href={`/questions/${question.id}/${createSlug(
|
||||
question.content,
|
||||
)}`}
|
||||
locations={
|
||||
question.aggregatedQuestionEncounters.locationCounts
|
||||
}
|
||||
locations={locationCounts}
|
||||
questionId={question.id}
|
||||
receivedCount={question.receivedCount}
|
||||
roles={
|
||||
question.aggregatedQuestionEncounters.roleCounts
|
||||
}
|
||||
roles={roleCounts}
|
||||
timestamp={question.seenAt.toLocaleDateString(
|
||||
undefined,
|
||||
{
|
||||
@ -502,25 +505,25 @@ export default function QuestionsBrowsePage() {
|
||||
type={question.type}
|
||||
upvoteCount={question.numVotes}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
<Button
|
||||
disabled={!hasNextPage || isFetchingNextPage}
|
||||
isLoading={isFetchingNextPage}
|
||||
label={hasNextPage ? 'Load more' : 'Nothing more to load'}
|
||||
variant="tertiary"
|
||||
onClick={() => {
|
||||
fetchNextPage();
|
||||
}}
|
||||
/>
|
||||
{questionCount === 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>Nothing found.</p>
|
||||
{hasFilters && <p>Try changing your search criteria.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
<Button
|
||||
disabled={!hasNextPage || isFetchingNextPage}
|
||||
isLoading={isFetchingNextPage}
|
||||
label={hasNextPage ? 'Load more' : 'Nothing more to load'}
|
||||
variant="tertiary"
|
||||
onClick={() => {
|
||||
fetchNextPage();
|
||||
}}
|
||||
/>
|
||||
{questionCount === 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>Nothing found.</p>
|
||||
{hasFilters && <p>Try changing your search criteria.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
@ -15,6 +15,7 @@ import DeleteListDialog from '~/components/questions/DeleteListDialog';
|
||||
import { Button } from '~/../../../packages/ui/dist';
|
||||
import { APP_TITLE } from '~/utils/questions/constants';
|
||||
import createSlug from '~/utils/questions/createSlug';
|
||||
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
export default function ListPage() {
|
||||
@ -172,37 +173,38 @@ export default function ListPage() {
|
||||
{lists?.[selectedListIndex] && (
|
||||
<div className="flex flex-col gap-4 pb-4">
|
||||
{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 });
|
||||
}}
|
||||
/>
|
||||
),
|
||||
({ question, id: entryId }) => {
|
||||
const { companyCounts, locationCounts, roleCounts } =
|
||||
relabelQuestionAggregates(
|
||||
question.aggregatedQuestionEncounters,
|
||||
);
|
||||
|
||||
return (
|
||||
<QuestionListCard
|
||||
key={question.id}
|
||||
companies={companyCounts}
|
||||
content={question.content}
|
||||
href={`/questions/${question.id}/${createSlug(
|
||||
question.content,
|
||||
)}`}
|
||||
locations={locationCounts}
|
||||
questionId={question.id}
|
||||
receivedCount={question.receivedCount}
|
||||
roles={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">
|
||||
|
26
apps/portal/src/utils/questions/relabelQuestionAggregates.ts
Normal file
26
apps/portal/src/utils/questions/relabelQuestionAggregates.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { JobTitleLabels } from '~/components/shared/JobTitles';
|
||||
|
||||
import type { AggregatedQuestionEncounter } from '~/types/questions';
|
||||
|
||||
export default function relabelQuestionAggregates({
|
||||
locationCounts,
|
||||
companyCounts,
|
||||
roleCounts,
|
||||
latestSeenAt,
|
||||
}: AggregatedQuestionEncounter) {
|
||||
const newRoleCounts = Object.fromEntries(
|
||||
Object.entries(roleCounts).map(([roleId, count]) => [
|
||||
JobTitleLabels[roleId as keyof typeof JobTitleLabels],
|
||||
count,
|
||||
]),
|
||||
);
|
||||
|
||||
const relabeledAggregate: AggregatedQuestionEncounter = {
|
||||
companyCounts,
|
||||
latestSeenAt,
|
||||
locationCounts,
|
||||
roleCounts: newRoleCounts,
|
||||
};
|
||||
|
||||
return relabeledAggregate;
|
||||
}
|
Reference in New Issue
Block a user