mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2026-02-02 18:21:37 +08:00
[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>
This commit is contained in:
@@ -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>
|
||||
{resumeInfo.isResolved ? 'Reviewed' : 'Unreviewed'}
|
||||
</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,35 +314,42 @@ 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}
|
||||
disabled={isLoading}
|
||||
label="Role"
|
||||
options={ROLES}
|
||||
placeholder=" "
|
||||
required={true}
|
||||
onChange={(val) => onValueChange('role', val)}
|
||||
/>
|
||||
<Select
|
||||
{...register('experience', { required: true })}
|
||||
disabled={isLoading}
|
||||
label="Experience Level"
|
||||
options={EXPERIENCES}
|
||||
placeholder=" "
|
||||
required={true}
|
||||
onChange={(val) => onValueChange('experience', val)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="location"
|
||||
render={({ field: { value } }) => (
|
||||
<ResumeLocationTypeahead
|
||||
disabled={isLoading}
|
||||
placeholder="Select a location"
|
||||
required={true}
|
||||
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('location', { required: true })}
|
||||
{...register('experience', { required: true })}
|
||||
disabled={isLoading}
|
||||
label="Location"
|
||||
options={LOCATIONS}
|
||||
label="Experience Level"
|
||||
options={EXPERIENCES}
|
||||
placeholder=" "
|
||||
required={true}
|
||||
onChange={(val) => onValueChange('location', val)}
|
||||
onChange={(val) => onValueChange('experience', val)}
|
||||
/>
|
||||
{/* Upload resume form */}
|
||||
{isNewForm && (
|
||||
|
||||
@@ -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 };
|
||||
|
||||
1
apps/portal/src/types/resume.d.ts
vendored
1
apps/portal/src/types/resume.d.ts
vendored
@@ -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;
|
||||
}
|
||||
return INITIAL_FILTER_STATE[filter as FilterId].every((value) =>
|
||||
filters[filter as FilterId].includes(value),
|
||||
);
|
||||
});
|
||||
|
||||
// We omit 'location' as its label should be fetched from the Country table.
|
||||
export const getFilterLabel = (
|
||||
filters: Array<
|
||||
FilterOption<ExperienceFilter | LocationFilter | RoleFilter | SortOrder>
|
||||
>,
|
||||
filterValue: ExperienceFilter | LocationFilter | RoleFilter | SortOrder,
|
||||
) => filters.find(({ value }) => value === filterValue)?.label ?? filterValue;
|
||||
filterId: Omit<FilterId | 'sort', 'location'>,
|
||||
filterValue: SortOrder | string,
|
||||
): string | undefined => {
|
||||
if (filterId === 'location') {
|
||||
return 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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user