From 6792e20f0f344c87658e649c3092349f16cb2f71 Mon Sep 17 00:00:00 2001 From: Jeff Sieu Date: Sun, 6 Nov 2022 18:03:08 +0800 Subject: [PATCH] [questions][fix] fix expanded typeaheads (#516) --- .../card/question/BaseQuestionCard.tsx | 2 +- .../forms/CreateQuestionEncounterForm.tsx | 23 ++++++-- .../questions/typeahead/CompanyTypeahead.tsx | 21 +------ .../questions/typeahead/ExpandedTypeahead.tsx | 29 +++++----- .../questions/typeahead/LocationTypeahead.tsx | 25 ++++++--- .../questions/typeahead/RoleTypeahead.tsx | 14 ++--- .../src/components/shared/CitiesTypeahead.tsx | 35 +++++++----- .../components/shared/CompaniesTypeahead.tsx | 18 +----- .../components/shared/CountriesTypeahead.tsx | 52 ++---------------- .../portal/src/components/shared/JobTitles.ts | 2 +- .../components/shared/JobTitlesTypeahead.tsx | 18 ++---- .../src/utils/shared/useCompanyOptions.ts | 22 ++++++++ .../src/utils/shared/useCountryOptions.ts | 55 +++++++++++++++++++ .../src/utils/shared/useJobTitleOptions.ts | 18 ++++++ 14 files changed, 188 insertions(+), 146 deletions(-) create mode 100644 apps/portal/src/utils/shared/useCompanyOptions.ts create mode 100644 apps/portal/src/utils/shared/useCountryOptions.ts create mode 100644 apps/portal/src/utils/shared/useJobTitleOptions.ts diff --git a/apps/portal/src/components/questions/card/question/BaseQuestionCard.tsx b/apps/portal/src/components/questions/card/question/BaseQuestionCard.tsx index 0a123397..a917bc87 100644 --- a/apps/portal/src/components/questions/card/question/BaseQuestionCard.tsx +++ b/apps/portal/src/components/questions/card/question/BaseQuestionCard.tsx @@ -186,7 +186,7 @@ export default function BaseQuestionCard({ )}
-
+
{showAggregateStatistics && ( <> diff --git a/apps/portal/src/components/questions/forms/CreateQuestionEncounterForm.tsx b/apps/portal/src/components/questions/forms/CreateQuestionEncounterForm.tsx index 6f65bf22..f158ede5 100644 --- a/apps/portal/src/components/questions/forms/CreateQuestionEncounterForm.tsx +++ b/apps/portal/src/components/questions/forms/CreateQuestionEncounterForm.tsx @@ -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 (
@@ -53,7 +62,7 @@ export default function CreateQuestionEncounterForm({ } return ( -
+

I saw this question {step <= 1 ? 'at' : step === 2 ? 'for' : 'on'}

@@ -62,8 +71,8 @@ export default function CreateQuestionEncounterForm({ { setSelectedCompany(company); }} @@ -79,7 +88,8 @@ export default function CreateQuestionEncounterForm({ { setSelectedLocation(location); }} @@ -95,7 +105,8 @@ export default function CreateQuestionEncounterForm({ { setSelectedRole(role); }} diff --git a/apps/portal/src/components/questions/typeahead/CompanyTypeahead.tsx b/apps/portal/src/components/questions/typeahead/CompanyTypeahead.tsx index e3cbaa98..a00cd3c7 100644 --- a/apps/portal/src/components/questions/typeahead/CompanyTypeahead.tsx +++ b/apps/portal/src/components/questions/typeahead/CompanyTypeahead.tsx @@ -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 ( & RequireAllOrNone<{ - clearOnSelect?: boolean; - filterOption: (option: TypeaheadOption) => boolean; onSuggestionClick: (option: TypeaheadOption) => void; suggestedCount: number; + suggestedOptions: Array; }> & { + 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 ( -
+
{suggestions.map((suggestion) => ( -
))}
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 ( ; -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 ( + options={roleOptions.filter((option) => option.label .toLocaleLowerCase() .includes(query.trim().toLocaleLowerCase()), diff --git a/apps/portal/src/components/shared/CitiesTypeahead.tsx b/apps/portal/src/components/shared/CitiesTypeahead.tsx index f24a4035..12812a7a 100644 --- a/apps/portal/src/components/shared/CitiesTypeahead.tsx +++ b/apps/portal/src/components/shared/CitiesTypeahead.tsx @@ -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 ( ({ - id, - label: `${name}, ${state?.name}, ${state?.country?.name}`, - value: id, - })) ?? [] - } + options={cityOptions} value={value} onQueryChange={setQuery} onSelect={onSelect} diff --git a/apps/portal/src/components/shared/CompaniesTypeahead.tsx b/apps/portal/src/components/shared/CompaniesTypeahead.tsx index d7a43b14..07d61669 100644 --- a/apps/portal/src/components/shared/CompaniesTypeahead.tsx +++ b/apps/portal/src/components/shared/CompaniesTypeahead.tsx @@ -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, @@ -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 ( ({ - id, - label: name, - value: id, - })) ?? [] - } + options={companyOptions} value={value} onQueryChange={setQuery} onSelect={onSelect} diff --git a/apps/portal/src/components/shared/CountriesTypeahead.tsx b/apps/portal/src/components/shared/CountriesTypeahead.tsx index 7784d7f4..2f4bb2a0 100644 --- a/apps/portal/src/components/shared/CountriesTypeahead.tsx +++ b/apps/portal/src/components/shared/CountriesTypeahead.tsx @@ -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, @@ -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 ( { - 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} diff --git a/apps/portal/src/components/shared/JobTitles.ts b/apps/portal/src/components/shared/JobTitles.ts index d992a79d..f11cbce3 100644 --- a/apps/portal/src/components/shared/JobTitles.ts +++ b/apps/portal/src/components/shared/JobTitles.ts @@ -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 ?? ''; } diff --git a/apps/portal/src/components/shared/JobTitlesTypeahead.tsx b/apps/portal/src/components/shared/JobTitlesTypeahead.tsx index 9f560fce..131db3d0 100644 --- a/apps/portal/src/components/shared/JobTitlesTypeahead.tsx +++ b/apps/portal/src/components/shared/JobTitlesTypeahead.tsx @@ -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, @@ -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 ( ({ + id, + label: name, + value: id, + })) ?? [], + ...restQuery, + }; +} diff --git a/apps/portal/src/utils/shared/useCountryOptions.ts b/apps/portal/src/utils/shared/useCountryOptions.ts new file mode 100644 index 00000000..f5f1700c --- /dev/null +++ b/apps/portal/src/utils/shared/useCountryOptions.ts @@ -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, + }; +} diff --git a/apps/portal/src/utils/shared/useJobTitleOptions.ts b/apps/portal/src/utils/shared/useJobTitleOptions.ts new file mode 100644 index 00000000..8c8bda9b --- /dev/null +++ b/apps/portal/src/utils/shared/useJobTitleOptions.ts @@ -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; +}