diff --git a/apps/portal/src/components/resumes/browse/ResumeListItem.tsx b/apps/portal/src/components/resumes/browse/ResumeListItem.tsx index 53c33e78..c7f7677a 100644 --- a/apps/portal/src/components/resumes/browse/ResumeListItem.tsx +++ b/apps/portal/src/components/resumes/browse/ResumeListItem.tsx @@ -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'; diff --git a/apps/portal/src/components/resumes/browse/resumeConstants.ts b/apps/portal/src/components/resumes/browse/resumeConstants.ts index 6996538a..42d79249 100644 --- a/apps/portal/src/components/resumes/browse/resumeConstants.ts +++ b/apps/portal/src/components/resumes/browse/resumeConstants.ts @@ -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 = [ diff --git a/apps/portal/src/pages/resumes/[resumeId].tsx b/apps/portal/src/pages/resumes/[resumeId].tsx index c888f6f3..40f7b630 100644 --- a/apps/portal/src/pages/resumes/[resumeId].tsx +++ b/apps/portal/src/pages/resumes/[resumeId].tsx @@ -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> diff --git a/apps/portal/src/pages/resumes/index.tsx b/apps/portal/src/pages/resumes/index.tsx index a65f3ca6..d6631f4d 100644 --- a/apps/portal/src/pages/resumes/index.tsx +++ b/apps/portal/src/pages/resumes/index.tsx @@ -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> diff --git a/apps/portal/src/pages/resumes/submit.tsx b/apps/portal/src/pages/resumes/submit.tsx index b85852f0..79c1fc9b 100644 --- a/apps/portal/src/pages/resumes/submit.tsx +++ b/apps/portal/src/pages/resumes/submit.tsx @@ -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)} />