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 = [ + { 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 = [ { - 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 = [ + { 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 = [ + { 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() { 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; +}; +type FilterState = Record>; + +const filters: Array = [ { - 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, + 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, 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>([]); 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 ( <> @@ -154,49 +224,17 @@ export default function ResumeHomePage() {
- -
- {/* TODO: Sort logic */} - - Sort - -
- - - -
- {SORT_OPTIONS.map((option) => ( - - {({ active }) => ( - - {option.name} - - )} - - ))} -
-
-
-
+ + {SORT_OPTIONS.map((option) => ( + + setSortOrder(option.value) + }> + ))} +
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)} />