mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-28 20:52:00 +08:00
[resumes][feat] resumes sorting and filtering (#374)
* [resumes][fix] use spinner for laggy star * [resumes][feat] add filtering for resumes * [resumes][feat] add sorting of resumes
This commit is contained in:
@ -1,4 +1,4 @@
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import formatDistanceToNow from 'date-fns/formatDistanceToNow';
|
||||
import Link from 'next/link';
|
||||
import type { UrlObject } from 'url';
|
||||
import { ChevronRightIcon } from '@heroicons/react/20/solid';
|
||||
|
@ -4,10 +4,16 @@ export const BROWSE_TABS_VALUES = {
|
||||
STARRED: 'starred',
|
||||
};
|
||||
|
||||
export const SORT_OPTIONS = [
|
||||
{ current: true, href: '#', name: 'Latest' },
|
||||
{ current: false, href: '#', name: 'Popular' },
|
||||
{ current: false, href: '#', name: 'Top Comments' },
|
||||
export type SortOrder = 'latest' | 'popular' | 'topComments';
|
||||
type SortOption = {
|
||||
name: string;
|
||||
value: SortOrder;
|
||||
};
|
||||
|
||||
export const SORT_OPTIONS: Array<SortOption> = [
|
||||
{ name: 'Latest', value: 'latest' },
|
||||
{ name: 'Popular', value: 'popular' },
|
||||
{ name: 'Top Comments', value: 'topComments' },
|
||||
];
|
||||
|
||||
export const TOP_HITS = [
|
||||
@ -17,45 +23,46 @@ export const TOP_HITS = [
|
||||
{ href: '#', name: 'US Only' },
|
||||
];
|
||||
|
||||
export const ROLES = [
|
||||
export type FilterOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const ROLE: Array<FilterOption> = [
|
||||
{
|
||||
checked: false,
|
||||
label: 'Full-Stack Engineer',
|
||||
value: 'Full-Stack Engineer',
|
||||
},
|
||||
{ checked: false, label: 'Frontend Engineer', value: 'Frontend Engineer' },
|
||||
{ checked: false, label: 'Backend Engineer', value: 'Backend Engineer' },
|
||||
{ checked: false, label: 'DevOps Engineer', value: 'DevOps Engineer' },
|
||||
{ checked: false, label: 'iOS Engineer', value: 'iOS Engineer' },
|
||||
{ checked: false, label: 'Android Engineer', value: 'Android 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' },
|
||||
];
|
||||
|
||||
export const EXPERIENCE = [
|
||||
{ checked: false, label: 'Freshman', value: 'Freshman' },
|
||||
{ checked: false, label: 'Sophomore', value: 'Sophomore' },
|
||||
{ checked: false, label: 'Junior', value: 'Junior' },
|
||||
{ checked: false, label: 'Senior', value: 'Senior' },
|
||||
export const EXPERIENCE: Array<FilterOption> = [
|
||||
{ label: 'Freshman', value: 'Freshman' },
|
||||
{ label: 'Sophomore', value: 'Sophomore' },
|
||||
{ label: 'Junior', value: 'Junior' },
|
||||
{ label: 'Senior', value: 'Senior' },
|
||||
{
|
||||
checked: false,
|
||||
label: 'Fresh Grad (0-1 years)',
|
||||
value: 'Fresh Grad (0-1 years)',
|
||||
},
|
||||
{
|
||||
checked: false,
|
||||
label: 'Mid-level (2 - 5 years)',
|
||||
value: 'Mid-level (2 - 5 years)',
|
||||
},
|
||||
{
|
||||
checked: false,
|
||||
label: 'Senior (5+ years)',
|
||||
value: 'Senior (5+ years)',
|
||||
},
|
||||
];
|
||||
|
||||
export const LOCATION = [
|
||||
{ checked: false, label: 'Singapore', value: 'Singapore' },
|
||||
{ checked: false, label: 'United States', value: 'United States' },
|
||||
{ checked: false, label: 'India', value: 'India' },
|
||||
export const LOCATION: Array<FilterOption> = [
|
||||
{ label: 'Singapore', value: 'Singapore' },
|
||||
{ label: 'United States', value: 'United States' },
|
||||
{ label: 'India', value: 'India' },
|
||||
];
|
||||
|
||||
export const TEST_RESUMES = [
|
||||
|
@ -4,7 +4,6 @@ import Error from 'next/error';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
AcademicCapIcon,
|
||||
BriefcaseIcon,
|
||||
@ -27,6 +26,7 @@ export default function ResumeReviewPage() {
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const { resumeId } = router.query;
|
||||
const utils = trpc.useContext();
|
||||
// Safe to assert resumeId type as string because query is only sent if so
|
||||
const detailsQuery = trpc.useQuery(
|
||||
['resumes.resume.findOne', { resumeId: resumeId as string }],
|
||||
@ -36,33 +36,14 @@ export default function ResumeReviewPage() {
|
||||
);
|
||||
const starMutation = trpc.useMutation('resumes.resume.star', {
|
||||
onSuccess() {
|
||||
setStarDetails({
|
||||
isStarred: true,
|
||||
numStars: starDetails.numStars + 1,
|
||||
});
|
||||
utils.invalidateQueries(['resumes.resume.findOne']);
|
||||
},
|
||||
});
|
||||
const unstarMutation = trpc.useMutation('resumes.resume.unstar', {
|
||||
onSuccess() {
|
||||
setStarDetails({
|
||||
isStarred: false,
|
||||
numStars: starDetails.numStars - 1,
|
||||
});
|
||||
utils.invalidateQueries(['resumes.resume.findOne']);
|
||||
},
|
||||
});
|
||||
const [starDetails, setStarDetails] = useState({
|
||||
isStarred: false,
|
||||
numStars: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (detailsQuery?.data !== undefined) {
|
||||
setStarDetails({
|
||||
isStarred: !!detailsQuery.data?.stars.length,
|
||||
numStars: detailsQuery.data?._count.stars ?? 0,
|
||||
});
|
||||
}
|
||||
}, [detailsQuery.data]);
|
||||
|
||||
const onStarButtonClick = () => {
|
||||
if (session?.user?.id == null) {
|
||||
@ -72,7 +53,7 @@ export default function ResumeReviewPage() {
|
||||
|
||||
// Star button only rendered if resume exists
|
||||
// Star button only clickable if user exists
|
||||
if (starDetails.isStarred) {
|
||||
if (detailsQuery.data?.stars.length) {
|
||||
unstarMutation.mutate({
|
||||
resumeId: resumeId as string,
|
||||
});
|
||||
@ -104,30 +85,37 @@ export default function ResumeReviewPage() {
|
||||
</h1>
|
||||
<button
|
||||
className={clsx(
|
||||
starDetails.isStarred
|
||||
detailsQuery.data?.stars.length
|
||||
? 'z-10 border-indigo-500 outline-none ring-1 ring-indigo-500'
|
||||
: '',
|
||||
'isolate inline-flex max-h-10 items-center space-x-4 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 disabled:hover:bg-white',
|
||||
)}
|
||||
disabled={starMutation.isLoading || unstarMutation.isLoading}
|
||||
id="star-button"
|
||||
disabled={
|
||||
session?.user === undefined ||
|
||||
starMutation.isLoading ||
|
||||
unstarMutation.isLoading
|
||||
}
|
||||
type="button"
|
||||
onClick={onStarButtonClick}>
|
||||
<span className="relative inline-flex">
|
||||
<StarIcon
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
starDetails.isStarred
|
||||
? 'text-orange-400'
|
||||
: 'text-gray-400',
|
||||
'-ml-1 mr-2 h-5 w-5',
|
||||
<div className="-ml-1 mr-2 h-5 w-5">
|
||||
{starMutation.isLoading || unstarMutation.isLoading ? (
|
||||
<Spinner className="mt-0.5" size="xs" />
|
||||
) : (
|
||||
<StarIcon
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
detailsQuery.data?.stars.length
|
||||
? 'text-orange-400'
|
||||
: 'text-gray-400',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
id="star-icon"
|
||||
/>
|
||||
</div>
|
||||
Star
|
||||
</span>
|
||||
<span className="relative -ml-px inline-flex">
|
||||
{starDetails.numStars}
|
||||
{detailsQuery.data?._count.stars}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -1,22 +1,28 @@
|
||||
import clsx from 'clsx';
|
||||
import compareAsc from 'date-fns/compareAsc';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Disclosure, Menu, Transition } from '@headlessui/react';
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
} from '@heroicons/react/20/solid';
|
||||
import { useState } from 'react';
|
||||
import { Disclosure } from '@headlessui/react';
|
||||
import { MinusIcon, PlusIcon } from '@heroicons/react/20/solid';
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import { Tabs, TextInput } from '@tih/ui';
|
||||
import {
|
||||
CheckboxInput,
|
||||
CheckboxList,
|
||||
DropdownMenu,
|
||||
Tabs,
|
||||
TextInput,
|
||||
} from '@tih/ui';
|
||||
|
||||
import type {
|
||||
FilterOption,
|
||||
SortOrder,
|
||||
} from '~/components/resumes/browse/resumeConstants';
|
||||
import {
|
||||
BROWSE_TABS_VALUES,
|
||||
EXPERIENCE,
|
||||
LOCATION,
|
||||
ROLES,
|
||||
ROLE,
|
||||
SORT_OPTIONS,
|
||||
TOP_HITS,
|
||||
} from '~/components/resumes/browse/resumeConstants';
|
||||
@ -29,11 +35,19 @@ import { trpc } from '~/utils/trpc';
|
||||
|
||||
import type { Resume } from '~/types/resume';
|
||||
|
||||
const filters = [
|
||||
type FilterId = 'experience' | 'location' | 'role';
|
||||
type Filter = {
|
||||
id: FilterId;
|
||||
name: string;
|
||||
options: Array<FilterOption>;
|
||||
};
|
||||
type FilterState = Record<FilterId, Array<string>>;
|
||||
|
||||
const filters: Array<Filter> = [
|
||||
{
|
||||
id: 'roles',
|
||||
name: 'Roles',
|
||||
options: ROLES,
|
||||
id: 'role',
|
||||
name: 'Role',
|
||||
options: ROLE,
|
||||
},
|
||||
{
|
||||
id: 'experience',
|
||||
@ -47,11 +61,47 @@ const filters = [
|
||||
},
|
||||
];
|
||||
|
||||
const INITIAL_FILTER_STATE: FilterState = {
|
||||
experience: Object.values(EXPERIENCE).map(({ value }) => value),
|
||||
location: Object.values(LOCATION).map(({ value }) => value),
|
||||
role: Object.values(ROLE).map(({ value }) => value),
|
||||
};
|
||||
|
||||
const filterResumes = (
|
||||
resumes: Array<Resume>,
|
||||
searchValue: string,
|
||||
userFilters: FilterState,
|
||||
) =>
|
||||
resumes
|
||||
.filter((resume) =>
|
||||
resume.title.toLowerCase().includes(searchValue.toLocaleLowerCase()),
|
||||
)
|
||||
.filter(
|
||||
({ experience, location, role }) =>
|
||||
userFilters.role.includes(role) &&
|
||||
userFilters.experience.includes(experience) &&
|
||||
userFilters.location.includes(location),
|
||||
);
|
||||
|
||||
const sortComparators: Record<
|
||||
SortOrder,
|
||||
(resume1: Resume, resume2: Resume) => number
|
||||
> = {
|
||||
latest: (resume1, resume2) =>
|
||||
compareAsc(resume2.createdAt, resume1.createdAt),
|
||||
popular: (resume1, resume2) => resume2.numStars - resume1.numStars,
|
||||
topComments: (resume1, resume2) => resume2.numComments - resume1.numComments,
|
||||
};
|
||||
const sortResumes = (resumes: Array<Resume>, sortOrder: SortOrder) =>
|
||||
resumes.sort(sortComparators[sortOrder]);
|
||||
|
||||
export default function ResumeHomePage() {
|
||||
const { data: sessionData } = useSession();
|
||||
const router = useRouter();
|
||||
const [tabsValue, setTabsValue] = useState(BROWSE_TABS_VALUES.ALL);
|
||||
const [sortOrder, setSortOrder] = useState(SORT_OPTIONS[0].value);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [userFilters, setUserFilters] = useState(INITIAL_FILTER_STATE);
|
||||
const [resumes, setResumes] = useState<Array<Resume>>([]);
|
||||
const [renderSignInButton, setRenderSignInButton] = useState(false);
|
||||
const [signInButtonText, setSignInButtonText] = useState('');
|
||||
@ -102,6 +152,26 @@ 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,
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
@ -154,49 +224,17 @@ export default function ResumeHomePage() {
|
||||
</form>
|
||||
</div>
|
||||
<div className="col-span-1 justify-self-center">
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
{/* TODO: Sort logic */}
|
||||
<Menu.Button className="group inline-flex justify-center text-sm font-medium text-gray-700 hover:text-gray-900">
|
||||
Sort
|
||||
<ChevronDownIcon
|
||||
aria-hidden="true"
|
||||
className="-mr-1 ml-1 h-5 w-5 flex-shrink-0 text-gray-400 group-hover:text-gray-500"
|
||||
/>
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95">
|
||||
<Menu.Items className="absolute right-0 z-10 mt-2 w-40 origin-top-right rounded-md bg-white shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="py-1">
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<Menu.Item key={option.name}>
|
||||
{({ active }) => (
|
||||
<a
|
||||
className={clsx(
|
||||
option.current
|
||||
? 'font-medium text-gray-900'
|
||||
: 'text-gray-500',
|
||||
active ? 'bg-gray-100' : '',
|
||||
'block px-4 py-2 text-sm',
|
||||
)}
|
||||
href={option.href}>
|
||||
{option.name}
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
<DropdownMenu align="end" label="Sort">
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<DropdownMenu.Item
|
||||
key={option.name}
|
||||
isSelected={sortOrder === option.value}
|
||||
label={option.name}
|
||||
onClick={() =>
|
||||
setSortOrder(option.value)
|
||||
}></DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<button
|
||||
@ -256,28 +294,32 @@ export default function ResumeHomePage() {
|
||||
</span>
|
||||
</Disclosure.Button>
|
||||
</h3>
|
||||
<Disclosure.Panel className="pt-6">
|
||||
<div className="space-y-4">
|
||||
{section.options.map((option, optionIdx) => (
|
||||
<Disclosure.Panel className="pt-4">
|
||||
<CheckboxList
|
||||
description=""
|
||||
isLabelHidden={true}
|
||||
label=""
|
||||
orientation="vertical">
|
||||
{section.options.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className="flex items-center">
|
||||
<input
|
||||
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
defaultChecked={option.checked}
|
||||
defaultValue={option.value}
|
||||
id={`filter-${section.id}-${optionIdx}`}
|
||||
name={`${section.id}[]`}
|
||||
type="checkbox"
|
||||
className="[&>div>div:nth-child(2)>label]:font-normal [&>div>div:nth-child(1)>input]:text-indigo-600 [&>div>div:nth-child(1)>input]:ring-indigo-500">
|
||||
<CheckboxInput
|
||||
label={option.label}
|
||||
value={userFilters[section.id].includes(
|
||||
option.value,
|
||||
)}
|
||||
onChange={(isChecked) =>
|
||||
onFilterCheckboxChange(
|
||||
isChecked,
|
||||
section.id,
|
||||
option.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
className="ml-3 text-sm text-gray-600"
|
||||
htmlFor={`filter-${section.id}-${optionIdx}`}>
|
||||
{option.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CheckboxList>
|
||||
</Disclosure.Panel>
|
||||
</>
|
||||
)}
|
||||
@ -296,7 +338,10 @@ export default function ResumeHomePage() {
|
||||
starredResumesQuery.isFetching ||
|
||||
myResumesQuery.isFetching
|
||||
}
|
||||
resumes={resumes}
|
||||
resumes={sortResumes(
|
||||
filterResumes(resumes, searchValue, userFilters),
|
||||
sortOrder,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -11,7 +11,7 @@ import { Button, CheckboxInput, Select, TextArea, TextInput } from '@tih/ui';
|
||||
import {
|
||||
EXPERIENCE,
|
||||
LOCATION,
|
||||
ROLES,
|
||||
ROLE,
|
||||
} from '~/components/resumes/browse/resumeConstants';
|
||||
|
||||
import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys';
|
||||
@ -152,7 +152,7 @@ export default function SubmitResumeForm() {
|
||||
{...register('role', { required: true })}
|
||||
disabled={isLoading}
|
||||
label="Role"
|
||||
options={ROLES}
|
||||
options={ROLE}
|
||||
required={true}
|
||||
onChange={(val) => setValue('role', val)}
|
||||
/>
|
||||
|
Reference in New Issue
Block a user