[questions][fix] update frontend to support locations

pull/457/head
Jeff Sieu 3 years ago
parent 46f69414a1
commit fa9b98ec60

@ -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 }) => (
<LocationTypeahead
{...field}
required={true}
onSelect={(option) => {
// @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({
<Controller
control={control}
name="company"
render={({ field }) => (
render={({ field: { value: _, ...field } }) => (
<CompanyTypeahead
{...field}
required={true}
// @ts-ignore TODO(questions): handle potentially null value.
onSelect={({ id }) => {
@ -134,13 +135,12 @@ export default function ContributeQuestionForm({
name="role"
render={({ field }) => (
<RoleTypeahead
{...field}
required={true}
onSelect={(option) => {
// @ts-ignore TODO(questions): handle potentially null value.
field.onChange(option.value);
field.onChange(option);
}}
{...field}
value={ROLES.find((role) => role.value === field.value)}
/>
)}
/>

@ -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<string | null>(null);
const [selectedLocation, setSelectedLocation] = useState<string | null>(null);
const [selectedLocation, setSelectedLocation] = useState<Location | null>(
null,
);
const [selectedRole, setSelectedRole] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState<Date>(
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,
});
}
}}

@ -8,13 +8,16 @@ import type { RequireAllOrNone } from '~/utils/questions/RequireAllOrNone';
type TypeaheadProps = ComponentProps<typeof Typeahead>;
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<TypeaheadProps, 'onSelect'> &
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!);
}}
/>
</div>

@ -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 (
<ExpandedTypeahead
{...(props as ExpandedTypeaheadProps)}
{...({
onSuggestionClick: onSuggestionClick
? (option: TypeaheadOption) => {
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);
}}
/>
);
}

@ -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 (
<ExpandedTypeahead
{...(props as ExpandedTypeaheadProps)}
label="Role"
options={ROLES}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
options={ROLES.filter((option) =>
option.label.toLowerCase().includes(query.toLowerCase()),
)}
onQueryChange={setQuery}
/>
);
}

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

@ -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<Location & TypeaheadOption>('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>('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<string>,
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() {
<FilterSection
label="Roles"
options={selectedRoleOptions}
renderInput={({ onOptionChange, field: { ref: _, ...field } }) => (
renderInput={({
onOptionChange,
field: { ref: _, onChange: __, ...field },
}) => (
<RoleTypeahead
{...field}
clearOnSelect={true}
@ -413,13 +438,16 @@ export default function QuestionsBrowsePage() {
<FilterSection
label="Locations"
options={selectedLocationOptions}
renderInput={({ onOptionChange, field: { ref: _, ...field } }) => (
renderInput={({
onOptionChange,
field: { ref: _, onChange: __, ...field },
}) => (
<LocationTypeahead
{...field}
clearOnSelect={true}
filterOption={(option) => {
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() {
<div className="m-4 flex max-w-3xl flex-1 flex-col items-stretch justify-start gap-8">
<ContributeQuestionCard
onSubmit={(data) => {
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,
});
}}
/>

@ -174,7 +174,7 @@ export default function ListPage() {
<div className="flex flex-col gap-4 pb-4">
{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}

@ -19,9 +19,11 @@ export const locationsRouter = createRouter()
select: {
country: {
select: {
id: true,
name: true,
},
},
id: true,
name: true,
},
},

@ -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: {

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

@ -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(),

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

@ -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<string, number>;
countryCounts: Record<string, CountryInfo>;

@ -63,47 +63,6 @@ export const QUESTION_AGES: FilterChoices<QuestionAge> = [
},
] 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',

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

Loading…
Cancel
Save