diff --git a/apps/portal/src/components/questions/forms/ContributeQuestionForm.tsx b/apps/portal/src/components/questions/forms/ContributeQuestionForm.tsx index a1f5777c..4a099c7c 100644 --- a/apps/portal/src/components/questions/forms/ContributeQuestionForm.tsx +++ b/apps/portal/src/components/questions/forms/ContributeQuestionForm.tsx @@ -1,9 +1,10 @@ import { startOfMonth } from 'date-fns'; import { Controller, useForm } from 'react-hook-form'; import type { QuestionsQuestionType } from '@prisma/client'; +import type { TypeaheadOption } from '@tih/ui'; import { Button, HorizontalDivider, Select, TextArea } from '@tih/ui'; -import { LOCATIONS, QUESTION_TYPES, ROLES } from '~/utils/questions/constants'; +import { QUESTION_TYPES } from '~/utils/questions/constants'; import { useFormRegister, useSelectRegister, @@ -15,14 +16,16 @@ import RoleTypeahead from '../typeahead/RoleTypeahead'; import type { Month } from '../../shared/MonthYearPicker'; import MonthYearPicker from '../../shared/MonthYearPicker'; +import type { Location } from '~/types/questions'; + export type ContributeQuestionData = { company: string; date: Date; - location: string; + location: Location & TypeaheadOption; position: string; questionContent: string; questionType: QuestionsQuestionType; - role: string; + role: TypeaheadOption; }; export type ContributeQuestionFormProps = { @@ -79,15 +82,12 @@ export default function ContributeQuestionForm({ name="location" render={({ field }) => ( { // @ts-ignore TODO(questions): handle potentially null value. - field.onChange(option.value); + field.onChange(option); }} - {...field} - value={LOCATIONS.find( - (location) => location.value === field.value, - )} /> )} /> @@ -117,8 +117,9 @@ export default function ContributeQuestionForm({ ( + render={({ field: { value: _, ...field } }) => ( { @@ -134,13 +135,12 @@ export default function ContributeQuestionForm({ name="role" render={({ field }) => ( { // @ts-ignore TODO(questions): handle potentially null value. - field.onChange(option.value); + field.onChange(option); }} - {...field} - value={ROLES.find((role) => role.value === field.value)} /> )} /> diff --git a/apps/portal/src/components/questions/forms/CreateQuestionEncounterForm.tsx b/apps/portal/src/components/questions/forms/CreateQuestionEncounterForm.tsx index fb00e48b..7a4f283e 100644 --- a/apps/portal/src/components/questions/forms/CreateQuestionEncounterForm.tsx +++ b/apps/portal/src/components/questions/forms/CreateQuestionEncounterForm.tsx @@ -9,11 +9,15 @@ import CompanyTypeahead from '../typeahead/CompanyTypeahead'; import LocationTypeahead from '../typeahead/LocationTypeahead'; import RoleTypeahead from '../typeahead/RoleTypeahead'; +import type { Location } from '~/types/questions'; + export type CreateQuestionEncounterData = { + cityId?: string; company: string; - location: string; + countryId: string; role: string; seenAt: Date; + stateId?: string; }; export type CreateQuestionEncounterFormProps = { @@ -28,7 +32,9 @@ export default function CreateQuestionEncounterForm({ const [step, setStep] = useState(0); const [selectedCompany, setSelectedCompany] = useState(null); - const [selectedLocation, setSelectedLocation] = useState(null); + const [selectedLocation, setSelectedLocation] = useState( + null, + ); const [selectedRole, setSelectedRole] = useState(null); const [selectedDate, setSelectedDate] = useState( startOfMonth(new Date()), @@ -61,10 +67,10 @@ export default function CreateQuestionEncounterForm({ placeholder="Other location" suggestedCount={3} // @ts-ignore TODO(questions): handle potentially null value. - onSelect={({ value: location }) => { + onSelect={(location) => { setSelectedLocation(location); }} - onSuggestionClick={({ value: location }) => { + onSuggestionClick={(location) => { setSelectedLocation(location); setStep(step + 1); }} @@ -130,11 +136,14 @@ export default function CreateQuestionEncounterForm({ selectedRole && selectedDate ) { + const { cityId, stateId, countryId } = selectedLocation; onSubmit({ + cityId, company: selectedCompany, - location: selectedLocation, + countryId, role: selectedRole, seenAt: selectedDate, + stateId, }); } }} diff --git a/apps/portal/src/components/questions/typeahead/ExpandedTypeahead.tsx b/apps/portal/src/components/questions/typeahead/ExpandedTypeahead.tsx index a485c6ed..bffe0d3d 100644 --- a/apps/portal/src/components/questions/typeahead/ExpandedTypeahead.tsx +++ b/apps/portal/src/components/questions/typeahead/ExpandedTypeahead.tsx @@ -8,13 +8,16 @@ import type { RequireAllOrNone } from '~/utils/questions/RequireAllOrNone'; type TypeaheadProps = ComponentProps; type TypeaheadOption = TypeaheadProps['options'][number]; -export type ExpandedTypeaheadProps = RequireAllOrNone<{ - clearOnSelect?: boolean; - filterOption: (option: TypeaheadOption) => boolean; - onSuggestionClick: (option: TypeaheadOption) => void; - suggestedCount: number; -}> & - TypeaheadProps; +export type ExpandedTypeaheadProps = Omit & + RequireAllOrNone<{ + clearOnSelect?: boolean; + filterOption: (option: TypeaheadOption) => boolean; + onSuggestionClick: (option: TypeaheadOption) => void; + suggestedCount: number; + }> & { + onChange?: unknown; // Workaround: This prop is here just to absorb the onChange returned react-hook-form + onSelect: (option: TypeaheadOption) => void; + }; export default function ExpandedTypeahead({ suggestedCount = 0, @@ -23,6 +26,7 @@ export default function ExpandedTypeahead({ clearOnSelect = false, options, onSelect, + onChange: _, ...typeaheadProps }: ExpandedTypeaheadProps) { const [key, setKey] = useState(0); @@ -55,7 +59,8 @@ export default function ExpandedTypeahead({ if (clearOnSelect) { setKey((key + 1) % 2); } - onSelect(option); + // TODO: Remove onSelect null coercion once onSelect prop is refactored + onSelect(option!); }} /> diff --git a/apps/portal/src/components/questions/typeahead/LocationTypeahead.tsx b/apps/portal/src/components/questions/typeahead/LocationTypeahead.tsx index 61d67962..9b2d4dc2 100644 --- a/apps/portal/src/components/questions/typeahead/LocationTypeahead.tsx +++ b/apps/portal/src/components/questions/typeahead/LocationTypeahead.tsx @@ -1,21 +1,71 @@ -import { LOCATIONS } from '~/utils/questions/constants'; +import { useMemo, useState } from 'react'; +import type { TypeaheadOption } from '@tih/ui'; + +import { trpc } from '~/utils/trpc'; import type { ExpandedTypeaheadProps } from './ExpandedTypeahead'; import ExpandedTypeahead from './ExpandedTypeahead'; +import type { Location } from '~/types/questions'; + export type LocationTypeaheadProps = Omit< ExpandedTypeaheadProps, - 'label' | 'onQueryChange' | 'options' ->; + 'label' | 'onQueryChange' | 'onSelect' | 'onSuggestionClick' | 'options' +> & { + onSelect: (option: Location & TypeaheadOption) => void; + onSuggestionClick?: (option: Location) => void; +}; + +export default function LocationTypeahead({ + onSelect, + onSuggestionClick, + ...restProps +}: LocationTypeaheadProps) { + const [query, setQuery] = useState(''); + + const { data: locations } = trpc.useQuery([ + 'locations.cities.list', + { + name: query, + }, + ]); + + const locationOptions = useMemo(() => { + return ( + locations?.map(({ id, name, state }) => ({ + cityId: id, + countryId: state.country.id, + id, + label: `${name}, ${state.name}, ${state.country.name}`, + stateId: state.id, + value: id, + })) ?? [] + ); + }, [locations]); -export default function LocationTypeahead(props: LocationTypeaheadProps) { return ( { + const location = locationOptions.find( + (locationOption) => locationOption.id === option.id, + )!; + onSuggestionClick({ + ...location, + ...option, + }); + } + : undefined, + ...restProps, + } as ExpandedTypeaheadProps)} label="Location" - options={LOCATIONS} - // eslint-disable-next-line @typescript-eslint/no-empty-function - onQueryChange={() => {}} + options={locationOptions} + onQueryChange={setQuery} + onSelect={({ id }: TypeaheadOption) => { + const location = locationOptions.find((option) => option.id === id)!; + onSelect(location); + }} /> ); } diff --git a/apps/portal/src/components/questions/typeahead/RoleTypeahead.tsx b/apps/portal/src/components/questions/typeahead/RoleTypeahead.tsx index 60dd3ffa..dac54418 100644 --- a/apps/portal/src/components/questions/typeahead/RoleTypeahead.tsx +++ b/apps/portal/src/components/questions/typeahead/RoleTypeahead.tsx @@ -1,3 +1,5 @@ +import { useState } from 'react'; + import { JobTitleLabels } from '~/components/shared/JobTitles'; import type { ExpandedTypeaheadProps } from './ExpandedTypeahead'; @@ -17,13 +19,16 @@ const ROLES: FilterChoices = Object.entries(JobTitleLabels).map( }), ); export default function RoleTypeahead(props: RoleTypeaheadProps) { + const [query, setQuery] = useState(''); + return ( {}} + options={ROLES.filter((option) => + option.label.toLowerCase().includes(query.toLowerCase()), + )} + onQueryChange={setQuery} /> ); } diff --git a/apps/portal/src/pages/questions/[questionId]/[questionSlug]/index.tsx b/apps/portal/src/pages/questions/[questionId]/[questionSlug]/index.tsx index 0b435c3c..d717bfcc 100644 --- a/apps/portal/src/pages/questions/[questionId]/[questionSlug]/index.tsx +++ b/apps/portal/src/pages/questions/[questionId]/[questionSlug]/index.tsx @@ -203,11 +203,13 @@ export default function QuestionPage() { upvoteCount={question.numVotes} onReceivedSubmit={(data) => { addEncounter({ + cityId: data.cityId, companyId: data.company, - location: data.location, + countryId: data.countryId, questionId: questionId as string, role: data.role, seenAt: data.seenAt, + stateId: data.stateId, }); }} /> diff --git a/apps/portal/src/pages/questions/browse.tsx b/apps/portal/src/pages/questions/browse.tsx index 2aa3c1c3..a66b1104 100644 --- a/apps/portal/src/pages/questions/browse.tsx +++ b/apps/portal/src/pages/questions/browse.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Bars3BottomLeftIcon } from '@heroicons/react/20/solid'; import { NoSymbolIcon } from '@heroicons/react/24/outline'; import type { QuestionsQuestionType } from '@prisma/client'; +import type { TypeaheadOption } from '@tih/ui'; import { Button, SlideOut } from '@tih/ui'; import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard'; @@ -28,9 +29,21 @@ import { } from '~/utils/questions/useSearchParam'; import { trpc } from '~/utils/trpc'; +import type { Location } from '~/types/questions.d'; import { SortType } from '~/types/questions.d'; import { SortOrder } from '~/types/questions.d'; +function locationToSlug(value: Location & TypeaheadOption): string { + return [ + value.countryId, + value.stateId, + value.cityId, + value.id, + value.label, + value.value, + ].join('-'); +} + export default function QuestionsBrowsePage() { const router = useRouter(); @@ -72,7 +85,13 @@ export default function QuestionsBrowsePage() { const [selectedRoles, setSelectedRoles, areRolesInitialized] = useSearchParam('roles'); const [selectedLocations, setSelectedLocations, areLocationsInitialized] = - useSearchParam('locations'); + useSearchParam('locations', { + paramToString: locationToSlug, + stringToParam: (param) => { + const [countryId, stateId, cityId, id, label, value] = param.split('-'); + return { cityId, countryId, id, label, stateId, value }; + }, + }); const [sortOrder, setSortOrder, isSortOrderInitialized] = useSearchParamSingle('sortOrder', { @@ -153,8 +172,10 @@ export default function QuestionsBrowsePage() { [ 'questions.questions.getQuestionsByFilter', { - // TODO: Enable filtering by cities, companies and states - cityIds: [], + // TODO: Enable filtering by countryIds and stateIds + cityIds: selectedLocations + .map(({ cityId }) => cityId) + .filter((id) => id !== undefined) as Array, companyIds: selectedCompanySlugs.map((slug) => slug.split('_')[0]), countryIds: [], endDate: today, @@ -242,7 +263,7 @@ export default function QuestionsBrowsePage() { pathname, query: { companies: selectedCompanySlugs, - locations: selectedLocations, + locations: selectedLocations.map(locationToSlug), questionAge: selectedQuestionAge, questionTypes: selectedQuestionTypes, roles: selectedRoles, @@ -267,12 +288,15 @@ export default function QuestionsBrowsePage() { ]); const selectedCompanyOptions = useMemo(() => { - return selectedCompanySlugs.map((company) => ({ - checked: true, - id: company, - label: company, - value: company, - })); + return selectedCompanySlugs.map((company) => { + const [id, label] = company.split('_'); + return { + checked: true, + id, + label, + value: id, + }; + }); }, [selectedCompanySlugs]); const selectedRoleOptions = useMemo(() => { @@ -287,9 +311,7 @@ export default function QuestionsBrowsePage() { const selectedLocationOptions = useMemo(() => { return selectedLocations.map((location) => ({ checked: true, - id: location, - label: location, - value: location, + ...location, })); }, [selectedLocations]); @@ -355,7 +377,10 @@ export default function QuestionsBrowsePage() { ( + renderInput={({ + onOptionChange, + field: { ref: _, onChange: __, ...field }, + }) => ( ( + renderInput={({ + onOptionChange, + field: { ref: _, onChange: __, ...field }, + }) => ( { return !selectedLocations.some((location) => { - return location === option.value; + return location.id === option.id; }); }} isLabelHidden={true} @@ -435,10 +463,14 @@ export default function QuestionsBrowsePage() { )} onOptionChange={(option) => { if (option.checked) { - setSelectedLocations([...selectedLocations, option.value]); + // TODO: Fix type inference, then remove the `as` cast. + setSelectedLocations([ + ...selectedLocations, + option as unknown as Location & TypeaheadOption, + ]); } else { setSelectedLocations( - selectedLocations.filter((role) => role !== option.value), + selectedLocations.filter((location) => location.id !== option.id), ); } }} @@ -457,13 +489,16 @@ export default function QuestionsBrowsePage() {
{ + const { cityId, countryId, stateId } = data.location; createQuestion({ + cityId, companyId: data.company, content: data.questionContent, - location: data.location, + countryId, questionType: data.questionType, - role: data.role, + role: data.role.value, seenAt: data.date, + stateId, }); }} /> diff --git a/apps/portal/src/pages/questions/lists.tsx b/apps/portal/src/pages/questions/lists.tsx index 1c79bd32..8d428e1b 100644 --- a/apps/portal/src/pages/questions/lists.tsx +++ b/apps/portal/src/pages/questions/lists.tsx @@ -174,7 +174,7 @@ export default function ListPage() {
{lists[selectedListIndex].questionEntries.map( ({ question, id: entryId }) => { - const { companyCounts, locationCounts, roleCounts } = + const { companyCounts, countryCounts, roleCounts } = relabelQuestionAggregates( question.aggregatedQuestionEncounters, ); @@ -184,10 +184,10 @@ export default function ListPage() { key={question.id} companies={companyCounts} content={question.content} + countries={countryCounts} href={`/questions/${question.id}/${createSlug( question.content, )}`} - countries={locationCounts} questionId={question.id} receivedCount={question.receivedCount} roles={roleCounts} diff --git a/apps/portal/src/server/router/locations-router.ts b/apps/portal/src/server/router/locations-router.ts index b6b5a09d..0db4d38f 100644 --- a/apps/portal/src/server/router/locations-router.ts +++ b/apps/portal/src/server/router/locations-router.ts @@ -19,9 +19,11 @@ export const locationsRouter = createRouter() select: { country: { select: { + id: true, name: true, }, }, + id: true, name: true, }, }, diff --git a/apps/portal/src/server/router/questions/questions-list-router.ts b/apps/portal/src/server/router/questions/questions-list-router.ts index 851c0a9c..fd5437d3 100644 --- a/apps/portal/src/server/router/questions/questions-list-router.ts +++ b/apps/portal/src/server/router/questions/questions-list-router.ts @@ -25,10 +25,12 @@ export const questionsListRouter = createProtectedRouter() }, encounters: { select: { + city: true, company: true, - location: true, + country: true, role: true, seenAt: true, + state: true, }, }, user: { @@ -83,10 +85,12 @@ export const questionsListRouter = createProtectedRouter() }, encounters: { select: { + city: true, company: true, - location: true, + country: true, role: true, seenAt: true, + state: true, }, }, user: { diff --git a/apps/portal/src/server/router/questions/questions-question-encounter-user-router.ts b/apps/portal/src/server/router/questions/questions-question-encounter-user-router.ts index c85f5607..e1e57f58 100644 --- a/apps/portal/src/server/router/questions/questions-question-encounter-user-router.ts +++ b/apps/portal/src/server/router/questions/questions-question-encounter-user-router.ts @@ -10,13 +10,13 @@ import { SortOrder } from '~/types/questions.d'; export const questionsQuestionEncounterUserRouter = createProtectedRouter() .mutation('create', { input: z.object({ - companyId: z.string(), cityId: z.string().nullish(), + companyId: z.string(), countryId: z.string(), - stateId: z.string().nullish(), questionId: z.string(), role: z.nativeEnum(JobTitleLabels), seenAt: z.date(), + stateId: z.string().nullish(), }), async resolve({ ctx, input }) { const userId = ctx.session?.user?.id; diff --git a/apps/portal/src/server/router/questions/questions-question-router.ts b/apps/portal/src/server/router/questions/questions-question-router.ts index 3553a1c9..0a2d58a4 100644 --- a/apps/portal/src/server/router/questions/questions-question-router.ts +++ b/apps/portal/src/server/router/questions/questions-question-router.ts @@ -2,8 +2,6 @@ import { z } from 'zod'; import { QuestionsQuestionType } from '@prisma/client'; import { TRPCError } from '@trpc/server'; -import { JobTitleLabels } from '~/components/shared/JobTitles'; - import { createQuestionWithAggregateData } from '~/utils/questions/server/aggregate-encounters'; import { createRouter } from '../context'; @@ -20,7 +18,7 @@ export const questionsQuestionRouter = createRouter() endDate: z.date().default(new Date()), limit: z.number().min(1).default(50), questionTypes: z.nativeEnum(QuestionsQuestionType).array(), - roles: z.nativeEnum(JobTitleLabels).array(), + roles: z.string().array(), sortOrder: z.nativeEnum(SortOrder), sortType: z.nativeEnum(SortType), startDate: z.date().optional(), diff --git a/apps/portal/src/server/router/questions/questions-question-user-router.ts b/apps/portal/src/server/router/questions/questions-question-user-router.ts index db3edb29..367fd185 100644 --- a/apps/portal/src/server/router/questions/questions-question-user-router.ts +++ b/apps/portal/src/server/router/questions/questions-question-user-router.ts @@ -2,21 +2,19 @@ import { z } from 'zod'; import { QuestionsQuestionType, Vote } from '@prisma/client'; import { TRPCError } from '@trpc/server'; -import { JobTitleLabels } from '~/components/shared/JobTitles'; - import { createProtectedRouter } from '../context'; export const questionsQuestionUserRouter = createProtectedRouter() .mutation('create', { input: z.object({ + cityId: z.string().nullish(), companyId: z.string(), content: z.string(), - cityId: z.string().nullish(), countryId: z.string(), - stateId: z.string().nullish(), questionType: z.nativeEnum(QuestionsQuestionType), - role: z.nativeEnum(JobTitleLabels), + role: z.string(), seenAt: z.date(), + stateId: z.string().nullish(), }), async resolve({ ctx, input }) { const userId = ctx.session?.user?.id; @@ -26,28 +24,34 @@ export const questionsQuestionUserRouter = createProtectedRouter() content: input.content, encounters: { create: { + city: + input.cityId !== null + ? { + connect: { + id: input.cityId, + }, + } + : undefined, company: { connect: { id: input.companyId, }, }, - city: input.cityId !== null ? { - connect: { - id: input.cityId, - }, - } : undefined, country: { connect: { id: input.countryId, }, }, - state: input.stateId !== null ? { - connect: { - id: input.stateId, - }, - } : undefined, role: input.role, seenAt: input.seenAt, + state: + input.stateId !== null + ? { + connect: { + id: input.stateId, + }, + } + : undefined, user: { connect: { id: userId, diff --git a/apps/portal/src/types/questions.d.ts b/apps/portal/src/types/questions.d.ts index e142db5a..8391e6e1 100644 --- a/apps/portal/src/types/questions.d.ts +++ b/apps/portal/src/types/questions.d.ts @@ -24,6 +24,26 @@ export type CountryInfo = { total: number; }; +export type CityLocation = { + cityId: string; + countryId: string; + stateId: string; +}; + +export type StateLocation = { + cityId?: never; + countryId: string; + stateId: string; +}; + +export type CountryLocation = { + cityId?: never; + countryId: string; + stateId?: never; +}; + +export type Location = CityLocation | CountryLocation | StateLocation; + export type AggregatedQuestionEncounter = { companyCounts: Record; countryCounts: Record; diff --git a/apps/portal/src/utils/questions/constants.ts b/apps/portal/src/utils/questions/constants.ts index 2cfdac8f..bada8a57 100644 --- a/apps/portal/src/utils/questions/constants.ts +++ b/apps/portal/src/utils/questions/constants.ts @@ -63,47 +63,6 @@ export const QUESTION_AGES: FilterChoices = [ }, ] as const; -export const LOCATIONS: FilterChoices = [ - { - id: 'Singapore', - label: 'Singapore', - value: 'Singapore', - }, - { - id: 'Menlo Park', - label: 'Menlo Park', - value: 'Menlo Park', - }, - { - id: 'California', - label: 'California', - value: 'California', - }, - { - id: 'Hong Kong', - label: 'Hong Kong', - value: 'Hong Kong', - }, - { - id: 'Taiwan', - label: 'Taiwan', - value: 'Taiwan', - }, -] as const; - -export const ROLES: FilterChoices = [ - { - id: 'Software Engineer', - label: 'Software Engineer', - value: 'Software Engineer', - }, - { - id: 'Software Engineer Intern', - label: 'Software Engineer Intern', - value: 'Software Engineer Intern', - }, -] as const; - export const SORT_ORDERS = [ { label: 'Ascending', diff --git a/apps/portal/src/utils/questions/useDefaultLocation.ts b/apps/portal/src/utils/questions/useDefaultLocation.ts index 3400a3be..6834410e 100644 --- a/apps/portal/src/utils/questions/useDefaultLocation.ts +++ b/apps/portal/src/utils/questions/useDefaultLocation.ts @@ -1,7 +1,31 @@ import type { FilterChoice } from '~/components/questions/filter/FilterSection'; -import { LOCATIONS } from './constants'; +import { trpc } from '../trpc'; -export default function useDefaultLocation(): FilterChoice | undefined { - return LOCATIONS[0]; +import type { Location } from '~/types/questions'; + +export default function useDefaultLocation(): + | (FilterChoice & Location) + | undefined { + const { data: locations } = trpc.useQuery([ + 'locations.cities.list', + { + name: 'singapore', + }, + ]); + + if (locations === undefined) { + return undefined; + } + + const { id, name, state } = locations[0]; + + return { + cityId: id, + countryId: state.country.id, + id, + label: `${name}, ${state.name}, ${state.country.name}`, + stateId: state.id, + value: id, + }; }