[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", "react-query": "^3.39.2",
"read-excel-file": "^5.5.3", "read-excel-file": "^5.5.3",
"superjson": "^1.10.0", "superjson": "^1.10.0",
"xlsx": "^0.18.5",
"unique-names-generator": "^4.7.1", "unique-names-generator": "^4.7.1",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
"zod": "^3.18.0" "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 code String @unique
states State[] states State[]
questionsQuestionEncounters QuestionsQuestionEncounter[] questionsQuestionEncounters QuestionsQuestionEncounter[]
ResumesResume ResumesResume[]
} }
model State { model State {
@ -148,13 +149,14 @@ model ResumesResume {
// TODO: Update role, experience, location to use Enums // TODO: Update role, experience, location to use Enums
role String @db.Text role String @db.Text
experience String @db.Text experience String @db.Text
location String @db.Text locationId String
url String url String
additionalInfo String? @db.Text additionalInfo String? @db.Text
isResolved Boolean @default(false) isResolved Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
location Country @relation(fields: [locationId], references: [id], onDelete: Cascade)
stars ResumesStar[] stars ResumesStar[]
comments ResumesComment[] comments ResumesComment[]
} }

@ -12,17 +12,7 @@ import { ChatBubbleLeftIcon, StarIcon } from '@heroicons/react/24/outline';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics'; import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import type { import { getFilterLabel } from '~/utils/resumes/resumeFilters';
ExperienceFilter,
LocationFilter,
RoleFilter,
} from '~/utils/resumes/resumeFilters';
import {
EXPERIENCES,
getFilterLabel,
LOCATIONS,
ROLES,
} from '~/utils/resumes/resumeFilters';
import type { Resume } from '~/types/resume'; 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="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="sm:col-span-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{resumeInfo.title} <p className="truncate">{resumeInfo.title}</p>
<p <p
className={clsx( 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', 'w-auto items-center space-x-4 rounded-xl border px-2 py-1 text-xs font-medium',
resumeInfo.isResolved ? 'bg-slate-400' : 'bg-success-500', resumeInfo.isResolved ? 'bg-slate-300' : 'bg-success-100',
resumeInfo.isResolved ? 'text-slate-600' : 'text-success-700',
)}> )}>
<span className="opacity-100">
{resumeInfo.isResolved ? 'Reviewed' : 'Unreviewed'} {resumeInfo.isResolved ? 'Reviewed' : 'Unreviewed'}
</span>
</p> </p>
</div> </div>
<div className="text-primary-500 mt-2 flex items-center justify-start text-xs"> <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" aria-hidden="true"
className="mr-1.5 h-4 w-4 flex-shrink-0" className="mr-1.5 h-4 w-4 flex-shrink-0"
/> />
{getFilterLabel(ROLES, resumeInfo.role as RoleFilter)} {getFilterLabel('role', resumeInfo.role)}
</div> </div>
<div className="ml-4 flex"> <div className="ml-4 flex">
<AcademicCapIcon <AcademicCapIcon
aria-hidden="true" aria-hidden="true"
className="mr-1.5 h-4 w-4 flex-shrink-0" className="mr-1.5 h-4 w-4 flex-shrink-0"
/> />
{getFilterLabel( {getFilterLabel('experience', resumeInfo.experience)}
EXPERIENCES,
resumeInfo.experience as ExperienceFilter,
)}
</div> </div>
</div> </div>
<div className="mt-4 flex justify-start text-xs text-slate-500"> <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, addSuffix: true,
})} by ${resumeInfo.user}`} })} by ${resumeInfo.user}`}
</div> </div>
<div className="mt-2 text-slate-400"> <div className="mt-2 text-slate-400">{resumeInfo.location}</div>
{getFilterLabel(LOCATIONS, resumeInfo.location as LocationFilter)}
</div>
</div> </div>
</div> </div>
<ChevronRightIcon className="col-span-1 w-8 self-center justify-self-center text-slate-400" /> <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 ResumeExpandableText from '~/components/resumes/shared/ResumeExpandableText';
import loginPageHref from '~/components/shared/loginPageHref'; import loginPageHref from '~/components/shared/loginPageHref';
import type {
ExperienceFilter,
FilterOption,
LocationFilter,
RoleFilter,
} from '~/utils/resumes/resumeFilters';
import { import {
BROWSE_TABS_VALUES, BROWSE_TABS_VALUES,
EXPERIENCES,
getFilterLabel, getFilterLabel,
getTypeaheadOption,
INITIAL_FILTER_STATE, INITIAL_FILTER_STATE,
LOCATIONS,
ROLES,
} from '~/utils/resumes/resumeFilters'; } from '~/utils/resumes/resumeFilters';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import SubmitResumeForm from './submit'; import SubmitResumeForm from './submit';
import type { JobTitleType } from '../../components/shared/JobTitles';
import { getLabelForJobTitleType } from '../../components/shared/JobTitles';
export default function ResumeReviewPage() { export default function ResumeReviewPage() {
const ErrorPage = ( const ErrorPage = (
@ -124,29 +118,24 @@ export default function ResumeReviewPage() {
}; };
const onInfoTagClick = ({ const onInfoTagClick = ({
locationLabel, locationName,
experienceLabel, locationValue,
roleLabel, experienceValue,
roleValue,
}: { }: {
experienceLabel?: string; experienceValue?: string;
locationLabel?: string; locationName?: string;
roleLabel?: string; locationValue?: string;
roleValue?: string;
}) => { }) => {
const getFilterValue = (
label: string,
filterOptions: Array<
FilterOption<ExperienceFilter | LocationFilter | RoleFilter>
>,
) => filterOptions.find((option) => option.label === label)?.value;
router.push({ router.push({
pathname: '/resumes', pathname: '/resumes',
query: { query: {
currentPage: JSON.stringify(1), currentPage: JSON.stringify(1),
isFiltersOpen: JSON.stringify({ isFiltersOpen: JSON.stringify({
experience: experienceLabel !== undefined, experience: experienceValue !== undefined,
location: locationLabel !== undefined, location: locationValue !== undefined,
role: roleLabel !== undefined, role: roleValue !== undefined,
}), }),
searchValue: JSON.stringify(''), searchValue: JSON.stringify(''),
shortcutSelected: JSON.stringify('all'), shortcutSelected: JSON.stringify('all'),
@ -154,14 +143,16 @@ export default function ResumeReviewPage() {
tabsValue: JSON.stringify(BROWSE_TABS_VALUES.ALL), tabsValue: JSON.stringify(BROWSE_TABS_VALUES.ALL),
userFilters: JSON.stringify({ userFilters: JSON.stringify({
...INITIAL_FILTER_STATE, ...INITIAL_FILTER_STATE,
...(locationLabel && { ...(locationValue && {
location: [getFilterValue(locationLabel, LOCATIONS)], location: [
getTypeaheadOption('location', locationValue, locationName),
],
}), }),
...(roleLabel && { ...(roleValue && {
role: [getFilterValue(roleLabel, ROLES)], role: [getTypeaheadOption('role', roleValue)],
}), }),
...(experienceLabel && { ...(experienceValue && {
experience: [getFilterValue(experienceLabel, EXPERIENCES)], experience: [getTypeaheadOption('experience', experienceValue)],
}), }),
}), }),
}, },
@ -207,9 +198,19 @@ export default function ResumeReviewPage() {
initFormDetails={{ initFormDetails={{
additionalInfo: detailsQuery.data.additionalInfo ?? '', additionalInfo: detailsQuery.data.additionalInfo ?? '',
experience: detailsQuery.data.experience, 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, 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, title: detailsQuery.data.title,
url: detailsQuery.data.url, url: detailsQuery.data.url,
}} }}
@ -325,13 +326,10 @@ export default function ResumeReviewPage() {
type="button" type="button"
onClick={() => onClick={() =>
onInfoTagClick({ onInfoTagClick({
roleLabel: detailsQuery.data?.role, roleValue: detailsQuery.data?.role,
}) })
}> }>
{getFilterLabel( {getFilterLabel('role', detailsQuery.data.role)}
ROLES,
detailsQuery.data.role as RoleFilter,
)}
</button> </button>
</div> </div>
<div className="col-span-1 flex items-center text-xs text-slate-600 sm:text-sm"> <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" type="button"
onClick={() => onClick={() =>
onInfoTagClick({ onInfoTagClick({
locationLabel: detailsQuery.data?.location, locationName: detailsQuery.data?.location.name,
locationValue: detailsQuery.data?.locationId,
}) })
}> }>
{getFilterLabel( {detailsQuery.data?.location.name}
LOCATIONS,
detailsQuery.data.location as LocationFilter,
)}
</button> </button>
</div> </div>
<div className="col-span-1 flex items-center text-xs text-slate-600 sm:text-sm"> <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" type="button"
onClick={() => onClick={() =>
onInfoTagClick({ onInfoTagClick({
experienceLabel: detailsQuery.data?.experience, experienceValue: detailsQuery.data?.experience,
}) })
}> }>
{getFilterLabel( {getFilterLabel(
EXPERIENCES, 'experience',
detailsQuery.data.experience as ExperienceFilter, detailsQuery.data.experience,
)} )}
</button> </button>
</div> </div>

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

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

@ -1,8 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { Vote } from '@prisma/client'; import { Vote } from '@prisma/client';
import { EXPERIENCES, LOCATIONS, ROLES } from '~/utils/resumes/resumeFilters';
import { createRouter } from '../context'; import { createRouter } from '../context';
import type { Resume } from '~/types/resume'; import type { Resume } from '~/types/resume';
@ -35,7 +33,7 @@ export const resumesRouter = createRouter()
where: { where: {
experience: { in: experienceFilters }, experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {}, isResolved: isUnreviewed ? false : {},
location: { in: locationFilters }, locationId: { in: locationFilters },
role: { in: roleFilters }, role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' }, title: { contains: searchValue, mode: 'insensitive' },
}, },
@ -49,6 +47,11 @@ export const resumesRouter = createRouter()
}, },
}, },
comments: true, comments: true,
location: {
select: {
name: true,
},
},
stars: { stars: {
where: { where: {
OR: { OR: {
@ -79,7 +82,7 @@ export const resumesRouter = createRouter()
where: { where: {
experience: { in: experienceFilters }, experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {}, isResolved: isUnreviewed ? false : {},
location: { in: locationFilters }, locationId: { in: locationFilters },
role: { in: roleFilters }, role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' }, title: { contains: searchValue, mode: 'insensitive' },
}, },
@ -92,7 +95,8 @@ export const resumesRouter = createRouter()
id: r.id, id: r.id,
isResolved: r.isResolved, isResolved: r.isResolved,
isStarredByUser: r.stars.length > 0, isStarredByUser: r.stars.length > 0,
location: r.location, location: r.location.name,
locationId: r.locationId,
numComments: r._count.comments, numComments: r._count.comments,
numStars: r._count.stars, numStars: r._count.stars,
role: r.role, role: r.role,
@ -103,7 +107,7 @@ export const resumesRouter = createRouter()
return resume; 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({ const roleCounts = await ctx.prisma.resumesResume.groupBy({
_count: { _count: {
_all: true, _all: true,
@ -112,7 +116,7 @@ export const resumesRouter = createRouter()
where: { where: {
experience: { in: experienceFilters }, experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {}, isResolved: isUnreviewed ? false : {},
location: { in: locationFilters }, locationId: { in: locationFilters },
title: { contains: searchValue, mode: 'insensitive' }, title: { contains: searchValue, mode: 'insensitive' },
}, },
}); });
@ -122,20 +126,6 @@ export const resumesRouter = createRouter()
roleCounts.map((rc) => [rc.role, rc._count._all]), 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({ const experienceCounts = await ctx.prisma.resumesResume.groupBy({
_count: { _count: {
_all: true, _all: true,
@ -143,7 +133,7 @@ export const resumesRouter = createRouter()
by: ['experience'], by: ['experience'],
where: { where: {
isResolved: isUnreviewed ? false : {}, isResolved: isUnreviewed ? false : {},
location: { in: locationFilters }, locationId: { in: locationFilters },
role: { in: roleFilters }, role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' }, title: { contains: searchValue, mode: 'insensitive' },
}, },
@ -151,21 +141,12 @@ export const resumesRouter = createRouter()
const mappedExperienceCounts = Object.fromEntries( const mappedExperienceCounts = Object.fromEntries(
experienceCounts.map((ec) => [ec.experience, ec._count._all]), 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({ const locationCounts = await ctx.prisma.resumesResume.groupBy({
_count: { _count: {
_all: true, _all: true,
}, },
by: ['location'], by: ['locationId'],
where: { where: {
experience: { in: experienceFilters }, experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {}, isResolved: isUnreviewed ? false : {},
@ -174,23 +155,13 @@ export const resumesRouter = createRouter()
}, },
}); });
const mappedLocationCounts = Object.fromEntries( 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 = { const filterCounts = {
Experience: processedExperienceCounts, experience: mappedExperienceCounts,
Location: processedLocationCounts, location: mappedLocationCounts,
Role: processedRoleCounts, role: mappedRoleCounts,
}; };
return { return {
@ -217,6 +188,11 @@ export const resumesRouter = createRouter()
stars: true, stars: true,
}, },
}, },
location: {
select: {
name: true,
},
},
stars: { stars: {
where: { where: {
OR: { OR: {

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

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