Create and use new usePagination hook for paginating loading (#769)

* Create and use new `usePagination` hook for paginating loading

* Fix index for new comment list code
This commit is contained in:
Marshall Polaris 2022-08-26 20:18:08 -07:00 committed by GitHub
parent 9698895c22
commit 902d9e140c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 227 additions and 73 deletions

View File

@ -40,6 +40,10 @@
"collectionGroup": "comments", "collectionGroup": "comments",
"queryScope": "COLLECTION_GROUP", "queryScope": "COLLECTION_GROUP",
"fields": [ "fields": [
{
"fieldPath": "commentType",
"order": "ASCENDING"
},
{ {
"fieldPath": "userId", "fieldPath": "userId",
"order": "ASCENDING" "order": "ASCENDING"

View File

@ -1,8 +1,7 @@
import { useEffect, useState } from 'react' import { ContractComment } from 'common/comment'
import { Comment, ContractComment } from 'common/comment'
import { groupConsecutive } from 'common/util/array' import { groupConsecutive } from 'common/util/array'
import { getUsersComments } from 'web/lib/firebase/comments' import { getUserCommentsQuery } from 'web/lib/firebase/comments'
import { usePagination } from 'web/hooks/use-pagination'
import { SiteLink } from './site-link' import { SiteLink } from './site-link'
import { Row } from './layout/row' import { Row } from './layout/row'
import { Avatar } from './avatar' import { Avatar } from './avatar'
@ -11,10 +10,14 @@ import { UserLink } from './user-page'
import { User } from 'common/user' import { User } from 'common/user'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Content } from './editor' import { Content } from './editor'
import { Pagination } from './pagination'
import { LoadingIndicator } from './loading-indicator' import { LoadingIndicator } from './loading-indicator'
import { PaginationNextPrev } from 'web/components/pagination'
const COMMENTS_PER_PAGE = 50 type ContractKey = {
contractId: string
contractSlug: string
contractQuestion: string
}
function contractPath(slug: string) { function contractPath(slug: string) {
// by convention this includes the contract creator username, but we don't // by convention this includes the contract creator username, but we don't
@ -24,67 +27,83 @@ function contractPath(slug: string) {
export function UserCommentsList(props: { user: User }) { export function UserCommentsList(props: { user: User }) {
const { user } = props const { user } = props
const [comments, setComments] = useState<ContractComment[] | undefined>()
const [page, setPage] = useState(0)
const start = page * COMMENTS_PER_PAGE
const end = start + COMMENTS_PER_PAGE
useEffect(() => { const page = usePagination({ q: getUserCommentsQuery(user.id), pageSize: 50 })
getUsersComments(user.id).then((cs) => { const { isStart, isEnd, getNext, getPrev, getItems, isLoading } = page
// we don't show comments in groups here atm, just comments on contracts
setComments(
cs.filter((c) => c.commentType == 'contract') as ContractComment[]
)
})
}, [user.id])
if (comments == null) { const pageComments = groupConsecutive(getItems(), (c) => {
return {
contractId: c.contractId,
contractQuestion: c.contractQuestion,
contractSlug: c.contractSlug,
}
})
if (isLoading) {
return <LoadingIndicator /> return <LoadingIndicator />
} }
const pageComments = groupConsecutive(comments.slice(start, end), (c) => { if (pageComments.length === 0) {
return { question: c.contractQuestion, slug: c.contractSlug } if (isStart && isEnd) {
}) return <p>This user hasn't made any comments yet.</p>
} else {
// this can happen if their comment count is a multiple of page size
return <p>No more comments to display.</p>
}
}
return ( return (
<Col className={'bg-white'}> <Col className={'bg-white'}>
{pageComments.map(({ key, items }, i) => { {pageComments.map(({ key, items }, i) => {
return ( return <ProfileCommentGroup key={i} groupKey={key} items={items} />
<div key={start + i} className="border-b p-5">
<SiteLink
className="mb-2 block pb-2 font-medium text-indigo-700"
href={contractPath(key.slug)}
>
{key.question}
</SiteLink>
<Col className="gap-6">
{items.map((comment) => (
<ProfileComment
key={comment.id}
comment={comment}
className="relative flex items-start space-x-3"
/>
))}
</Col>
</div>
)
})} })}
<Pagination <nav
page={page} className="border-t border-gray-200 px-4 py-3 sm:px-6"
itemsPerPage={COMMENTS_PER_PAGE} aria-label="Pagination"
totalItems={comments.length} >
setPage={setPage} <PaginationNextPrev
/> prev={!isStart ? 'Previous' : null}
next={!isEnd ? 'Next' : null}
onClickPrev={getPrev}
onClickNext={getNext}
scrollToTop={true}
/>
</nav>
</Col> </Col>
) )
} }
function ProfileComment(props: { comment: Comment; className?: string }) { function ProfileCommentGroup(props: {
const { comment, className } = props groupKey: ContractKey
items: ContractComment[]
}) {
const { groupKey, items } = props
const { contractSlug, contractQuestion } = groupKey
const path = contractPath(contractSlug)
return (
<div className="border-b p-5">
<SiteLink
className="mb-2 block pb-2 font-medium text-indigo-700"
href={path}
>
{contractQuestion}
</SiteLink>
<Col className="gap-6">
{items.map((c) => (
<ProfileComment key={c.id} comment={c} />
))}
</Col>
</div>
)
}
function ProfileComment(props: { comment: ContractComment }) {
const { comment } = props
const { text, content, userUsername, userName, userAvatarUrl, createdTime } = const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
comment comment
// TODO: find and attach relevant bets by comment betId at some point // TODO: find and attach relevant bets by comment betId at some point
return ( return (
<Row className={className}> <Row className="relative flex items-start space-x-3">
<Avatar username={userUsername} avatarUrl={userAvatarUrl} /> <Avatar username={userUsername} avatarUrl={userAvatarUrl} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="mt-0.5 text-sm text-gray-500"> <p className="mt-0.5 text-sm text-gray-500">

View File

@ -1,6 +1,40 @@
import { ReactNode } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
import { Row } from './layout/row'
export function PaginationNextPrev(props: {
className?: string
prev?: ReactNode
next?: ReactNode
onClickPrev: () => void
onClickNext: () => void
scrollToTop?: boolean
}) {
const { className, prev, next, onClickPrev, onClickNext, scrollToTop } = props
return (
<Row className={clsx(className, 'flex-1 justify-between sm:justify-end')}>
{prev != null && (
<a
href={scrollToTop ? '#' : undefined}
className="relative inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
onClick={onClickPrev}
>
{prev ?? 'Previous'}
</a>
)}
{next != null && (
<a
href={scrollToTop ? '#' : undefined}
className="relative ml-3 inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
onClick={onClickNext}
>
{next ?? 'Next'}
</a>
)}
</Row>
)
}
export function Pagination(props: { export function Pagination(props: {
page: number page: number
itemsPerPage: number itemsPerPage: number
@ -44,24 +78,13 @@ export function Pagination(props: {
of <span className="font-medium">{totalItems}</span> results of <span className="font-medium">{totalItems}</span> results
</p> </p>
</div> </div>
<div className="flex flex-1 justify-between sm:justify-end"> <PaginationNextPrev
{page > 0 && ( prev={page > 0 ? prevTitle ?? 'Previous' : null}
<a next={page < maxPage ? nextTitle ?? 'Next' : null}
href={scrollToTop ? '#' : undefined} onClickPrev={() => setPage(page - 1)}
className="relative inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" onClickNext={() => setPage(page + 1)}
onClick={() => page > 0 && setPage(page - 1)} scrollToTop={scrollToTop}
> />
{prevTitle ?? 'Previous'}
</a>
)}
<a
href={scrollToTop ? '#' : undefined}
className="relative ml-3 inline-flex cursor-pointer select-none items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
onClick={() => page < maxPage && setPage(page + 1)}
>
{nextTitle ?? 'Next'}
</a>
</div>
</nav> </nav>
) )
} }

109
web/hooks/use-pagination.ts Normal file
View File

@ -0,0 +1,109 @@
// adapted from https://github.com/premshree/use-pagination-firestore
import { useEffect, useReducer } from 'react'
import {
Query,
QuerySnapshot,
QueryDocumentSnapshot,
queryEqual,
limit,
onSnapshot,
query,
startAfter,
} from 'firebase/firestore'
interface State<T> {
baseQ: Query<T>
docs: QueryDocumentSnapshot<T>[]
pageStart: number
pageEnd: number
pageSize: number
isLoading: boolean
isComplete: boolean
}
type ActionBase<K, V = void> = V extends void ? { type: K } : { type: K } & V
type Action<T> =
| ActionBase<'INIT', { opts: PaginationOptions<T> }>
| ActionBase<'LOAD', { snapshot: QuerySnapshot<T> }>
| ActionBase<'PREV'>
| ActionBase<'NEXT'>
const getReducer =
<T>() =>
(state: State<T>, action: Action<T>): State<T> => {
switch (action.type) {
case 'INIT': {
return getInitialState(action.opts)
}
case 'LOAD': {
const docs = state.docs.concat(action.snapshot.docs)
const isComplete = action.snapshot.docs.length < state.pageSize
return { ...state, docs, isComplete, isLoading: false }
}
case 'PREV': {
const { pageStart, pageSize } = state
const prevStart = pageStart - pageSize
const isLoading = false
return { ...state, isLoading, pageStart: prevStart, pageEnd: pageStart }
}
case 'NEXT': {
const { docs, pageEnd, isComplete, pageSize } = state
const nextEnd = pageEnd + pageSize
const isLoading = !isComplete && docs.length < nextEnd
return { ...state, isLoading, pageStart: pageEnd, pageEnd: nextEnd }
}
default:
throw new Error('Invalid action.')
}
}
export type PaginationOptions<T> = { q: Query<T>; pageSize: number }
const getInitialState = <T>(opts: PaginationOptions<T>): State<T> => {
return {
baseQ: opts.q,
docs: [],
pageStart: 0,
pageEnd: opts.pageSize,
pageSize: opts.pageSize,
isLoading: true,
isComplete: false,
}
}
export const usePagination = <T>(opts: PaginationOptions<T>) => {
const [state, dispatch] = useReducer(getReducer<T>(), opts, getInitialState)
useEffect(() => {
// save callers the effort of ref-izing their opts by checking for
// deep equality over here
if (queryEqual(opts.q, state.baseQ) && opts.pageSize === state.pageSize) {
return
}
dispatch({ type: 'INIT', opts })
}, [opts, state.baseQ, state.pageSize])
useEffect(() => {
if (state.isLoading) {
const lastDoc = state.docs[state.docs.length - 1]
const nextQ = lastDoc
? query(state.baseQ, startAfter(lastDoc), limit(state.pageSize))
: query(state.baseQ, limit(state.pageSize))
return onSnapshot(nextQ, (snapshot) => {
dispatch({ type: 'LOAD', snapshot })
})
}
}, [state.isLoading, state.baseQ, state.docs, state.pageSize])
return {
isLoading: state.isLoading,
isStart: state.pageStart === 0,
isEnd: state.isComplete && state.pageEnd >= state.docs.length,
getPrev: () => dispatch({ type: 'PREV' }),
getNext: () => dispatch({ type: 'NEXT' }),
getItems: () =>
state.docs.slice(state.pageStart, state.pageEnd).map((d) => d.data()),
}
}

View File

@ -1,4 +1,5 @@
import { import {
Query,
collection, collection,
collectionGroup, collectionGroup,
doc, doc,
@ -148,12 +149,10 @@ export function listenForRecentComments(
return listenForValues<Comment>(recentCommentsQuery, setComments) return listenForValues<Comment>(recentCommentsQuery, setComments)
} }
const getUsersCommentsQuery = (userId: string) => export const getUserCommentsQuery = (userId: string) =>
query( query(
collectionGroup(db, 'comments'), collectionGroup(db, 'comments'),
where('userId', '==', userId), where('userId', '==', userId),
where('commentType', '==', 'contract'),
orderBy('createdTime', 'desc') orderBy('createdTime', 'desc')
) ) as Query<ContractComment>
export async function getUsersComments(userId: string) {
return await getValues<Comment>(getUsersCommentsQuery(userId))
}