[resumes][feat] migrate to use location db and role enum (#506)

* [resumes][feat] use role and countries typeaheads

* [resumes][feat] add location and role typeaheads

* [resumes][chore] locationId migration

* [resumes][fix] update upsert to take in locationId

* [resumes][refactor] use typeahead for browse filters

* [resumes][feat] use role and countries typeaheads

* [resumes][chore] locationId migration

* [resumes][feat] fetch location on resumes page

* [resumes][feat] enable edit resume form

* [resumes][misc] update namings and oredrings

* [resumes][feat] add default locations

* [resumes][fix] truncate title text in resume card

* [resumes][fix] filter out selected options

* [resumes][feat] add more countries to default search

* [resumes][feat] update default roles

* [resumes][chore] revert removal of value

* [resumes]feat] add default location for migration file

* [resumes][fix] fix merge conflicts

Co-authored-by: Wu Peirong <wupeirong294@gmail.com>
pull/519/head
Keane Chan 2 years ago committed by GitHub
parent 9815d125ff
commit 1ebd32ca2f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -43,7 +43,6 @@
"react-query": "^3.39.2",
"read-excel-file": "^5.5.3",
"superjson": "^1.10.0",
"xlsx": "^0.18.5",
"unique-names-generator": "^4.7.1",
"xlsx": "^0.18.5",
"zod": "^3.18.0"

@ -0,0 +1,13 @@
/*
Warnings:
- You are about to drop the column `location` on the `ResumesResume` table. All the data in the column will be lost.
- Added the required column `locationId` to the `ResumesResume` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable. Set default location to Singapore.
ALTER TABLE "ResumesResume" DROP COLUMN "location",
ADD COLUMN "locationId" TEXT NOT NULL DEFAULT '196';
-- AddForeignKey
ALTER TABLE "ResumesResume" ADD CONSTRAINT "ResumesResume_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Country"("id") ON DELETE CASCADE ON UPDATE CASCADE;

@ -112,6 +112,7 @@ model Country {
code String @unique
states State[]
questionsQuestionEncounters QuestionsQuestionEncounter[]
ResumesResume ResumesResume[]
}
model State {
@ -148,13 +149,14 @@ model ResumesResume {
// TODO: Update role, experience, location to use Enums
role String @db.Text
experience String @db.Text
location String @db.Text
locationId String
url String
additionalInfo String? @db.Text
isResolved Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
location Country @relation(fields: [locationId], references: [id], onDelete: Cascade)
stars ResumesStar[]
comments ResumesComment[]
}

@ -12,17 +12,7 @@ import { ChatBubbleLeftIcon, StarIcon } from '@heroicons/react/24/outline';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import type {
ExperienceFilter,
LocationFilter,
RoleFilter,
} from '~/utils/resumes/resumeFilters';
import {
EXPERIENCES,
getFilterLabel,
LOCATIONS,
ROLES,
} from '~/utils/resumes/resumeFilters';
import { getFilterLabel } from '~/utils/resumes/resumeFilters';
import type { Resume } from '~/types/resume';
@ -47,15 +37,14 @@ export default function ResumeListItem({ href, resumeInfo }: Props) {
<div className="col-span-7 grid gap-4 border-b border-slate-200 p-4 hover:bg-slate-100 sm:grid-cols-7">
<div className="sm:col-span-4">
<div className="flex items-center gap-3">
{resumeInfo.title}
<p className="truncate">{resumeInfo.title}</p>
<p
className={clsx(
'w-auto items-center space-x-4 rounded-xl border border-slate-300 px-2 py-1 text-xs font-medium text-white opacity-60',
resumeInfo.isResolved ? 'bg-slate-400' : 'bg-success-500',
'w-auto items-center space-x-4 rounded-xl border px-2 py-1 text-xs font-medium',
resumeInfo.isResolved ? 'bg-slate-300' : 'bg-success-100',
resumeInfo.isResolved ? 'text-slate-600' : 'text-success-700',
)}>
<span className="opacity-100">
{resumeInfo.isResolved ? 'Reviewed' : 'Unreviewed'}
</span>
</p>
</div>
<div className="text-primary-500 mt-2 flex items-center justify-start text-xs">
@ -64,17 +53,14 @@ export default function ResumeListItem({ href, resumeInfo }: Props) {
aria-hidden="true"
className="mr-1.5 h-4 w-4 flex-shrink-0"
/>
{getFilterLabel(ROLES, resumeInfo.role as RoleFilter)}
{getFilterLabel('role', resumeInfo.role)}
</div>
<div className="ml-4 flex">
<AcademicCapIcon
aria-hidden="true"
className="mr-1.5 h-4 w-4 flex-shrink-0"
/>
{getFilterLabel(
EXPERIENCES,
resumeInfo.experience as ExperienceFilter,
)}
{getFilterLabel('experience', resumeInfo.experience)}
</div>
</div>
<div className="mt-4 flex justify-start text-xs text-slate-500">
@ -102,9 +88,7 @@ export default function ResumeListItem({ href, resumeInfo }: Props) {
addSuffix: true,
})} by ${resumeInfo.user}`}
</div>
<div className="mt-2 text-slate-400">
{getFilterLabel(LOCATIONS, resumeInfo.location as LocationFilter)}
</div>
<div className="mt-2 text-slate-400">{resumeInfo.location}</div>
</div>
</div>
<ChevronRightIcon className="col-span-1 w-8 self-center justify-self-center text-slate-400" />

@ -0,0 +1,51 @@
import type { ComponentProps } from 'react';
import { useState } from 'react';
import type { TypeaheadOption } from '@tih/ui';
import { Typeahead } from '@tih/ui';
import { EXPERIENCES } from '~/utils/resumes/resumeFilters';
type BaseProps = Pick<
ComponentProps<typeof Typeahead>,
| 'disabled'
| 'errorMessage'
| 'isLabelHidden'
| 'placeholder'
| 'required'
| 'textSize'
>;
type Props = BaseProps &
Readonly<{
onSelect: (option: TypeaheadOption | null) => void;
selectedValues?: Set<string>;
value?: TypeaheadOption | null;
}>;
export default function ResumeExperienceTypeahead({
onSelect,
selectedValues = new Set(),
value,
...props
}: Props) {
const [query, setQuery] = useState('');
const options = EXPERIENCES.filter(
(option) => !selectedValues.has(option.value),
).filter(
({ label }) =>
label.toLocaleLowerCase().indexOf(query.toLocaleLowerCase()) > -1,
);
return (
<Typeahead
label="Experiences"
noResultsMessage="No available experiences."
nullable={true}
options={options}
value={value}
onQueryChange={setQuery}
onSelect={onSelect}
{...props}
/>
);
}

@ -0,0 +1,66 @@
import type { ComponentProps } from 'react';
import { useMemo, useState } from 'react';
import type { TypeaheadOption } from '@tih/ui';
import { Typeahead } from '@tih/ui';
import { trpc } from '~/utils/trpc';
type BaseProps = Pick<
ComponentProps<typeof Typeahead>,
| 'disabled'
| 'errorMessage'
| 'isLabelHidden'
| 'placeholder'
| 'required'
| 'textSize'
>;
type Props = BaseProps &
Readonly<{
onSelect: (option: TypeaheadOption | null) => void;
selectedValues?: Set<string>;
value?: TypeaheadOption | null;
}>;
export default function ResumeLocationTypeahead({
onSelect,
selectedValues = new Set(),
value,
...props
}: Props) {
const [query, setQuery] = useState('');
const countries = trpc.useQuery([
'locations.countries.list',
{
name: query,
},
]);
const options = useMemo(() => {
const { data } = countries;
if (data == null) {
return [];
}
return data
.map(({ id, name }) => ({
id,
label: name,
value: id,
}))
.filter((option) => !selectedValues.has(option.value));
}, [countries, selectedValues]);
return (
<Typeahead
label="Location"
noResultsMessage="No location found"
nullable={true}
options={options}
value={value}
onQueryChange={setQuery}
onSelect={onSelect}
{...props}
/>
);
}

@ -0,0 +1,56 @@
import type { ComponentProps } from 'react';
import { useState } from 'react';
import type { TypeaheadOption } from '@tih/ui';
import { Typeahead } from '@tih/ui';
import { JobTitleLabels } from '~/components/shared/JobTitles';
type BaseProps = Pick<
ComponentProps<typeof Typeahead>,
| 'disabled'
| 'errorMessage'
| 'isLabelHidden'
| 'placeholder'
| 'required'
| 'textSize'
>;
type Props = BaseProps &
Readonly<{
onSelect: (option: TypeaheadOption | null) => void;
selectedValues?: Set<string>;
value?: TypeaheadOption | null;
}>;
export default function ResumeRoleTypeahead({
onSelect,
selectedValues = new Set(),
value,
...props
}: Props) {
const [query, setQuery] = useState('');
const options = Object.entries(JobTitleLabels)
.map(([slug, label]) => ({
id: slug,
label,
value: slug,
}))
.filter((option) => !selectedValues.has(option.value))
.filter(
({ label }) =>
label.toLocaleLowerCase().indexOf(query.toLocaleLowerCase()) > -1,
);
return (
<Typeahead
label="Role"
noResultsMessage="No available roles."
nullable={true}
options={options}
value={value}
onQueryChange={setQuery}
onSelect={onSelect}
{...props}
/>
);
}

@ -24,23 +24,17 @@ import ResumePdf from '~/components/resumes/ResumePdf';
import ResumeExpandableText from '~/components/resumes/shared/ResumeExpandableText';
import loginPageHref from '~/components/shared/loginPageHref';
import type {
ExperienceFilter,
FilterOption,
LocationFilter,
RoleFilter,
} from '~/utils/resumes/resumeFilters';
import {
BROWSE_TABS_VALUES,
EXPERIENCES,
getFilterLabel,
getTypeaheadOption,
INITIAL_FILTER_STATE,
LOCATIONS,
ROLES,
} from '~/utils/resumes/resumeFilters';
import { trpc } from '~/utils/trpc';
import SubmitResumeForm from './submit';
import type { JobTitleType } from '../../components/shared/JobTitles';
import { getLabelForJobTitleType } from '../../components/shared/JobTitles';
export default function ResumeReviewPage() {
const ErrorPage = (
@ -124,29 +118,24 @@ export default function ResumeReviewPage() {
};
const onInfoTagClick = ({
locationLabel,
experienceLabel,
roleLabel,
locationName,
locationValue,
experienceValue,
roleValue,
}: {
experienceLabel?: string;
locationLabel?: string;
roleLabel?: string;
experienceValue?: string;
locationName?: string;
locationValue?: string;
roleValue?: string;
}) => {
const getFilterValue = (
label: string,
filterOptions: Array<
FilterOption<ExperienceFilter | LocationFilter | RoleFilter>
>,
) => filterOptions.find((option) => option.label === label)?.value;
router.push({
pathname: '/resumes',
query: {
currentPage: JSON.stringify(1),
isFiltersOpen: JSON.stringify({
experience: experienceLabel !== undefined,
location: locationLabel !== undefined,
role: roleLabel !== undefined,
experience: experienceValue !== undefined,
location: locationValue !== undefined,
role: roleValue !== undefined,
}),
searchValue: JSON.stringify(''),
shortcutSelected: JSON.stringify('all'),
@ -154,14 +143,16 @@ export default function ResumeReviewPage() {
tabsValue: JSON.stringify(BROWSE_TABS_VALUES.ALL),
userFilters: JSON.stringify({
...INITIAL_FILTER_STATE,
...(locationLabel && {
location: [getFilterValue(locationLabel, LOCATIONS)],
...(locationValue && {
location: [
getTypeaheadOption('location', locationValue, locationName),
],
}),
...(roleLabel && {
role: [getFilterValue(roleLabel, ROLES)],
...(roleValue && {
role: [getTypeaheadOption('role', roleValue)],
}),
...(experienceLabel && {
experience: [getFilterValue(experienceLabel, EXPERIENCES)],
...(experienceValue && {
experience: [getTypeaheadOption('experience', experienceValue)],
}),
}),
},
@ -207,9 +198,19 @@ export default function ResumeReviewPage() {
initFormDetails={{
additionalInfo: detailsQuery.data.additionalInfo ?? '',
experience: detailsQuery.data.experience,
location: detailsQuery.data.location,
location: {
id: detailsQuery.data.locationId,
label: detailsQuery.data.location.name,
value: detailsQuery.data.locationId,
},
resumeId: resumeId as string,
role: detailsQuery.data.role,
role: {
id: detailsQuery.data.role,
label: getLabelForJobTitleType(
detailsQuery.data.role as JobTitleType,
),
value: detailsQuery.data.role,
},
title: detailsQuery.data.title,
url: detailsQuery.data.url,
}}
@ -325,13 +326,10 @@ export default function ResumeReviewPage() {
type="button"
onClick={() =>
onInfoTagClick({
roleLabel: detailsQuery.data?.role,
roleValue: detailsQuery.data?.role,
})
}>
{getFilterLabel(
ROLES,
detailsQuery.data.role as RoleFilter,
)}
{getFilterLabel('role', detailsQuery.data.role)}
</button>
</div>
<div className="col-span-1 flex items-center text-xs text-slate-600 sm:text-sm">
@ -344,13 +342,11 @@ export default function ResumeReviewPage() {
type="button"
onClick={() =>
onInfoTagClick({
locationLabel: detailsQuery.data?.location,
locationName: detailsQuery.data?.location.name,
locationValue: detailsQuery.data?.locationId,
})
}>
{getFilterLabel(
LOCATIONS,
detailsQuery.data.location as LocationFilter,
)}
{detailsQuery.data?.location.name}
</button>
</div>
<div className="col-span-1 flex items-center text-xs text-slate-600 sm:text-sm">
@ -363,12 +359,12 @@ export default function ResumeReviewPage() {
type="button"
onClick={() =>
onInfoTagClick({
experienceLabel: detailsQuery.data?.experience,
experienceValue: detailsQuery.data?.experience,
})
}>
{getFilterLabel(
EXPERIENCES,
detailsQuery.data.experience as ExperienceFilter,
'experience',
detailsQuery.data.experience,
)}
</button>
</div>

@ -9,6 +9,7 @@ import {
NewspaperIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import type { TypeaheadOption } from '@tih/ui';
import {
Button,
CheckboxInput,
@ -23,23 +24,18 @@ import {
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill';
import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
import ResumeExperienceTypeahead from '~/components/resumes/shared/ResumeExperienceTypeahead';
import ResumeLocationTypeahead from '~/components/resumes/shared/ResumeLocationTypeahead';
import ResumeRoleTypeahead from '~/components/resumes/shared/ResumeRoleTypeahead';
import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
import loginPageHref from '~/components/shared/loginPageHref';
import type {
Filter,
FilterId,
FilterLabel,
Shortcut,
} from '~/utils/resumes/resumeFilters';
import type { Filter, FilterId, Shortcut } from '~/utils/resumes/resumeFilters';
import type { SortOrder } from '~/utils/resumes/resumeFilters';
import {
BROWSE_TABS_VALUES,
EXPERIENCES,
getFilterLabel,
INITIAL_FILTER_STATE,
isInitialFilterState,
LOCATIONS,
ROLES,
SHORTCUTS,
SORT_OPTIONS,
} from '~/utils/resumes/resumeFilters';
@ -47,8 +43,6 @@ import useDebounceValue from '~/utils/resumes/useDebounceValue';
import useSearchParams from '~/utils/resumes/useSearchParams';
import { trpc } from '~/utils/trpc';
import type { FilterState, SortOrder } from '../../utils/resumes/resumeFilters';
const STALE_TIME = 5 * 60 * 1000;
const DEBOUNCE_DELAY = 800;
const PAGE_LIMIT = 10;
@ -56,17 +50,14 @@ const filters: Array<Filter> = [
{
id: 'role',
label: 'Role',
options: ROLES,
},
{
id: 'experience',
label: 'Experience',
options: EXPERIENCES,
},
{
id: 'location',
label: 'Location',
options: LOCATIONS,
},
];
@ -81,20 +72,14 @@ const getLoggedOutText = (tabsValue: string) => {
}
};
const getEmptyDataText = (
tabsValue: string,
searchValue: string,
userFilters: FilterState,
) => {
const getEmptyDataText = (tabsValue: string, searchValue: string) => {
if (searchValue.length > 0) {
return 'Try tweaking your search text to see more resumes.';
}
if (!isInitialFilterState(userFilters)) {
return 'Try tweaking your filters to see more resumes.';
}
switch (tabsValue) {
case BROWSE_TABS_VALUES.ALL:
return "There's nothing to see here...";
return 'Oops, there is no resumes to see here. Maybe try tweaking your filters to see more.';
case BROWSE_TABS_VALUES.STARRED:
return 'You have not starred any resumes. Star one to see it here!';
case BROWSE_TABS_VALUES.MY:
@ -200,10 +185,10 @@ export default function ResumeHomePage() {
[
'resumes.resume.findAll',
{
experienceFilters: userFilters.experience,
experienceFilters: userFilters.experience.map(({ value }) => value),
isUnreviewed: userFilters.isUnreviewed,
locationFilters: userFilters.location,
roleFilters: userFilters.role,
locationFilters: userFilters.location.map(({ value }) => value),
roleFilters: userFilters.role.map(({ value }) => value),
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip,
sortOrder,
@ -219,10 +204,10 @@ export default function ResumeHomePage() {
[
'resumes.resume.user.findUserStarred',
{
experienceFilters: userFilters.experience,
experienceFilters: userFilters.experience.map(({ value }) => value),
isUnreviewed: userFilters.isUnreviewed,
locationFilters: userFilters.location,
roleFilters: userFilters.role,
locationFilters: userFilters.location.map(({ value }) => value),
roleFilters: userFilters.role.map(({ value }) => value),
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip,
sortOrder,
@ -239,10 +224,10 @@ export default function ResumeHomePage() {
[
'resumes.resume.user.findUserCreated',
{
experienceFilters: userFilters.experience,
experienceFilters: userFilters.experience.map(({ value }) => value),
isUnreviewed: userFilters.isUnreviewed,
locationFilters: userFilters.location,
roleFilters: userFilters.role,
locationFilters: userFilters.location.map(({ value }) => value),
roleFilters: userFilters.role.map(({ value }) => value),
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip,
sortOrder,
@ -264,31 +249,6 @@ export default function ResumeHomePage() {
}
};
const onFilterCheckboxChange = (
isChecked: boolean,
filterSection: FilterId,
filterValue: string,
) => {
if (isChecked) {
setUserFilters({
...userFilters,
[filterSection]: [...userFilters[filterSection], filterValue],
});
} else {
setUserFilters({
...userFilters,
[filterSection]: userFilters[filterSection].filter(
(value) => value !== filterValue,
),
});
}
gaEvent({
action: 'resumes.filter_checkbox_click',
category: 'engagement',
label: 'Select Filter',
});
};
const onClearFilterClick = (filterSection: FilterId) => {
setUserFilters({
...userFilters,
@ -354,12 +314,71 @@ export default function ResumeHomePage() {
return getTabQueryData()?.filterCounts;
};
const getFilterCount = (filter: FilterLabel, value: string) => {
const getFilterTypeahead = (filterId: FilterId) => {
const onSelect = (option: TypeaheadOption | null) => {
if (option === null) {
return;
}
setUserFilters({
...userFilters,
[filterId]: [...userFilters[filterId], option],
});
gaEvent({
action: 'resumes.filter_typeahead_click',
category: 'engagement',
label: 'Select Filter',
});
};
switch (filterId) {
case 'experience':
return (
<ResumeExperienceTypeahead
isLabelHidden={true}
placeholder="Select experiences"
selectedValues={
new Set(userFilters[filterId].map(({ value }) => value))
}
onSelect={onSelect}
/>
);
case 'location':
return (
<ResumeLocationTypeahead
isLabelHidden={true}
placeholder="Select locations"
selectedValues={
new Set(userFilters[filterId].map(({ value }) => value))
}
onSelect={onSelect}
/>
);
case 'role':
return (
<ResumeRoleTypeahead
isLabelHidden={true}
placeholder="Select roles"
selectedValues={
new Set(userFilters[filterId].map(({ value }) => value))
}
onSelect={onSelect}
/>
);
default:
return null;
}
};
const getFilterCount = (filterId: FilterId, value: string) => {
const filterCountsData = getTabFilterCounts();
if (!filterCountsData) {
if (
filterCountsData === undefined ||
filterCountsData[filterId] === undefined ||
filterCountsData[filterId][value] === undefined
) {
return 0;
}
return filterCountsData[filter][value];
return filterCountsData[filterId][value];
};
return (
@ -461,29 +480,28 @@ export default function ResumeHomePage() {
</h3>
<Disclosure.Panel className="space-y-4 pt-6">
<div className="space-y-3">
{filter.options.map((option) => (
{getFilterTypeahead(filter.id)}
{userFilters[filter.id].map((option) => (
<div
key={option.value}
className="[&>div>div:nth-child(1)>input]:text-primary-600 [&>div>div:nth-child(1)>input]:ring-primary-500 flex items-center px-1 text-sm [&>div>div:nth-child(2)>label]:font-normal">
className="flex items-center px-1 text-sm">
<CheckboxInput
label={option.label}
value={userFilters[filter.id].includes(
option.value,
)}
onChange={(isChecked) =>
onFilterCheckboxChange(
isChecked,
filter.id,
option.value,
)
value={true}
onChange={() =>
setUserFilters({
...userFilters,
[filter.id]: userFilters[
filter.id
].filter(
({ value }) =>
value !== option.value,
),
})
}
/>
<span className="ml-1 text-slate-500">
(
{getFilterCount(
filter.label,
option.label,
)}
({getFilterCount(filter.id, option.value)}
)
</span>
</div>
@ -570,32 +588,32 @@ export default function ResumeHomePage() {
</Disclosure.Button>
</h3>
<Disclosure.Panel className="space-y-4 pt-4">
{getFilterTypeahead(filter.id)}
<CheckboxList
description=""
isLabelHidden={true}
label=""
orientation="vertical">
{filter.options.map((option) => (
{userFilters[filter.id].map((option) => (
<div
key={option.value}
className="[&>div>div:nth-child(1)>input]:text-primary-600 [&>div>div:nth-child(1)>input]:ring-primary-500 flex items-center px-1 text-sm [&>div>div:nth-child(2)>label]:font-normal">
className="flex items-center px-1 text-sm">
<CheckboxInput
label={option.label}
value={userFilters[filter.id].includes(
option.value,
)}
onChange={(isChecked) =>
onFilterCheckboxChange(
isChecked,
filter.id,
option.value,
)
value={true}
onChange={() =>
setUserFilters({
...userFilters,
[filter.id]: userFilters[
filter.id
].filter(
({ value }) => value !== option.value,
),
})
}
/>
<span className="ml-1 text-slate-500">
(
{getFilterCount(filter.label, option.label)}
)
({getFilterCount(filter.id, option.value)})
</span>
</div>
))}
@ -660,7 +678,7 @@ export default function ResumeHomePage() {
</div>
<DropdownMenu
align="end"
label={getFilterLabel(SORT_OPTIONS, sortOrder)}>
label={getFilterLabel('sort', sortOrder)}>
{SORT_OPTIONS.map(({ label, value }) => (
<DropdownMenu.Item
key={value}
@ -702,7 +720,7 @@ export default function ResumeHomePage() {
height={196}
width={196}
/>
{getEmptyDataText(tabsValue, searchValue, userFilters)}
{getEmptyDataText(tabsValue, searchValue)}
</div>
) : (
<div>

@ -7,8 +7,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import type { FileRejection } from 'react-dropzone';
import { useDropzone } from 'react-dropzone';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { Controller, useForm } from 'react-hook-form';
import { ArrowUpCircleIcon } from '@heroicons/react/24/outline';
import type { TypeaheadOption } from '@tih/ui';
import {
Button,
CheckboxInput,
@ -20,12 +21,14 @@ import {
} from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import ResumeLocationTypeahead from '~/components/resumes/shared/ResumeLocationTypeahead';
import ResumeRoleTypeahead from '~/components/resumes/shared/ResumeRoleTypeahead';
import ResumeSubmissionGuidelines from '~/components/resumes/submit-form/ResumeSubmissionGuidelines';
import Container from '~/components/shared/Container';
import loginPageHref from '~/components/shared/loginPageHref';
import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys';
import { EXPERIENCES, LOCATIONS, ROLES } from '~/utils/resumes/resumeFilters';
import { EXPERIENCES } from '~/utils/resumes/resumeFilters';
import { trpc } from '~/utils/trpc';
const FILE_SIZE_LIMIT_MB = 3;
@ -41,19 +44,20 @@ type IFormInput = {
experience: string;
file: File;
isChecked: boolean;
location: string;
role: string;
location: TypeaheadOption;
role: TypeaheadOption;
title: string;
};
type InputKeys = keyof IFormInput;
type TypeAheadKeys = keyof Pick<IFormInput, 'location' | 'role'>;
type InitFormDetails = {
additionalInfo?: string;
experience: string;
location: string;
location: TypeaheadOption;
resumeId: string;
role: string;
role: TypeaheadOption;
title: string;
url: string;
};
@ -85,6 +89,7 @@ export default function SubmitResumeForm({
register,
handleSubmit,
setValue,
control,
reset,
watch,
clearErrors,
@ -94,8 +99,6 @@ export default function SubmitResumeForm({
additionalInfo: '',
experience: '',
isChecked: false,
location: '',
role: '',
title: '',
...initFormDetails,
},
@ -136,6 +139,11 @@ export default function SubmitResumeForm({
}, [router, status]);
const onSubmit: SubmitHandler<IFormInput> = async (data) => {
if (!isDirty) {
onClose();
return;
}
setIsLoading(true);
let fileUrl = initFormDetails?.url ?? '';
@ -158,8 +166,8 @@ export default function SubmitResumeForm({
additionalInfo: data.additionalInfo,
experience: data.experience,
id: initFormDetails?.resumeId,
location: data.location,
role: data.role,
locationId: data.location.value,
role: data.role.value,
title: data.title,
url: fileUrl,
},
@ -235,6 +243,13 @@ export default function SubmitResumeForm({
setValue(section, value.trim(), { shouldDirty: true });
};
const onSelect = (section: TypeAheadKeys, option: TypeaheadOption | null) => {
if (option == null) {
return;
}
setValue(section, option, { shouldDirty: true });
};
return (
<>
<Head>
@ -299,16 +314,33 @@ export default function SubmitResumeForm({
required={true}
onChange={(val) => onValueChange('title', val)}
/>
<div className="flex flex-wrap gap-6">
<Select
{...register('role', { required: true })}
defaultValue={undefined}
<Controller
control={control}
name="location"
render={({ field: { value } }) => (
<ResumeLocationTypeahead
disabled={isLoading}
label="Role"
options={ROLES}
placeholder=" "
placeholder="Select a location"
required={true}
onChange={(val) => onValueChange('role', val)}
value={value}
onSelect={(option) => onSelect('location', option)}
/>
)}
rules={{ required: true }}
/>
<Controller
control={control}
name="role"
render={({ field: { value } }) => (
<ResumeRoleTypeahead
disabled={isLoading}
placeholder="Select a role"
required={true}
value={value}
onSelect={(option) => onSelect('role', option)}
/>
)}
rules={{ required: true }}
/>
<Select
{...register('experience', { required: true })}
@ -319,16 +351,6 @@ export default function SubmitResumeForm({
required={true}
onChange={(val) => onValueChange('experience', val)}
/>
</div>
<Select
{...register('location', { required: true })}
disabled={isLoading}
label="Location"
options={LOCATIONS}
placeholder=" "
required={true}
onChange={(val) => onValueChange('location', val)}
/>
{/* Upload resume form */}
{isNewForm && (
<div className="space-y-2">

@ -1,8 +1,6 @@
import { z } from 'zod';
import { Vote } from '@prisma/client';
import { EXPERIENCES, LOCATIONS, ROLES } from '~/utils/resumes/resumeFilters';
import { createRouter } from '../context';
import type { Resume } from '~/types/resume';
@ -35,7 +33,7 @@ export const resumesRouter = createRouter()
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
@ -49,6 +47,11 @@ export const resumesRouter = createRouter()
},
},
comments: true,
location: {
select: {
name: true,
},
},
stars: {
where: {
OR: {
@ -79,7 +82,7 @@ export const resumesRouter = createRouter()
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
@ -92,7 +95,8 @@ export const resumesRouter = createRouter()
id: r.id,
isResolved: r.isResolved,
isStarredByUser: r.stars.length > 0,
location: r.location,
location: r.location.name,
locationId: r.locationId,
numComments: r._count.comments,
numStars: r._count.stars,
role: r.role,
@ -103,7 +107,7 @@ export const resumesRouter = createRouter()
return resume;
});
// Group by role and count, taking into account all role/experience/location/isUnreviewed filters and search value
// Group by role and count, taking into account all role/experience/locationId/isUnreviewed filters and search value
const roleCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
@ -112,7 +116,7 @@ export const resumesRouter = createRouter()
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
});
@ -122,20 +126,6 @@ export const resumesRouter = createRouter()
roleCounts.map((rc) => [rc.role, rc._count._all]),
);
// Filter out roles with zero counts and map to object where key = role and value = 0
const zeroRoleCounts = Object.fromEntries(
ROLES.filter((r) => !(r.value in mappedRoleCounts)).map((r) => [
r.value,
0,
]),
);
// Combine to form singular role counts object
const processedRoleCounts = {
...mappedRoleCounts,
...zeroRoleCounts,
};
const experienceCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
@ -143,7 +133,7 @@ export const resumesRouter = createRouter()
by: ['experience'],
where: {
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
@ -151,21 +141,12 @@ export const resumesRouter = createRouter()
const mappedExperienceCounts = Object.fromEntries(
experienceCounts.map((ec) => [ec.experience, ec._count._all]),
);
const zeroExperienceCounts = Object.fromEntries(
EXPERIENCES.filter((e) => !(e.value in mappedExperienceCounts)).map(
(e) => [e.value, 0],
),
);
const processedExperienceCounts = {
...mappedExperienceCounts,
...zeroExperienceCounts,
};
const locationCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['location'],
by: ['locationId'],
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
@ -174,23 +155,13 @@ export const resumesRouter = createRouter()
},
});
const mappedLocationCounts = Object.fromEntries(
locationCounts.map((lc) => [lc.location, lc._count._all]),
locationCounts.map((lc) => [lc.locationId, lc._count._all]),
);
const zeroLocationCounts = Object.fromEntries(
LOCATIONS.filter((l) => !(l.value in mappedLocationCounts)).map((l) => [
l.value,
0,
]),
);
const processedLocationCounts = {
...mappedLocationCounts,
...zeroLocationCounts,
};
const filterCounts = {
Experience: processedExperienceCounts,
Location: processedLocationCounts,
Role: processedRoleCounts,
experience: mappedExperienceCounts,
location: mappedLocationCounts,
role: mappedRoleCounts,
};
return {
@ -217,6 +188,11 @@ export const resumesRouter = createRouter()
stars: true,
},
},
location: {
select: {
name: true,
},
},
stars: {
where: {
OR: {

@ -1,19 +1,16 @@
import { z } from 'zod';
import { EXPERIENCES, LOCATIONS, ROLES } from '~/utils/resumes/resumeFilters';
import { createProtectedRouter } from '../context';
import type { Resume } from '~/types/resume';
export const resumesResumeUserRouter = createProtectedRouter()
.mutation('upsert', {
// TODO: Use enums for experience, location, role
input: z.object({
additionalInfo: z.string().optional(),
experience: z.string(),
id: z.string().optional(),
location: z.string(),
locationId: z.string(),
role: z.string(),
title: z.string(),
url: z.string(),
@ -25,7 +22,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
create: {
additionalInfo: input.additionalInfo,
experience: input.experience,
location: input.location,
locationId: input.locationId,
role: input.role,
title: input.title,
url: input.url,
@ -34,7 +31,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
update: {
additionalInfo: input.additionalInfo,
experience: input.experience,
location: input.location,
locationId: input.locationId,
role: input.role,
title: input.title,
url: input.url,
@ -91,7 +88,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
resume: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
@ -108,6 +105,11 @@ export const resumesResumeUserRouter = createProtectedRouter()
stars: true,
},
},
location: {
select: {
name: true,
},
},
user: {
select: {
name: true,
@ -144,7 +146,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
resume: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
@ -160,7 +162,8 @@ export const resumesResumeUserRouter = createProtectedRouter()
id: rs.resume.id,
isResolved: rs.resume.isResolved,
isStarredByUser: true,
location: rs.resume.location,
location: rs.resume.location.name,
locationId: rs.resume.locationId,
numComments: rs.resume._count.comments,
numStars: rs.resume._count.stars,
role: rs.resume.role,
@ -179,7 +182,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
stars: {
some: {
userId,
@ -191,16 +194,6 @@ export const resumesResumeUserRouter = createProtectedRouter()
const mappedRoleCounts = Object.fromEntries(
roleCounts.map((rc) => [rc.role, rc._count._all]),
);
const zeroRoleCounts = Object.fromEntries(
ROLES.filter((r) => !(r.value in mappedRoleCounts)).map((r) => [
r.value,
0,
]),
);
const processedRoleCounts = {
...mappedRoleCounts,
...zeroRoleCounts,
};
const experienceCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
@ -209,7 +202,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
by: ['experience'],
where: {
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
role: { in: roleFilters },
stars: {
some: {
@ -222,21 +215,12 @@ export const resumesResumeUserRouter = createProtectedRouter()
const mappedExperienceCounts = Object.fromEntries(
experienceCounts.map((ec) => [ec.experience, ec._count._all]),
);
const zeroExperienceCounts = Object.fromEntries(
EXPERIENCES.filter((e) => !(e.value in mappedExperienceCounts)).map(
(e) => [e.value, 0],
),
);
const processedExperienceCounts = {
...mappedExperienceCounts,
...zeroExperienceCounts,
};
const locationCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['location'],
by: ['locationId'],
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
@ -250,23 +234,13 @@ export const resumesResumeUserRouter = createProtectedRouter()
},
});
const mappedLocationCounts = Object.fromEntries(
locationCounts.map((lc) => [lc.location, lc._count._all]),
locationCounts.map((lc) => [lc.locationId, lc._count._all]),
);
const zeroLocationCounts = Object.fromEntries(
LOCATIONS.filter((l) => !(l.value in mappedLocationCounts)).map((l) => [
l.value,
0,
]),
);
const processedLocationCounts = {
...mappedLocationCounts,
...zeroLocationCounts,
};
const filterCounts = {
Experience: processedExperienceCounts,
Location: processedLocationCounts,
Role: processedRoleCounts,
experience: mappedExperienceCounts,
location: mappedLocationCounts,
role: mappedRoleCounts,
};
return { filterCounts, mappedResumeData, totalRecords };
@ -299,7 +273,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
userId,
@ -313,6 +287,11 @@ export const resumesResumeUserRouter = createProtectedRouter()
stars: true,
},
},
location: {
select: {
name: true,
},
},
stars: {
where: {
userId,
@ -341,7 +320,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
userId,
@ -355,7 +334,8 @@ export const resumesResumeUserRouter = createProtectedRouter()
id: r.id,
isResolved: r.isResolved,
isStarredByUser: r.stars.length > 0,
location: r.location,
location: r.location.name,
locationId: r.locationId,
numComments: r._count.comments,
numStars: r._count.stars,
role: r.role,
@ -374,7 +354,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
title: { contains: searchValue, mode: 'insensitive' },
userId,
},
@ -382,16 +362,6 @@ export const resumesResumeUserRouter = createProtectedRouter()
const mappedRoleCounts = Object.fromEntries(
roleCounts.map((rc) => [rc.role, rc._count._all]),
);
const zeroRoleCounts = Object.fromEntries(
ROLES.filter((r) => !(r.value in mappedRoleCounts)).map((r) => [
r.value,
0,
]),
);
const processedRoleCounts = {
...mappedRoleCounts,
...zeroRoleCounts,
};
const experienceCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
@ -400,7 +370,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
by: ['experience'],
where: {
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
userId,
@ -409,21 +379,12 @@ export const resumesResumeUserRouter = createProtectedRouter()
const mappedExperienceCounts = Object.fromEntries(
experienceCounts.map((ec) => [ec.experience, ec._count._all]),
);
const zeroExperienceCounts = Object.fromEntries(
EXPERIENCES.filter((e) => !(e.value in mappedExperienceCounts)).map(
(e) => [e.value, 0],
),
);
const processedExperienceCounts = {
...mappedExperienceCounts,
...zeroExperienceCounts,
};
const locationCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['location'],
by: ['locationId'],
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
@ -433,23 +394,13 @@ export const resumesResumeUserRouter = createProtectedRouter()
},
});
const mappedLocationCounts = Object.fromEntries(
locationCounts.map((lc) => [lc.location, lc._count._all]),
locationCounts.map((lc) => [lc.locationId, lc._count._all]),
);
const zeroLocationCounts = Object.fromEntries(
LOCATIONS.filter((l) => !(l.value in mappedLocationCounts)).map((l) => [
l.value,
0,
]),
);
const processedLocationCounts = {
...mappedLocationCounts,
...zeroLocationCounts,
};
const filterCounts = {
Experience: processedExperienceCounts,
Location: processedLocationCounts,
Role: processedRoleCounts,
experience: mappedExperienceCounts,
location: mappedLocationCounts,
role: mappedRoleCounts,
};
return { filterCounts, mappedResumeData, totalRecords };

@ -6,6 +6,7 @@ export type Resume = {
isResolved: boolean;
isStarredByUser: boolean;
location: string;
locationId: string;
numComments: number;
numStars: number;
role: string;

@ -1,28 +1,14 @@
import type { TypeaheadOption } from '@tih/ui';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { JobTitleLabels } from '~/components/shared/JobTitles';
export type FilterId = 'experience' | 'location' | 'role';
export type FilterLabel = 'Experience' | 'Location' | 'Role';
export type CustomFilter = {
isUnreviewed: boolean;
};
export type RoleFilter =
| 'Android Engineer'
| 'Backend Engineer'
| 'DevOps Engineer'
| 'Frontend Engineer'
| 'Full-Stack Engineer'
| 'iOS Engineer';
export type ExperienceFilter =
| 'Entry Level (0 - 2 years)'
| 'Internship'
| 'Mid Level (3 - 5 years)'
| 'Senior Level (5+ years)';
export type LocationFilter = 'India' | 'Singapore' | 'United States';
export type FilterValue = ExperienceFilter | LocationFilter | RoleFilter;
export type FilterOption<T> = {
label: string;
value: T;
@ -30,11 +16,11 @@ export type FilterOption<T> = {
export type Filter = {
id: FilterId;
label: FilterLabel;
options: Array<FilterOption<FilterValue>>;
label: string;
};
export type FilterState = CustomFilter & Record<FilterId, Array<FilterValue>>;
export type FilterState = CustomFilter &
Record<FilterId, Array<TypeaheadOption>>;
export type SortOrder = 'latest' | 'mostComments' | 'popular';
@ -45,6 +31,31 @@ export type Shortcut = {
sortOrder: SortOrder;
};
export const getTypeaheadOption = (
filterId: FilterId,
filterValue: string,
locationName?: string,
) => {
switch (filterId) {
case 'experience':
return EXPERIENCES.find(({ value }) => value === filterValue);
case 'role':
return {
id: filterValue,
label: JobTitleLabels[filterValue as keyof typeof JobTitleLabels],
value: filterValue,
};
case 'location':
return {
id: filterValue,
label: locationName ?? '',
value: filterValue,
};
default:
break;
}
};
export const BROWSE_TABS_VALUES = {
ALL: 'all',
MY: 'my',
@ -57,45 +68,85 @@ export const SORT_OPTIONS: Array<FilterOption<SortOrder>> = [
{ label: 'Most Comments', value: 'mostComments' },
];
export const ROLES: Array<FilterOption<RoleFilter>> = [
{
label: 'Full-Stack Engineer',
value: 'Full-Stack Engineer',
},
{ label: 'Frontend Engineer', value: 'Frontend Engineer' },
{ label: 'Backend Engineer', value: 'Backend Engineer' },
{ label: 'DevOps Engineer', value: 'DevOps Engineer' },
{ label: 'iOS Engineer', value: 'iOS Engineer' },
{ label: 'Android Engineer', value: 'Android Engineer' },
const INITIAL_ROLES_VALUES: Array<JobTitleType> = [
'software-engineer',
'back-end-engineer',
'front-end-engineer',
'full-stack-engineer',
'ios-engineer',
'android-engineer',
'data-engineer',
];
export const INITIAL_ROLES: Array<TypeaheadOption> = INITIAL_ROLES_VALUES.map(
(value) =>
getTypeaheadOption('role', value) ?? {
id: value,
label: value,
value,
},
);
export const EXPERIENCES: Array<FilterOption<ExperienceFilter>> = [
{ label: 'Internship', value: 'Internship' },
export const EXPERIENCES: Array<TypeaheadOption> = [
{
id: 'internship',
label: 'Internship',
value: 'internship',
},
{
id: 'entry-level',
label: 'Entry Level (0 - 2 years)',
value: 'Entry Level (0 - 2 years)',
value: 'entry-level',
},
{
id: 'mid-level',
label: 'Mid Level (3 - 5 years)',
value: 'Mid Level (3 - 5 years)',
value: 'mid-level',
},
{
id: 'senior-level',
label: 'Senior Level (5+ years)',
value: 'Senior Level (5+ years)',
value: 'senior-level',
},
];
export const LOCATIONS: Array<FilterOption<LocationFilter>> = [
{ label: 'Singapore', value: 'Singapore' },
{ label: 'United States', value: 'United States' },
{ label: 'India', value: 'India' },
export const INITIAL_LOCATIONS: Array<TypeaheadOption> = [
{
id: '196',
label: 'Singapore',
value: '196',
},
{
id: '101',
label: 'India',
value: '101',
},
{
id: '231',
label: 'United States',
value: '231',
},
{
id: '230',
label: 'United Kingdom',
value: '230',
},
{
id: '102',
label: 'Indonesia',
value: '102',
},
{
id: '44',
label: 'China',
value: '44',
},
];
export const INITIAL_FILTER_STATE: FilterState = {
experience: Object.values(EXPERIENCES).map(({ value }) => value),
experience: EXPERIENCES,
isUnreviewed: true,
location: Object.values(LOCATIONS).map(({ value }) => value),
role: Object.values(ROLES).map(({ value }) => value),
location: INITIAL_LOCATIONS,
role: INITIAL_ROLES,
};
export const SHORTCUTS: Array<Shortcut> = [
@ -104,7 +155,7 @@ export const SHORTCUTS: Array<Shortcut> = [
...INITIAL_FILTER_STATE,
isUnreviewed: false,
},
name: 'All',
name: 'General',
sortOrder: 'latest',
},
{
@ -118,7 +169,13 @@ export const SHORTCUTS: Array<Shortcut> = [
{
filters: {
...INITIAL_FILTER_STATE,
experience: ['Entry Level (0 - 2 years)'],
experience: [
{
id: 'entry-level',
label: 'Entry Level (0 - 2 years)',
value: 'entry-level',
},
],
isUnreviewed: false,
},
name: 'Fresh Grad',
@ -136,26 +193,46 @@ export const SHORTCUTS: Array<Shortcut> = [
filters: {
...INITIAL_FILTER_STATE,
isUnreviewed: false,
location: ['United States'],
location: [
{
id: '231',
label: 'United States',
value: '231',
},
],
},
name: 'US Only',
sortOrder: 'latest',
},
];
export const isInitialFilterState = (filters: FilterState) =>
Object.keys(filters).every((filter) => {
if (!['experience', 'location', 'role'].includes(filter)) {
return true;
// We omit 'location' as its label should be fetched from the Country table.
export const getFilterLabel = (
filterId: Omit<FilterId | 'sort', 'location'>,
filterValue: SortOrder | string,
): string | undefined => {
if (filterId === 'location') {
return filterValue;
}
return INITIAL_FILTER_STATE[filter as FilterId].every((value) =>
filters[filter as FilterId].includes(value),
);
});
export const getFilterLabel = (
filters: Array<
FilterOption<ExperienceFilter | LocationFilter | RoleFilter | SortOrder>
>,
filterValue: ExperienceFilter | LocationFilter | RoleFilter | SortOrder,
) => filters.find(({ value }) => value === filterValue)?.label ?? filterValue;
let filters: Array<TypeaheadOption> = [];
switch (filterId) {
case 'experience':
filters = EXPERIENCES;
break;
case 'role':
filters = Object.entries(JobTitleLabels).map(([slug, label]) => ({
id: slug,
label,
value: slug,
}));
break;
case 'sort':
return SORT_OPTIONS.find(({ value }) => value === filterValue)?.label;
default:
break;
}
return filters.find(({ value }) => value === filterValue)?.label;
};

Loading…
Cancel
Save