[resumes][feat] Add pagination on browse page (#388)

* [resumes][feat] Add pagination on browse page

* [resume][fix] Remove unused type
pull/396/head
Su Yin 2 years ago committed by GitHub
parent d8213639d3
commit a53c10483e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,12 +1,22 @@
import clsx from 'clsx';
type Props = Readonly<{ type Props = Readonly<{
isSelected: boolean;
onClick?: (event: React.MouseEvent<HTMLElement>) => void; onClick?: (event: React.MouseEvent<HTMLElement>) => void;
title: string; title: string;
}>; }>;
export default function ResumeFilterPill({ title, onClick }: Props) { export default function ResumeFilterPill({
title,
onClick,
isSelected,
}: Props) {
return ( return (
<button <button
className="rounded-xl border border-indigo-500 border-transparent bg-white px-2 py-1 text-xs font-medium text-indigo-500 focus:bg-indigo-500 focus:text-white" className={clsx(
'rounded-xl border border-indigo-500 border-transparent px-2 py-1 text-xs font-medium focus:bg-indigo-500 focus:text-white',
isSelected ? 'bg-indigo-500 text-white' : 'bg-white text-indigo-500',
)}
type="button" type="button"
onClick={onClick}> onClick={onClick}>
{title} {title}

@ -41,11 +41,6 @@ export type FilterState = Partial<CustomFilter> &
export type SortOrder = 'latest' | 'popular' | 'topComments'; export type SortOrder = 'latest' | 'popular' | 'topComments';
type SortOption = {
name: string;
value: SortOrder;
};
export type Shortcut = { export type Shortcut = {
customFilters?: CustomFilter; customFilters?: CustomFilter;
filters: FilterState; filters: FilterState;
@ -59,11 +54,11 @@ export const BROWSE_TABS_VALUES = {
STARRED: 'starred', STARRED: 'starred',
}; };
export const SORT_OPTIONS: Array<SortOption> = [ export const SORT_OPTIONS: Record<string, string> = {
{ name: 'Latest', value: 'latest' }, latest: 'Latest',
{ name: 'Popular', value: 'popular' }, popular: 'Popular',
{ name: 'Top Comments', value: 'topComments' }, topComments: 'Top Comments',
]; };
export const ROLE: Array<FilterOption<RoleFilter>> = [ export const ROLE: Array<FilterOption<RoleFilter>> = [
{ {

@ -1,8 +1,7 @@
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 { useState } from 'react'; import { useEffect, useState } from 'react';
import { Disclosure } from '@headlessui/react'; import { Disclosure } from '@headlessui/react';
import { MinusIcon, PlusIcon } from '@heroicons/react/20/solid'; import { MinusIcon, PlusIcon } from '@heroicons/react/20/solid';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
@ -10,6 +9,7 @@ import {
CheckboxInput, CheckboxInput,
CheckboxList, CheckboxList,
DropdownMenu, DropdownMenu,
Pagination,
Tabs, Tabs,
TextInput, TextInput,
} from '@tih/ui'; } from '@tih/ui';
@ -18,10 +18,7 @@ import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill';
import type { import type {
Filter, Filter,
FilterId, FilterId,
FilterState,
FilterValue,
Shortcut, Shortcut,
SortOrder,
} from '~/components/resumes/browse/resumeFilters'; } from '~/components/resumes/browse/resumeFilters';
import { import {
BROWSE_TABS_VALUES, BROWSE_TABS_VALUES,
@ -58,59 +55,64 @@ const filters: Array<Filter> = [
}, },
]; ];
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 as FilterValue) &&
userFilters.experience.includes(experience as FilterValue) &&
userFilters.location.includes(location as FilterValue),
)
.filter(
({ numComments }) =>
userFilters.numComments === undefined ||
numComments === userFilters.numComments,
);
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 [sortOrder, setSortOrder] = useState('latest');
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
const [userFilters, setUserFilters] = useState(INITIAL_FILTER_STATE); const [userFilters, setUserFilters] = useState(INITIAL_FILTER_STATE);
const [shortcutSelected, setShortcutSelected] = useState('All');
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('');
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const PAGE_LIMIT = 10;
const skip = (currentPage - 1) * PAGE_LIMIT;
const allResumesQuery = trpc.useQuery(['resumes.resume.findAll'], { useEffect(() => {
enabled: tabsValue === BROWSE_TABS_VALUES.ALL, setCurrentPage(1);
onSuccess: (data) => { }, [userFilters, sortOrder]);
setResumes(data);
setRenderSignInButton(false); const allResumesQuery = trpc.useQuery(
[
'resumes.resume.findAll',
{
experienceFilters: userFilters.experience,
locationFilters: userFilters.location,
numComments: userFilters.numComments,
roleFilters: userFilters.role,
skip,
sortOrder,
},
],
{
enabled: tabsValue === BROWSE_TABS_VALUES.ALL,
onSuccess: (data) => {
setTotalPages(
data.totalRecords % PAGE_LIMIT === 0
? data.totalRecords / PAGE_LIMIT
: Math.floor(data.totalRecords / PAGE_LIMIT) + 1,
);
setResumes(data.mappedResumeData);
setRenderSignInButton(false);
},
}, },
}); );
const starredResumesQuery = trpc.useQuery( const starredResumesQuery = trpc.useQuery(
['resumes.resume.user.findUserStarred'], [
'resumes.resume.user.findUserStarred',
{
experienceFilters: userFilters.experience,
locationFilters: userFilters.location,
numComments: userFilters.numComments,
roleFilters: userFilters.role,
skip,
sortOrder,
},
],
{ {
enabled: tabsValue === BROWSE_TABS_VALUES.STARRED, enabled: tabsValue === BROWSE_TABS_VALUES.STARRED,
onError: () => { onError: () => {
@ -119,13 +121,28 @@ export default function ResumeHomePage() {
setSignInButtonText('to view starred resumes'); setSignInButtonText('to view starred resumes');
}, },
onSuccess: (data) => { onSuccess: (data) => {
setResumes(data); setTotalPages(
data.totalRecords % PAGE_LIMIT === 0
? data.totalRecords / PAGE_LIMIT
: Math.floor(data.totalRecords / PAGE_LIMIT) + 1,
);
setResumes(data.mappedResumeData);
}, },
retry: false, retry: false,
}, },
); );
const myResumesQuery = trpc.useQuery( const myResumesQuery = trpc.useQuery(
['resumes.resume.user.findUserCreated'], [
'resumes.resume.user.findUserCreated',
{
experienceFilters: userFilters.experience,
locationFilters: userFilters.location,
numComments: userFilters.numComments,
roleFilters: userFilters.role,
skip,
sortOrder,
},
],
{ {
enabled: tabsValue === BROWSE_TABS_VALUES.MY, enabled: tabsValue === BROWSE_TABS_VALUES.MY,
onError: () => { onError: () => {
@ -134,7 +151,12 @@ export default function ResumeHomePage() {
setSignInButtonText('to view your submitted resumes'); setSignInButtonText('to view your submitted resumes');
}, },
onSuccess: (data) => { onSuccess: (data) => {
setResumes(data); setTotalPages(
data.totalRecords % PAGE_LIMIT === 0
? data.totalRecords / PAGE_LIMIT
: Math.floor(data.totalRecords / PAGE_LIMIT) + 1,
);
setResumes(data.mappedResumeData);
}, },
retry: false, retry: false,
}, },
@ -171,11 +193,18 @@ export default function ResumeHomePage() {
const onShortcutChange = ({ const onShortcutChange = ({
sortOrder: shortcutSortOrder, sortOrder: shortcutSortOrder,
filters: shortcutFilters, filters: shortcutFilters,
name: shortcutName,
}: Shortcut) => { }: Shortcut) => {
setShortcutSelected(shortcutName);
setSortOrder(shortcutSortOrder); setSortOrder(shortcutSortOrder);
setUserFilters(shortcutFilters); setUserFilters(shortcutFilters);
}; };
const onTabChange = (tab: string) => {
setTabsValue(tab);
setCurrentPage(1);
};
return ( return (
<> <>
<Head> <Head>
@ -195,7 +224,7 @@ export default function ResumeHomePage() {
</div> </div>
<div className="col-span-10"> <div className="col-span-10">
<div className="border-grey-200 grid grid-cols-12 items-center gap-4 border-b pb-2"> <div className="border-grey-200 grid grid-cols-12 items-center gap-4 border-b pb-2">
<div className="col-span-7"> <div className="col-span-5">
<Tabs <Tabs
label="Resume Browse Tabs" label="Resume Browse Tabs"
tabs={[ tabs={[
@ -213,42 +242,44 @@ export default function ResumeHomePage() {
}, },
]} ]}
value={tabsValue} value={tabsValue}
onChange={setTabsValue} onChange={onTabChange}
/> />
</div> </div>
<div className="col-span-3 self-end"> <div className="col-span-7 flex items-center justify-evenly">
<form> <div className="w-64">
<TextInput <form>
label="" <TextInput
placeholder="Search Resumes" label=""
startAddOn={MagnifyingGlassIcon} placeholder="Search Resumes"
startAddOnType="icon" startAddOn={MagnifyingGlassIcon}
type="text" startAddOnType="icon"
value={searchValue} type="text"
onChange={setSearchValue} value={searchValue}
/> onChange={setSearchValue}
</form> />
</div> </form>
<div className="col-span-1 justify-self-center"> </div>
<DropdownMenu align="end" label="Sort"> <div>
{SORT_OPTIONS.map((option) => ( <DropdownMenu align="end" label={SORT_OPTIONS[sortOrder]}>
<DropdownMenu.Item {Object.entries(SORT_OPTIONS).map(([key, value]) => (
key={option.name} <DropdownMenu.Item
isSelected={sortOrder === option.value} key={key}
label={option.name} isSelected={sortOrder === key}
onClick={() => label={value}
setSortOrder(option.value) onClick={() =>
}></DropdownMenu.Item> setSortOrder(key)
))} }></DropdownMenu.Item>
</DropdownMenu> ))}
</div> </DropdownMenu>
<div className="col-span-1"> </div>
<button <div>
className="rounded-md bg-indigo-500 py-1 px-3 text-sm font-medium text-white" <button
type="button" className="rounded-md bg-indigo-500 py-1 px-3 text-sm font-medium text-white"
onClick={onSubmitResume}> type="button"
Submit Resume onClick={onSubmitResume}>
</button> Submit Resume
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -265,6 +296,7 @@ export default function ResumeHomePage() {
{SHORTCUTS.map((shortcut) => ( {SHORTCUTS.map((shortcut) => (
<li key={shortcut.name}> <li key={shortcut.name}>
<ResumeFilterPill <ResumeFilterPill
isSelected={shortcutSelected === shortcut.name}
title={shortcut.name} title={shortcut.name}
onClick={() => onShortcutChange(shortcut)} onClick={() => onShortcutChange(shortcut)}
/> />
@ -339,17 +371,26 @@ export default function ResumeHomePage() {
{renderSignInButton && ( {renderSignInButton && (
<ResumeSignInButton text={signInButtonText} /> <ResumeSignInButton text={signInButtonText} />
)} )}
{totalPages === 0 && (
<div className="mt-4">Nothing to see here.</div>
)}
<ResumeListItems <ResumeListItems
isLoading={ isLoading={
allResumesQuery.isFetching || allResumesQuery.isFetching ||
starredResumesQuery.isFetching || starredResumesQuery.isFetching ||
myResumesQuery.isFetching myResumesQuery.isFetching
} }
resumes={sortResumes( resumes={resumes}
filterResumes(resumes, searchValue, userFilters),
sortOrder,
)}
/> />
<div className="my-4 flex justify-center">
<Pagination
current={currentPage}
end={totalPages}
label="pagination"
start={1}
onSelect={(page) => setCurrentPage(page)}
/>
</div>
</div> </div>
</div> </div>
</div> </div>

@ -6,8 +6,36 @@ import type { Resume } from '~/types/resume';
export const resumesRouter = createRouter() export const resumesRouter = createRouter()
.query('findAll', { .query('findAll', {
async resolve({ ctx }) { input: z.object({
experienceFilters: z.string().array(),
locationFilters: z.string().array(),
numComments: z.number().optional(),
roleFilters: z.string().array(),
skip: z.number(),
sortOrder: z.string(),
}),
async resolve({ ctx, input }) {
const {
roleFilters,
locationFilters,
experienceFilters,
sortOrder,
numComments,
skip,
} = input;
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
const totalRecords = await ctx.prisma.resumesResume.count({
where: {
...(numComments === 0 && {
comments: {
none: {},
},
}),
experience: { in: experienceFilters },
location: { in: locationFilters },
role: { in: roleFilters },
},
});
const resumesData = await ctx.prisma.resumesResume.findMany({ const resumesData = await ctx.prisma.resumesResume.findMany({
include: { include: {
_count: { _count: {
@ -16,6 +44,7 @@ export const resumesRouter = createRouter()
stars: true, stars: true,
}, },
}, },
comments: true,
stars: { stars: {
where: { where: {
OR: { OR: {
@ -29,11 +58,32 @@ export const resumesRouter = createRouter()
}, },
}, },
}, },
orderBy: { orderBy:
createdAt: 'desc', sortOrder === 'latest'
? {
createdAt: 'desc',
}
: sortOrder === 'popular'
? {
stars: {
_count: 'desc',
},
}
: { comments: { _count: 'desc' } },
skip,
take: 10,
where: {
...(numComments === 0 && {
comments: {
none: {},
},
}),
experience: { in: experienceFilters },
location: { in: locationFilters },
role: { in: roleFilters },
}, },
}); });
return resumesData.map((r) => { const mappedResumeData = resumesData.map((r) => {
const resume: Resume = { const resume: Resume = {
additionalInfo: r.additionalInfo, additionalInfo: r.additionalInfo,
createdAt: r.createdAt, createdAt: r.createdAt,
@ -50,6 +100,7 @@ export const resumesRouter = createRouter()
}; };
return resume; return resume;
}); });
return { mappedResumeData, totalRecords };
}, },
}) })
.query('findOne', { .query('findOne', {

@ -45,8 +45,39 @@ export const resumesResumeUserRouter = createProtectedRouter()
}, },
}) })
.query('findUserStarred', { .query('findUserStarred', {
async resolve({ ctx }) { input: z.object({
experienceFilters: z.string().array(),
locationFilters: z.string().array(),
numComments: z.number().optional(),
roleFilters: z.string().array(),
skip: z.number(),
sortOrder: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
const {
roleFilters,
locationFilters,
experienceFilters,
sortOrder,
numComments,
skip,
} = input;
const totalRecords = await ctx.prisma.resumesStar.count({
where: {
resume: {
...(numComments === 0 && {
comments: {
none: {},
},
}),
experience: { in: experienceFilters },
location: { in: locationFilters },
role: { in: roleFilters },
},
userId,
},
});
const resumeStarsData = await ctx.prisma.resumesStar.findMany({ const resumeStarsData = await ctx.prisma.resumesStar.findMany({
include: { include: {
resume: { resume: {
@ -65,14 +96,46 @@ export const resumesResumeUserRouter = createProtectedRouter()
}, },
}, },
}, },
orderBy: { orderBy:
createdAt: 'desc', sortOrder === 'latest'
}, ? {
resume: {
createdAt: 'desc',
},
}
: sortOrder === 'popular'
? {
resume: {
stars: {
_count: 'desc',
},
},
}
: {
resume: {
comments: {
_count: 'desc',
},
},
},
skip,
take: 10,
where: { where: {
resume: {
...(numComments === 0 && {
comments: {
none: {},
},
}),
experience: { in: experienceFilters },
location: { in: locationFilters },
role: { in: roleFilters },
},
userId, userId,
}, },
}); });
return resumeStarsData.map((rs) => {
const mappedResumeData = resumeStarsData.map((rs) => {
const resume: Resume = { const resume: Resume = {
additionalInfo: rs.resume.additionalInfo, additionalInfo: rs.resume.additionalInfo,
createdAt: rs.resume.createdAt, createdAt: rs.resume.createdAt,
@ -89,11 +152,41 @@ export const resumesResumeUserRouter = createProtectedRouter()
}; };
return resume; return resume;
}); });
return { mappedResumeData, totalRecords };
}, },
}) })
.query('findUserCreated', { .query('findUserCreated', {
async resolve({ ctx }) { input: z.object({
experienceFilters: z.string().array(),
locationFilters: z.string().array(),
numComments: z.number().optional(),
roleFilters: z.string().array(),
skip: z.number(),
sortOrder: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
const {
roleFilters,
locationFilters,
experienceFilters,
sortOrder,
numComments,
skip,
} = input;
const totalRecords = await ctx.prisma.resumesResume.count({
where: {
...(numComments === 0 && {
comments: {
none: {},
},
}),
experience: { in: experienceFilters },
location: { in: locationFilters },
role: { in: roleFilters },
userId,
},
});
const resumesData = await ctx.prisma.resumesResume.findMany({ const resumesData = await ctx.prisma.resumesResume.findMany({
include: { include: {
_count: { _count: {
@ -113,14 +206,33 @@ export const resumesResumeUserRouter = createProtectedRouter()
}, },
}, },
}, },
orderBy: { orderBy:
createdAt: 'desc', sortOrder === 'latest'
}, ? {
createdAt: 'desc',
}
: sortOrder === 'popular'
? {
stars: {
_count: 'desc',
},
}
: { comments: { _count: 'desc' } },
skip,
take: 10,
where: { where: {
...(numComments === 0 && {
comments: {
none: {},
},
}),
experience: { in: experienceFilters },
location: { in: locationFilters },
role: { in: roleFilters },
userId, userId,
}, },
}); });
return resumesData.map((r) => { const mappedResumeData = resumesData.map((r) => {
const resume: Resume = { const resume: Resume = {
additionalInfo: r.additionalInfo, additionalInfo: r.additionalInfo,
createdAt: r.createdAt, createdAt: r.createdAt,
@ -137,5 +249,6 @@ export const resumesResumeUserRouter = createProtectedRouter()
}; };
return resume; return resume;
}); });
return { mappedResumeData, totalRecords };
}, },
}); });

Loading…
Cancel
Save