[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:
Peirong
2022-10-13 17:55:15 +08:00
committed by GitHub
parent fd67a20a2b
commit a6700a2bca
5 changed files with 177 additions and 137 deletions

View File

@ -1,4 +1,4 @@
import { formatDistanceToNow } from 'date-fns'; import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import Link from 'next/link'; import Link from 'next/link';
import type { UrlObject } from 'url'; import type { UrlObject } from 'url';
import { ChevronRightIcon } from '@heroicons/react/20/solid'; import { ChevronRightIcon } from '@heroicons/react/20/solid';

View File

@ -4,10 +4,16 @@ export const BROWSE_TABS_VALUES = {
STARRED: 'starred', STARRED: 'starred',
}; };
export const SORT_OPTIONS = [ export type SortOrder = 'latest' | 'popular' | 'topComments';
{ current: true, href: '#', name: 'Latest' }, type SortOption = {
{ current: false, href: '#', name: 'Popular' }, name: string;
{ current: false, href: '#', name: 'Top Comments' }, 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 = [ export const TOP_HITS = [
@ -17,45 +23,46 @@ export const TOP_HITS = [
{ href: '#', name: 'US Only' }, { 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', label: 'Full-Stack Engineer',
value: 'Full-Stack Engineer', value: 'Full-Stack Engineer',
}, },
{ checked: false, label: 'Frontend Engineer', value: 'Frontend Engineer' }, { label: 'Frontend Engineer', value: 'Frontend Engineer' },
{ checked: false, label: 'Backend Engineer', value: 'Backend Engineer' }, { label: 'Backend Engineer', value: 'Backend Engineer' },
{ checked: false, label: 'DevOps Engineer', value: 'DevOps Engineer' }, { label: 'DevOps Engineer', value: 'DevOps Engineer' },
{ checked: false, label: 'iOS Engineer', value: 'iOS Engineer' }, { label: 'iOS Engineer', value: 'iOS Engineer' },
{ checked: false, label: 'Android Engineer', value: 'Android Engineer' }, { label: 'Android Engineer', value: 'Android Engineer' },
]; ];
export const EXPERIENCE = [ export const EXPERIENCE: Array<FilterOption> = [
{ checked: false, label: 'Freshman', value: 'Freshman' }, { label: 'Freshman', value: 'Freshman' },
{ checked: false, label: 'Sophomore', value: 'Sophomore' }, { label: 'Sophomore', value: 'Sophomore' },
{ checked: false, label: 'Junior', value: 'Junior' }, { label: 'Junior', value: 'Junior' },
{ checked: false, label: 'Senior', value: 'Senior' }, { label: 'Senior', value: 'Senior' },
{ {
checked: false,
label: 'Fresh Grad (0-1 years)', label: 'Fresh Grad (0-1 years)',
value: 'Fresh Grad (0-1 years)', value: 'Fresh Grad (0-1 years)',
}, },
{ {
checked: false,
label: 'Mid-level (2 - 5 years)', label: 'Mid-level (2 - 5 years)',
value: 'Mid-level (2 - 5 years)', value: 'Mid-level (2 - 5 years)',
}, },
{ {
checked: false,
label: 'Senior (5+ years)', label: 'Senior (5+ years)',
value: 'Senior (5+ years)', value: 'Senior (5+ years)',
}, },
]; ];
export const LOCATION = [ export const LOCATION: Array<FilterOption> = [
{ checked: false, label: 'Singapore', value: 'Singapore' }, { label: 'Singapore', value: 'Singapore' },
{ checked: false, label: 'United States', value: 'United States' }, { label: 'United States', value: 'United States' },
{ checked: false, label: 'India', value: 'India' }, { label: 'India', value: 'India' },
]; ];
export const TEST_RESUMES = [ export const TEST_RESUMES = [

View File

@ -4,7 +4,6 @@ import Error from 'next/error';
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useEffect, useState } from 'react';
import { import {
AcademicCapIcon, AcademicCapIcon,
BriefcaseIcon, BriefcaseIcon,
@ -27,6 +26,7 @@ export default function ResumeReviewPage() {
const { data: session } = useSession(); const { data: session } = useSession();
const router = useRouter(); const router = useRouter();
const { resumeId } = router.query; const { resumeId } = router.query;
const utils = trpc.useContext();
// Safe to assert resumeId type as string because query is only sent if so // Safe to assert resumeId type as string because query is only sent if so
const detailsQuery = trpc.useQuery( const detailsQuery = trpc.useQuery(
['resumes.resume.findOne', { resumeId: resumeId as string }], ['resumes.resume.findOne', { resumeId: resumeId as string }],
@ -36,33 +36,14 @@ export default function ResumeReviewPage() {
); );
const starMutation = trpc.useMutation('resumes.resume.star', { const starMutation = trpc.useMutation('resumes.resume.star', {
onSuccess() { onSuccess() {
setStarDetails({ utils.invalidateQueries(['resumes.resume.findOne']);
isStarred: true,
numStars: starDetails.numStars + 1,
});
}, },
}); });
const unstarMutation = trpc.useMutation('resumes.resume.unstar', { const unstarMutation = trpc.useMutation('resumes.resume.unstar', {
onSuccess() { onSuccess() {
setStarDetails({ utils.invalidateQueries(['resumes.resume.findOne']);
isStarred: false,
numStars: starDetails.numStars - 1,
});
}, },
}); });
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 = () => { const onStarButtonClick = () => {
if (session?.user?.id == null) { if (session?.user?.id == null) {
@ -72,7 +53,7 @@ export default function ResumeReviewPage() {
// Star button only rendered if resume exists // Star button only rendered if resume exists
// Star button only clickable if user exists // Star button only clickable if user exists
if (starDetails.isStarred) { if (detailsQuery.data?.stars.length) {
unstarMutation.mutate({ unstarMutation.mutate({
resumeId: resumeId as string, resumeId: resumeId as string,
}); });
@ -104,30 +85,37 @@ export default function ResumeReviewPage() {
</h1> </h1>
<button <button
className={clsx( className={clsx(
starDetails.isStarred detailsQuery.data?.stars.length
? 'z-10 border-indigo-500 outline-none ring-1 ring-indigo-500' ? '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', '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} disabled={
id="star-button" session?.user === undefined ||
starMutation.isLoading ||
unstarMutation.isLoading
}
type="button" type="button"
onClick={onStarButtonClick}> onClick={onStarButtonClick}>
<span className="relative inline-flex"> <span className="relative inline-flex">
<div className="-ml-1 mr-2 h-5 w-5">
{starMutation.isLoading || unstarMutation.isLoading ? (
<Spinner className="mt-0.5" size="xs" />
) : (
<StarIcon <StarIcon
aria-hidden="true" aria-hidden="true"
className={clsx( className={clsx(
starDetails.isStarred detailsQuery.data?.stars.length
? 'text-orange-400' ? 'text-orange-400'
: 'text-gray-400', : 'text-gray-400',
'-ml-1 mr-2 h-5 w-5',
)} )}
id="star-icon"
/> />
)}
</div>
Star Star
</span> </span>
<span className="relative -ml-px inline-flex"> <span className="relative -ml-px inline-flex">
{starDetails.numStars} {detailsQuery.data?._count.stars}
</span> </span>
</button> </button>
</div> </div>

View File

@ -1,22 +1,28 @@
import clsx from 'clsx'; import compareAsc from 'date-fns/compareAsc';
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { Fragment, useState } from 'react'; import { useState } from 'react';
import { Disclosure, Menu, Transition } from '@headlessui/react'; import { Disclosure } from '@headlessui/react';
import { import { MinusIcon, PlusIcon } from '@heroicons/react/20/solid';
ChevronDownIcon,
MinusIcon,
PlusIcon,
} from '@heroicons/react/20/solid';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; 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 { import {
BROWSE_TABS_VALUES, BROWSE_TABS_VALUES,
EXPERIENCE, EXPERIENCE,
LOCATION, LOCATION,
ROLES, ROLE,
SORT_OPTIONS, SORT_OPTIONS,
TOP_HITS, TOP_HITS,
} from '~/components/resumes/browse/resumeConstants'; } from '~/components/resumes/browse/resumeConstants';
@ -29,11 +35,19 @@ import { trpc } from '~/utils/trpc';
import type { Resume } from '~/types/resume'; 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', id: 'role',
name: 'Roles', name: 'Role',
options: ROLES, options: ROLE,
}, },
{ {
id: 'experience', 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() { export default function ResumeHomePage() {
const { data: sessionData } = useSession(); const { data: sessionData } = useSession();
const router = useRouter(); const router = useRouter();
const [tabsValue, setTabsValue] = useState(BROWSE_TABS_VALUES.ALL); const [tabsValue, setTabsValue] = useState(BROWSE_TABS_VALUES.ALL);
const [sortOrder, setSortOrder] = useState(SORT_OPTIONS[0].value);
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
const [userFilters, setUserFilters] = useState(INITIAL_FILTER_STATE);
const [resumes, setResumes] = useState<Array<Resume>>([]); const [resumes, setResumes] = useState<Array<Resume>>([]);
const [renderSignInButton, setRenderSignInButton] = useState(false); const [renderSignInButton, setRenderSignInButton] = useState(false);
const [signInButtonText, setSignInButtonText] = useState(''); 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 ( return (
<> <>
<Head> <Head>
@ -154,49 +224,17 @@ export default function ResumeHomePage() {
</form> </form>
</div> </div>
<div className="col-span-1 justify-self-center"> <div className="col-span-1 justify-self-center">
<Menu as="div" className="relative inline-block text-left"> <DropdownMenu align="end" label="Sort">
<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) => ( {SORT_OPTIONS.map((option) => (
<Menu.Item key={option.name}> <DropdownMenu.Item
{({ active }) => ( key={option.name}
<a isSelected={sortOrder === option.value}
className={clsx( label={option.name}
option.current onClick={() =>
? 'font-medium text-gray-900' setSortOrder(option.value)
: 'text-gray-500', }></DropdownMenu.Item>
active ? 'bg-gray-100' : '',
'block px-4 py-2 text-sm',
)}
href={option.href}>
{option.name}
</a>
)}
</Menu.Item>
))} ))}
</div> </DropdownMenu>
</Menu.Items>
</Transition>
</Menu>
</div> </div>
<div className="col-span-1"> <div className="col-span-1">
<button <button
@ -256,28 +294,32 @@ export default function ResumeHomePage() {
</span> </span>
</Disclosure.Button> </Disclosure.Button>
</h3> </h3>
<Disclosure.Panel className="pt-6"> <Disclosure.Panel className="pt-4">
<div className="space-y-4"> <CheckboxList
{section.options.map((option, optionIdx) => ( description=""
isLabelHidden={true}
label=""
orientation="vertical">
{section.options.map((option) => (
<div <div
key={option.value} key={option.value}
className="flex items-center"> 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">
<input <CheckboxInput
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" label={option.label}
defaultChecked={option.checked} value={userFilters[section.id].includes(
defaultValue={option.value} option.value,
id={`filter-${section.id}-${optionIdx}`} )}
name={`${section.id}[]`} onChange={(isChecked) =>
type="checkbox" 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>
))} ))}
</div> </CheckboxList>
</Disclosure.Panel> </Disclosure.Panel>
</> </>
)} )}
@ -296,7 +338,10 @@ export default function ResumeHomePage() {
starredResumesQuery.isFetching || starredResumesQuery.isFetching ||
myResumesQuery.isFetching myResumesQuery.isFetching
} }
resumes={resumes} resumes={sortResumes(
filterResumes(resumes, searchValue, userFilters),
sortOrder,
)}
/> />
</div> </div>
</div> </div>

View File

@ -11,7 +11,7 @@ import { Button, CheckboxInput, Select, TextArea, TextInput } from '@tih/ui';
import { import {
EXPERIENCE, EXPERIENCE,
LOCATION, LOCATION,
ROLES, ROLE,
} from '~/components/resumes/browse/resumeConstants'; } from '~/components/resumes/browse/resumeConstants';
import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys'; import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys';
@ -152,7 +152,7 @@ export default function SubmitResumeForm() {
{...register('role', { required: true })} {...register('role', { required: true })}
disabled={isLoading} disabled={isLoading}
label="Role" label="Role"
options={ROLES} options={ROLE}
required={true} required={true}
onChange={(val) => setValue('role', val)} onChange={(val) => setValue('role', val)}
/> />