[questions][fix] fix expanded typeaheads (#516)

pull/521/head
Jeff Sieu 2 years ago committed by GitHub
parent c10aa15347
commit 6792e20f0f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -186,7 +186,7 @@ export default function BaseQuestionCard({
)} )}
<div className="flex flex-1 flex-col items-start gap-2"> <div className="flex flex-1 flex-col items-start gap-2">
<div className="flex items-baseline justify-between self-stretch"> <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 && ( {showAggregateStatistics && (
<> <>
<QuestionTypeBadge type={type} /> <QuestionTypeBadge type={type} />

@ -6,8 +6,13 @@ import { Button } from '@tih/ui';
import type { Month } from '~/components/shared/MonthYearPicker'; import type { Month } from '~/components/shared/MonthYearPicker';
import MonthYearPicker 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 CompanyTypeahead from '../typeahead/CompanyTypeahead';
import LocationTypeahead from '../typeahead/LocationTypeahead'; import LocationTypeahead, {
useLocationOptions,
} from '../typeahead/LocationTypeahead';
import RoleTypeahead from '../typeahead/RoleTypeahead'; import RoleTypeahead from '../typeahead/RoleTypeahead';
import type { Location } from '~/types/questions'; import type { Location } from '~/types/questions';
@ -43,6 +48,10 @@ export default function CreateQuestionEncounterForm({
startOfMonth(new Date()), startOfMonth(new Date()),
); );
const { data: allCompanyOptions } = useCompanyOptions('');
const { data: allLocationOptions } = useLocationOptions('');
const allRoleOptions = useJobTitleOptions('');
if (submitted) { if (submitted) {
return ( 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"> <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 ( return (
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<p className="text-md text-slate-600"> <p className="text-md text-slate-600">
I saw this question {step <= 1 ? 'at' : step === 2 ? 'for' : 'on'} I saw this question {step <= 1 ? 'at' : step === 2 ? 'for' : 'on'}
</p> </p>
@ -62,8 +71,8 @@ export default function CreateQuestionEncounterForm({
<CompanyTypeahead <CompanyTypeahead
isLabelHidden={true} isLabelHidden={true}
placeholder="Company" placeholder="Company"
// TODO: Fix suggestions and set count back to 3 suggestedCount={3}
suggestedCount={0} suggestedOptions={allCompanyOptions}
onSelect={({ value: company }) => { onSelect={({ value: company }) => {
setSelectedCompany(company); setSelectedCompany(company);
}} }}
@ -79,7 +88,8 @@ export default function CreateQuestionEncounterForm({
<LocationTypeahead <LocationTypeahead
isLabelHidden={true} isLabelHidden={true}
placeholder="Location" placeholder="Location"
suggestedCount={0} suggestedCount={3}
suggestedOptions={allLocationOptions}
onSelect={(location) => { onSelect={(location) => {
setSelectedLocation(location); setSelectedLocation(location);
}} }}
@ -95,7 +105,8 @@ export default function CreateQuestionEncounterForm({
<RoleTypeahead <RoleTypeahead
isLabelHidden={true} isLabelHidden={true}
placeholder="Role" placeholder="Role"
suggestedCount={0} suggestedCount={3}
suggestedOptions={allRoleOptions}
onSelect={({ value: role }) => { onSelect={({ value: role }) => {
setSelectedRole(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 type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
import ExpandedTypeahead from './ExpandedTypeahead'; import ExpandedTypeahead from './ExpandedTypeahead';
@ -13,22 +13,7 @@ export type CompanyTypeaheadProps = Omit<
export default function CompanyTypeahead(props: CompanyTypeaheadProps) { export default function CompanyTypeahead(props: CompanyTypeaheadProps) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const { data: companies, isLoading } = trpc.useQuery([ const { data: companyOptions, isLoading } = useCompanyOptions(query);
'companies.list',
{
name: query,
},
]);
const companyOptions = useMemo(() => {
return (
companies?.map(({ id, name }) => ({
id,
label: name,
value: id,
})) ?? []
);
}, [companies]);
return ( return (
<ExpandedTypeahead <ExpandedTypeahead

@ -13,11 +13,12 @@ export type ExpandedTypeaheadProps = Omit<
'nullable' | 'onSelect' 'nullable' | 'onSelect'
> & > &
RequireAllOrNone<{ RequireAllOrNone<{
clearOnSelect?: boolean;
filterOption: (option: TypeaheadOption) => boolean;
onSuggestionClick: (option: TypeaheadOption) => void; onSuggestionClick: (option: TypeaheadOption) => void;
suggestedCount: number; 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 onChange?: unknown; // Workaround: This prop is here just to absorb the onChange returned react-hook-form
onSelect: (option: TypeaheadOption) => void; onSelect: (option: TypeaheadOption) => void;
}; };
@ -25,6 +26,7 @@ export type ExpandedTypeaheadProps = Omit<
export default function ExpandedTypeahead({ export default function ExpandedTypeahead({
suggestedCount = 0, suggestedCount = 0,
onSuggestionClick, onSuggestionClick,
suggestedOptions = [],
filterOption = () => true, filterOption = () => true,
clearOnSelect = false, clearOnSelect = false,
options, options,
@ -37,21 +39,22 @@ export default function ExpandedTypeahead({
return options.filter(filterOption); return options.filter(filterOption);
}, [options, filterOption]); }, [options, filterOption]);
const suggestions = useMemo( const suggestions = useMemo(
() => filteredOptions.slice(0, suggestedCount), () => suggestedOptions.slice(0, suggestedCount),
[filteredOptions, suggestedCount], [suggestedOptions, suggestedCount],
); );
return ( return (
<div className="flex flex-wrap gap-x-2"> <div className="flex flex-wrap gap-2">
{suggestions.map((suggestion) => ( {suggestions.map((suggestion) => (
<div key={suggestion.id} className="hidden lg:block">
<Button <Button
key={suggestion.id}
label={suggestion.label} label={suggestion.label}
variant="tertiary" variant="tertiary"
onClick={() => { onClick={() => {
onSuggestionClick?.(suggestion); onSuggestionClick?.(suggestion);
}} }}
/> />
</div>
))} ))}
<div className="flex-1"> <div className="flex-1">
<Typeahead <Typeahead

@ -16,14 +16,8 @@ export type LocationTypeaheadProps = Omit<
onSuggestionClick?: (option: Location) => void; onSuggestionClick?: (option: Location) => void;
}; };
export default function LocationTypeahead({ export function useLocationOptions(query: string) {
onSelect, const { data: locations, ...restQuery } = trpc.useQuery([
onSuggestionClick,
...restProps
}: LocationTypeaheadProps) {
const [query, setQuery] = useState('');
const { data: locations, isLoading } = trpc.useQuery([
'locations.cities.list', 'locations.cities.list',
{ {
name: query, name: query,
@ -43,6 +37,21 @@ export default function LocationTypeahead({
); );
}, [locations]); }, [locations]);
return {
data: locationOptions,
...restQuery,
};
}
export default function LocationTypeahead({
onSelect,
onSuggestionClick,
...restProps
}: LocationTypeaheadProps) {
const [query, setQuery] = useState('');
const { data: locationOptions, isLoading } = useLocationOptions(query);
return ( return (
<ExpandedTypeahead <ExpandedTypeahead
isLoading={isLoading} isLoading={isLoading}

@ -1,31 +1,25 @@
import { useState } from 'react'; import { useState } from 'react';
import { JobTitleLabels } from '~/components/shared/JobTitles'; import useJobTitleOptions from '~/utils/shared/useJobTitleOptions';
import type { ExpandedTypeaheadProps } from './ExpandedTypeahead'; import type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
import ExpandedTypeahead from './ExpandedTypeahead'; import ExpandedTypeahead from './ExpandedTypeahead';
import type { FilterChoices } from '../filter/FilterSection';
export type RoleTypeaheadProps = Omit< export type RoleTypeaheadProps = Omit<
ExpandedTypeaheadProps, ExpandedTypeaheadProps,
'label' | 'onQueryChange' | 'options' 'label' | 'onQueryChange' | 'options'
>; >;
const ROLES: FilterChoices = Object.entries(JobTitleLabels).map(
([slug, { label }]) => ({
id: slug,
label,
value: slug,
}),
);
export default function RoleTypeahead(props: RoleTypeaheadProps) { export default function RoleTypeahead(props: RoleTypeaheadProps) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const roleOptions = useJobTitleOptions(query);
return ( return (
<ExpandedTypeahead <ExpandedTypeahead
{...(props as ExpandedTypeaheadProps)} {...(props as ExpandedTypeaheadProps)}
label="Role" label="Role"
options={ROLES.filter((option) => options={roleOptions.filter((option) =>
option.label option.label
.toLocaleLowerCase() .toLocaleLowerCase()
.includes(query.trim().toLocaleLowerCase()), .includes(query.trim().toLocaleLowerCase()),

@ -22,6 +22,25 @@ type Props = BaseProps &
value?: TypeaheadOption | null; 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({ export default function CitiesTypeahead({
label = 'City', label = 'City',
onSelect, onSelect,
@ -29,14 +48,8 @@ export default function CitiesTypeahead({
...props ...props
}: Props) { }: Props) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const cities = trpc.useQuery([
'locations.cities.list',
{
name: query,
},
]);
const { data, isLoading } = cities; const { data: cityOptions, isLoading } = useCityOptions(query);
return ( return (
<Typeahead <Typeahead
@ -45,13 +58,7 @@ export default function CitiesTypeahead({
minQueryLength={3} minQueryLength={3}
noResultsMessage="No cities found" noResultsMessage="No cities found"
nullable={true} nullable={true}
options={ options={cityOptions}
data?.map(({ id, name, state }) => ({
id,
label: `${name}, ${state?.name}, ${state?.country?.name}`,
value: id,
})) ?? []
}
value={value} value={value}
onQueryChange={setQuery} onQueryChange={setQuery}
onSelect={onSelect} onSelect={onSelect}

@ -3,7 +3,7 @@ import { useState } from 'react';
import type { TypeaheadOption } from '@tih/ui'; import type { TypeaheadOption } from '@tih/ui';
import { Typeahead } from '@tih/ui'; import { Typeahead } from '@tih/ui';
import { trpc } from '~/utils/trpc'; import useCompanyOptions from '~/utils/shared/useCompanyOptions';
type BaseProps = Pick< type BaseProps = Pick<
ComponentProps<typeof Typeahead>, ComponentProps<typeof Typeahead>,
@ -27,14 +27,8 @@ export default function CompaniesTypeahead({
...props ...props
}: Props) { }: Props) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const companies = trpc.useQuery([
'companies.list',
{
name: query,
},
]);
const { data, isLoading } = companies; const { data: companyOptions, isLoading } = useCompanyOptions(query);
return ( return (
<Typeahead <Typeahead
@ -42,13 +36,7 @@ export default function CompaniesTypeahead({
label="Company" label="Company"
noResultsMessage="No companies found" noResultsMessage="No companies found"
nullable={true} nullable={true}
options={ options={companyOptions}
data?.map(({ id, name }) => ({
id,
label: name,
value: id,
})) ?? []
}
value={value} value={value}
onQueryChange={setQuery} onQueryChange={setQuery}
onSelect={onSelect} onSelect={onSelect}

@ -3,7 +3,7 @@ import { useState } from 'react';
import type { TypeaheadOption } from '@tih/ui'; import type { TypeaheadOption } from '@tih/ui';
import { Typeahead } from '@tih/ui'; import { Typeahead } from '@tih/ui';
import { trpc } from '~/utils/trpc'; import useCountryOptions from '~/utils/shared/useCountryOptions';
type BaseProps = Pick< type BaseProps = Pick<
ComponentProps<typeof Typeahead>, ComponentProps<typeof Typeahead>,
@ -23,16 +23,6 @@ type Props = BaseProps &
value?: TypeaheadOption | null; 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({ export default function CountriesTypeahead({
excludedValues, excludedValues,
label = 'Country', label = 'Country',
@ -41,14 +31,7 @@ export default function CountriesTypeahead({
...props ...props
}: Props) { }: Props) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const countries = trpc.useQuery([ const { data: countryOptions, isLoading } = useCountryOptions(query);
'locations.countries.list',
{
name: query,
},
]);
const { data, isLoading } = countries;
return ( return (
<Typeahead <Typeahead
@ -56,34 +39,9 @@ export default function CountriesTypeahead({
label={label} label={label}
noResultsMessage="No countries found" noResultsMessage="No countries found"
nullable={true} nullable={true}
options={(data ?? []) options={countryOptions.filter(
// Client-side sorting by position of query string appearing (option) => !excludedValues?.has(option.value),
// 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))}
value={value} value={value}
onQueryChange={setQuery} onQueryChange={setQuery}
onSelect={onSelect} onSelect={onSelect}

@ -78,5 +78,5 @@ export const JobTitleLabels: JobTitleData = {
export type JobTitleType = keyof typeof JobTitleLabels; export type JobTitleType = keyof typeof JobTitleLabels;
export function getLabelForJobTitleType(jobTitle: JobTitleType): string { 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 type { TypeaheadOption } from '@tih/ui';
import { Typeahead } from '@tih/ui'; import { Typeahead } from '@tih/ui';
import { JobTitleLabels } from './JobTitles'; import useJobTitleOptions from '~/utils/shared/useJobTitleOptions';
type BaseProps = Pick< type BaseProps = Pick<
ComponentProps<typeof Typeahead>, ComponentProps<typeof Typeahead>,
@ -33,18 +33,10 @@ export default function JobTitlesTypeahead({
...props ...props
}: Props) { }: Props) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const options = Object.entries(JobTitleLabels) const jobTitleOptions = useJobTitleOptions(query);
.map(([slug, { label, ranking }]) => ({ const options = jobTitleOptions.filter(
id: slug, (option) => !excludedValues?.has(option.value),
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);
return ( return (
<Typeahead <Typeahead

@ -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,
};
}

@ -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,
};
}

@ -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;
}
Loading…
Cancel
Save