mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2026-04-04 03:18:05 +08:00
[questions][fix] fix expanded typeaheads (#516)
This commit is contained in:
@@ -186,7 +186,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="flex flex-wrap items-center gap-2 text-slate-500">
|
||||
{showAggregateStatistics && (
|
||||
<>
|
||||
<QuestionTypeBadge type={type} />
|
||||
|
||||
@@ -6,8 +6,13 @@ import { Button } from '@tih/ui';
|
||||
import type { Month } from '~/components/shared/MonthYearPicker';
|
||||
import MonthYearPicker from '~/components/shared/MonthYearPicker';
|
||||
|
||||
import useCompanyOptions from '~/utils/shared/useCompanyOptions';
|
||||
import useJobTitleOptions from '~/utils/shared/useJobTitleOptions';
|
||||
|
||||
import CompanyTypeahead from '../typeahead/CompanyTypeahead';
|
||||
import LocationTypeahead from '../typeahead/LocationTypeahead';
|
||||
import LocationTypeahead, {
|
||||
useLocationOptions,
|
||||
} from '../typeahead/LocationTypeahead';
|
||||
import RoleTypeahead from '../typeahead/RoleTypeahead';
|
||||
|
||||
import type { Location } from '~/types/questions';
|
||||
@@ -43,6 +48,10 @@ export default function CreateQuestionEncounterForm({
|
||||
startOfMonth(new Date()),
|
||||
);
|
||||
|
||||
const { data: allCompanyOptions } = useCompanyOptions('');
|
||||
const { data: allLocationOptions } = useLocationOptions('');
|
||||
const allRoleOptions = useJobTitleOptions('');
|
||||
|
||||
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">
|
||||
@@ -53,7 +62,7 @@ export default function CreateQuestionEncounterForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-md text-slate-600">
|
||||
I saw this question {step <= 1 ? 'at' : step === 2 ? 'for' : 'on'}
|
||||
</p>
|
||||
@@ -62,8 +71,8 @@ export default function CreateQuestionEncounterForm({
|
||||
<CompanyTypeahead
|
||||
isLabelHidden={true}
|
||||
placeholder="Company"
|
||||
// TODO: Fix suggestions and set count back to 3
|
||||
suggestedCount={0}
|
||||
suggestedCount={3}
|
||||
suggestedOptions={allCompanyOptions}
|
||||
onSelect={({ value: company }) => {
|
||||
setSelectedCompany(company);
|
||||
}}
|
||||
@@ -79,7 +88,8 @@ export default function CreateQuestionEncounterForm({
|
||||
<LocationTypeahead
|
||||
isLabelHidden={true}
|
||||
placeholder="Location"
|
||||
suggestedCount={0}
|
||||
suggestedCount={3}
|
||||
suggestedOptions={allLocationOptions}
|
||||
onSelect={(location) => {
|
||||
setSelectedLocation(location);
|
||||
}}
|
||||
@@ -95,7 +105,8 @@ export default function CreateQuestionEncounterForm({
|
||||
<RoleTypeahead
|
||||
isLabelHidden={true}
|
||||
placeholder="Role"
|
||||
suggestedCount={0}
|
||||
suggestedCount={3}
|
||||
suggestedOptions={allRoleOptions}
|
||||
onSelect={({ value: role }) => {
|
||||
setSelectedRole(role);
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { trpc } from '~/utils/trpc';
|
||||
import useCompanyOptions from '~/utils/shared/useCompanyOptions';
|
||||
|
||||
import type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
|
||||
import ExpandedTypeahead from './ExpandedTypeahead';
|
||||
@@ -13,22 +13,7 @@ export type CompanyTypeaheadProps = Omit<
|
||||
export default function CompanyTypeahead(props: CompanyTypeaheadProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const { data: companies, isLoading } = trpc.useQuery([
|
||||
'companies.list',
|
||||
{
|
||||
name: query,
|
||||
},
|
||||
]);
|
||||
|
||||
const companyOptions = useMemo(() => {
|
||||
return (
|
||||
companies?.map(({ id, name }) => ({
|
||||
id,
|
||||
label: name,
|
||||
value: id,
|
||||
})) ?? []
|
||||
);
|
||||
}, [companies]);
|
||||
const { data: companyOptions, isLoading } = useCompanyOptions(query);
|
||||
|
||||
return (
|
||||
<ExpandedTypeahead
|
||||
|
||||
@@ -13,11 +13,12 @@ export type ExpandedTypeaheadProps = Omit<
|
||||
'nullable' | 'onSelect'
|
||||
> &
|
||||
RequireAllOrNone<{
|
||||
clearOnSelect?: boolean;
|
||||
filterOption: (option: TypeaheadOption) => boolean;
|
||||
onSuggestionClick: (option: TypeaheadOption) => void;
|
||||
suggestedCount: number;
|
||||
suggestedOptions: Array<TypeaheadOption>;
|
||||
}> & {
|
||||
clearOnSelect?: boolean;
|
||||
filterOption?: (option: TypeaheadOption) => boolean;
|
||||
onChange?: unknown; // Workaround: This prop is here just to absorb the onChange returned react-hook-form
|
||||
onSelect: (option: TypeaheadOption) => void;
|
||||
};
|
||||
@@ -25,6 +26,7 @@ export type ExpandedTypeaheadProps = Omit<
|
||||
export default function ExpandedTypeahead({
|
||||
suggestedCount = 0,
|
||||
onSuggestionClick,
|
||||
suggestedOptions = [],
|
||||
filterOption = () => true,
|
||||
clearOnSelect = false,
|
||||
options,
|
||||
@@ -37,21 +39,22 @@ export default function ExpandedTypeahead({
|
||||
return options.filter(filterOption);
|
||||
}, [options, filterOption]);
|
||||
const suggestions = useMemo(
|
||||
() => filteredOptions.slice(0, suggestedCount),
|
||||
[filteredOptions, suggestedCount],
|
||||
() => suggestedOptions.slice(0, suggestedCount),
|
||||
[suggestedOptions, suggestedCount],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{suggestions.map((suggestion) => (
|
||||
<Button
|
||||
key={suggestion.id}
|
||||
label={suggestion.label}
|
||||
variant="tertiary"
|
||||
onClick={() => {
|
||||
onSuggestionClick?.(suggestion);
|
||||
}}
|
||||
/>
|
||||
<div key={suggestion.id} className="hidden lg:block">
|
||||
<Button
|
||||
label={suggestion.label}
|
||||
variant="tertiary"
|
||||
onClick={() => {
|
||||
onSuggestionClick?.(suggestion);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex-1">
|
||||
<Typeahead
|
||||
|
||||
@@ -16,14 +16,8 @@ export type LocationTypeaheadProps = Omit<
|
||||
onSuggestionClick?: (option: Location) => void;
|
||||
};
|
||||
|
||||
export default function LocationTypeahead({
|
||||
onSelect,
|
||||
onSuggestionClick,
|
||||
...restProps
|
||||
}: LocationTypeaheadProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const { data: locations, isLoading } = trpc.useQuery([
|
||||
export function useLocationOptions(query: string) {
|
||||
const { data: locations, ...restQuery } = trpc.useQuery([
|
||||
'locations.cities.list',
|
||||
{
|
||||
name: query,
|
||||
@@ -43,6 +37,21 @@ export default function LocationTypeahead({
|
||||
);
|
||||
}, [locations]);
|
||||
|
||||
return {
|
||||
data: locationOptions,
|
||||
...restQuery,
|
||||
};
|
||||
}
|
||||
|
||||
export default function LocationTypeahead({
|
||||
onSelect,
|
||||
onSuggestionClick,
|
||||
...restProps
|
||||
}: LocationTypeaheadProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const { data: locationOptions, isLoading } = useLocationOptions(query);
|
||||
|
||||
return (
|
||||
<ExpandedTypeahead
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { JobTitleLabels } from '~/components/shared/JobTitles';
|
||||
import useJobTitleOptions from '~/utils/shared/useJobTitleOptions';
|
||||
|
||||
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) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const roleOptions = useJobTitleOptions(query);
|
||||
|
||||
return (
|
||||
<ExpandedTypeahead
|
||||
{...(props as ExpandedTypeaheadProps)}
|
||||
label="Role"
|
||||
options={ROLES.filter((option) =>
|
||||
options={roleOptions.filter((option) =>
|
||||
option.label
|
||||
.toLocaleLowerCase()
|
||||
.includes(query.trim().toLocaleLowerCase()),
|
||||
|
||||
@@ -22,6 +22,25 @@ type Props = BaseProps &
|
||||
value?: TypeaheadOption | null;
|
||||
}>;
|
||||
|
||||
export function useCityOptions(query: string) {
|
||||
const { data, ...restQuery } = trpc.useQuery([
|
||||
'locations.cities.list',
|
||||
{
|
||||
name: query,
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
data:
|
||||
data?.map(({ id, name, state }) => ({
|
||||
id,
|
||||
label: `${name}, ${state?.name}, ${state?.country?.name}`,
|
||||
value: id,
|
||||
})) ?? [],
|
||||
...restQuery,
|
||||
};
|
||||
}
|
||||
|
||||
export default function CitiesTypeahead({
|
||||
label = 'City',
|
||||
onSelect,
|
||||
@@ -29,14 +48,8 @@ export default function CitiesTypeahead({
|
||||
...props
|
||||
}: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const cities = trpc.useQuery([
|
||||
'locations.cities.list',
|
||||
{
|
||||
name: query,
|
||||
},
|
||||
]);
|
||||
|
||||
const { data, isLoading } = cities;
|
||||
const { data: cityOptions, isLoading } = useCityOptions(query);
|
||||
|
||||
return (
|
||||
<Typeahead
|
||||
@@ -45,13 +58,7 @@ export default function CitiesTypeahead({
|
||||
minQueryLength={3}
|
||||
noResultsMessage="No cities found"
|
||||
nullable={true}
|
||||
options={
|
||||
data?.map(({ id, name, state }) => ({
|
||||
id,
|
||||
label: `${name}, ${state?.name}, ${state?.country?.name}`,
|
||||
value: id,
|
||||
})) ?? []
|
||||
}
|
||||
options={cityOptions}
|
||||
value={value}
|
||||
onQueryChange={setQuery}
|
||||
onSelect={onSelect}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState } from 'react';
|
||||
import type { TypeaheadOption } from '@tih/ui';
|
||||
import { Typeahead } from '@tih/ui';
|
||||
|
||||
import { trpc } from '~/utils/trpc';
|
||||
import useCompanyOptions from '~/utils/shared/useCompanyOptions';
|
||||
|
||||
type BaseProps = Pick<
|
||||
ComponentProps<typeof Typeahead>,
|
||||
@@ -27,14 +27,8 @@ export default function CompaniesTypeahead({
|
||||
...props
|
||||
}: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const companies = trpc.useQuery([
|
||||
'companies.list',
|
||||
{
|
||||
name: query,
|
||||
},
|
||||
]);
|
||||
|
||||
const { data, isLoading } = companies;
|
||||
const { data: companyOptions, isLoading } = useCompanyOptions(query);
|
||||
|
||||
return (
|
||||
<Typeahead
|
||||
@@ -42,13 +36,7 @@ export default function CompaniesTypeahead({
|
||||
label="Company"
|
||||
noResultsMessage="No companies found"
|
||||
nullable={true}
|
||||
options={
|
||||
data?.map(({ id, name }) => ({
|
||||
id,
|
||||
label: name,
|
||||
value: id,
|
||||
})) ?? []
|
||||
}
|
||||
options={companyOptions}
|
||||
value={value}
|
||||
onQueryChange={setQuery}
|
||||
onSelect={onSelect}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState } from 'react';
|
||||
import type { TypeaheadOption } from '@tih/ui';
|
||||
import { Typeahead } from '@tih/ui';
|
||||
|
||||
import { trpc } from '~/utils/trpc';
|
||||
import useCountryOptions from '~/utils/shared/useCountryOptions';
|
||||
|
||||
type BaseProps = Pick<
|
||||
ComponentProps<typeof Typeahead>,
|
||||
@@ -23,16 +23,6 @@ type Props = BaseProps &
|
||||
value?: TypeaheadOption | null;
|
||||
}>;
|
||||
|
||||
function stringPositionComparator(a: string, b: string, query: string): number {
|
||||
const normalizedQueryString = query.trim().toLocaleLowerCase();
|
||||
const positionA = a.toLocaleLowerCase().indexOf(normalizedQueryString);
|
||||
const positionB = b.toLocaleLowerCase().indexOf(normalizedQueryString);
|
||||
return (
|
||||
(positionA === -1 ? 9999 : positionA) -
|
||||
(positionB === -1 ? 9999 : positionB)
|
||||
);
|
||||
}
|
||||
|
||||
export default function CountriesTypeahead({
|
||||
excludedValues,
|
||||
label = 'Country',
|
||||
@@ -41,14 +31,7 @@ export default function CountriesTypeahead({
|
||||
...props
|
||||
}: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const countries = trpc.useQuery([
|
||||
'locations.countries.list',
|
||||
{
|
||||
name: query,
|
||||
},
|
||||
]);
|
||||
|
||||
const { data, isLoading } = countries;
|
||||
const { data: countryOptions, isLoading } = useCountryOptions(query);
|
||||
|
||||
return (
|
||||
<Typeahead
|
||||
@@ -56,34 +39,9 @@ export default function CountriesTypeahead({
|
||||
label={label}
|
||||
noResultsMessage="No countries found"
|
||||
nullable={true}
|
||||
options={(data ?? [])
|
||||
// Client-side sorting by position of query string appearing
|
||||
// in the country name since we can't do that in Prisma.
|
||||
.sort((a, b) => {
|
||||
const normalizedQueryString = query.trim().toLocaleLowerCase();
|
||||
if (
|
||||
a.code.toLocaleLowerCase() === normalizedQueryString ||
|
||||
b.code.toLocaleLowerCase() === normalizedQueryString
|
||||
) {
|
||||
return stringPositionComparator(
|
||||
a.code,
|
||||
b.code,
|
||||
normalizedQueryString,
|
||||
);
|
||||
}
|
||||
|
||||
return stringPositionComparator(
|
||||
a.name,
|
||||
b.name,
|
||||
normalizedQueryString,
|
||||
);
|
||||
})
|
||||
.map(({ id, name }) => ({
|
||||
id,
|
||||
label: name,
|
||||
value: id,
|
||||
}))
|
||||
.filter((option) => !excludedValues?.has(option.value))}
|
||||
options={countryOptions.filter(
|
||||
(option) => !excludedValues?.has(option.value),
|
||||
)}
|
||||
value={value}
|
||||
onQueryChange={setQuery}
|
||||
onSelect={onSelect}
|
||||
|
||||
@@ -78,5 +78,5 @@ export const JobTitleLabels: JobTitleData = {
|
||||
export type JobTitleType = keyof typeof JobTitleLabels;
|
||||
|
||||
export function getLabelForJobTitleType(jobTitle: JobTitleType): string {
|
||||
return JobTitleLabels[jobTitle].label;
|
||||
return JobTitleLabels[jobTitle]?.label ?? '';
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState } from 'react';
|
||||
import type { TypeaheadOption } from '@tih/ui';
|
||||
import { Typeahead } from '@tih/ui';
|
||||
|
||||
import { JobTitleLabels } from './JobTitles';
|
||||
import useJobTitleOptions from '~/utils/shared/useJobTitleOptions';
|
||||
|
||||
type BaseProps = Pick<
|
||||
ComponentProps<typeof Typeahead>,
|
||||
@@ -33,18 +33,10 @@ export default function JobTitlesTypeahead({
|
||||
...props
|
||||
}: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const options = Object.entries(JobTitleLabels)
|
||||
.map(([slug, { label, ranking }]) => ({
|
||||
id: slug,
|
||||
label,
|
||||
ranking,
|
||||
value: slug,
|
||||
}))
|
||||
.filter(({ label }) =>
|
||||
label.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()),
|
||||
)
|
||||
.filter((option) => !excludedValues?.has(option.value))
|
||||
.sort((a, b) => b.ranking - a.ranking);
|
||||
const jobTitleOptions = useJobTitleOptions(query);
|
||||
const options = jobTitleOptions.filter(
|
||||
(option) => !excludedValues?.has(option.value),
|
||||
);
|
||||
|
||||
return (
|
||||
<Typeahead
|
||||
|
||||
22
apps/portal/src/utils/shared/useCompanyOptions.ts
Normal file
22
apps/portal/src/utils/shared/useCompanyOptions.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { trpc } from '../trpc';
|
||||
|
||||
export default function useCompanyOptions(query: string) {
|
||||
const companies = trpc.useQuery([
|
||||
'companies.list',
|
||||
{
|
||||
name: query,
|
||||
},
|
||||
]);
|
||||
|
||||
const { data, ...restQuery } = companies;
|
||||
|
||||
return {
|
||||
data:
|
||||
data?.map(({ id, name }) => ({
|
||||
id,
|
||||
label: name,
|
||||
value: id,
|
||||
})) ?? [],
|
||||
...restQuery,
|
||||
};
|
||||
}
|
||||
55
apps/portal/src/utils/shared/useCountryOptions.ts
Normal file
55
apps/portal/src/utils/shared/useCountryOptions.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Country } from '@prisma/client';
|
||||
|
||||
import { trpc } from '../trpc';
|
||||
|
||||
function stringPositionComparator(a: string, b: string, query: string): number {
|
||||
const normalizedQueryString = query.trim().toLocaleLowerCase();
|
||||
const positionA = a.toLocaleLowerCase().indexOf(normalizedQueryString);
|
||||
const positionB = b.toLocaleLowerCase().indexOf(normalizedQueryString);
|
||||
return (
|
||||
(positionA === -1 ? 9999 : positionA) -
|
||||
(positionB === -1 ? 9999 : positionB)
|
||||
);
|
||||
}
|
||||
|
||||
export function useCompareCountry(query: string) {
|
||||
return (a: Country, b: Country) => {
|
||||
const normalizedQueryString = query.trim().toLocaleLowerCase();
|
||||
if (
|
||||
a.code.toLocaleLowerCase() === normalizedQueryString ||
|
||||
b.code.toLocaleLowerCase() === normalizedQueryString
|
||||
) {
|
||||
return stringPositionComparator(a.code, b.code, normalizedQueryString);
|
||||
}
|
||||
|
||||
return stringPositionComparator(a.name, b.name, normalizedQueryString);
|
||||
};
|
||||
}
|
||||
|
||||
export default function useCountryOptions(query: string) {
|
||||
const countries = trpc.useQuery([
|
||||
'locations.countries.list',
|
||||
{
|
||||
name: query,
|
||||
},
|
||||
]);
|
||||
|
||||
const { data, ...restQuery } = countries;
|
||||
|
||||
const compareCountry = useCompareCountry(query);
|
||||
|
||||
const countryOptions = (data ?? [])
|
||||
// Client-side sorting by position of query string appearing
|
||||
// in the country name since we can't do that in Prisma.
|
||||
.sort(compareCountry)
|
||||
.map(({ id, name }) => ({
|
||||
id,
|
||||
label: name,
|
||||
value: id,
|
||||
}));
|
||||
|
||||
return {
|
||||
...restQuery,
|
||||
data: countryOptions,
|
||||
};
|
||||
}
|
||||
18
apps/portal/src/utils/shared/useJobTitleOptions.ts
Normal file
18
apps/portal/src/utils/shared/useJobTitleOptions.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { JobTitleLabels } from '~/components/shared/JobTitles';
|
||||
|
||||
const sortedJobTitleOptions = Object.entries(JobTitleLabels)
|
||||
.map(([slug, { label, ranking }]) => ({
|
||||
id: slug,
|
||||
label,
|
||||
ranking,
|
||||
value: slug,
|
||||
}))
|
||||
.sort((a, b) => b.ranking - a.ranking);
|
||||
|
||||
export default function useJobTitleOptions(query: string) {
|
||||
const jobTitles = sortedJobTitleOptions.filter(({ label }) =>
|
||||
label.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()),
|
||||
);
|
||||
|
||||
return jobTitles;
|
||||
}
|
||||
Reference in New Issue
Block a user