feat(hooks): add usePagination

This commit is contained in:
Divlo 2021-11-19 17:06:18 +01:00
parent ad945d7a7a
commit dc3d658d85
No known key found for this signature in database
GPG Key ID: 6F24DA54DA3967CF
9 changed files with 178 additions and 103 deletions

View File

@ -13,6 +13,7 @@ export const Guild: React.FC<GuildProps> = (props) => {
return ( return (
<IconLink <IconLink
className='mt-2'
key={guild.id} key={guild.id}
href={`/application/${guild.id}/${guild.defaultChannelId}`} href={`/application/${guild.id}/${guild.defaultChannelId}`}
selected={selected} selected={selected}

View File

@ -1,48 +1,29 @@
import { useCallback, useEffect, useState, useRef } from 'react' import { useEffect } from 'react'
import InfiniteScroll from 'react-infinite-scroll-component' import InfiniteScroll from 'react-infinite-scroll-component'
import { useAuthentication } from 'utils/authentication' import { useAuthentication } from 'utils/authentication'
import { GuildWithDefaultChannelId } from 'models/Guild' import { GuildWithDefaultChannelId } from 'models/Guild'
import { Loader } from 'components/design/Loader' import { Loader } from 'components/design/Loader'
import { useFetchState } from 'hooks/useFetchState'
import { ApplicationProps } from '../Application' import { ApplicationProps } from '../Application'
import { Guild } from './Guild' import { Guild } from './Guild'
import { usePagination } from 'hooks/usePagination'
export interface GuildsProps extends ApplicationProps {} export interface GuildsProps extends ApplicationProps {}
export const Guilds: React.FC<GuildsProps> = (props) => { export const Guilds: React.FC<GuildsProps> = (props) => {
const { path } = props const { path } = props
const [guilds, setGuilds] = useState<GuildWithDefaultChannelId[]>([])
const [hasMore, setHasMore] = useState(true)
const [fetchState, setFetchState] = useFetchState('idle')
const afterId = useRef<number | null>(null)
const { authentication } = useAuthentication() const { authentication } = useAuthentication()
const fetchGuilds = useCallback(async (): Promise<void> => { const { items, hasMore, nextPage } = usePagination<GuildWithDefaultChannelId>(
if (fetchState !== 'idle') { {
return api: authentication.api,
url: '/guilds'
} }
setFetchState('loading') )
const { data } = await authentication.api.get<GuildWithDefaultChannelId[]>(
`/guilds?limit=20${
afterId.current != null ? `&after=${afterId.current}` : ''
}`
)
afterId.current = data.length > 0 ? data[data.length - 1].id : null
setGuilds((oldGuilds) => {
return [...oldGuilds, ...data]
})
setHasMore(data.length > 0)
setFetchState('idle')
}, [authentication, fetchState, setFetchState])
useEffect(() => { useEffect(() => {
fetchGuilds().catch((error) => { nextPage()
console.error(error) }, [nextPage])
})
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return ( return (
<div <div
@ -51,13 +32,13 @@ export const Guilds: React.FC<GuildsProps> = (props) => {
> >
<InfiniteScroll <InfiniteScroll
className='guilds-list' className='guilds-list'
dataLength={guilds.length} dataLength={items.length}
next={fetchGuilds} next={nextPage}
hasMore={hasMore} hasMore={hasMore}
scrollableTarget='guilds-list-members' scrollableTarget='guilds-list-members'
loader={<Loader />} loader={<Loader />}
> >
{guilds.map((guild) => { {items.map((guild) => {
return ( return (
<Guild <Guild
guild={guild} guild={guild}

View File

@ -1,49 +1,29 @@
import { useCallback, useEffect, useState, useRef } from 'react' import { useEffect, useState } from 'react'
import InfiniteScroll from 'react-infinite-scroll-component' import InfiniteScroll from 'react-infinite-scroll-component'
import { useAuthentication } from 'utils/authentication' import { useAuthentication } from 'utils/authentication'
import { GuildPublic as GuildPublicType } from 'models/Guild' import { GuildPublic as GuildPublicType } from 'models/Guild'
import { Loader } from 'components/design/Loader' import { Loader } from 'components/design/Loader'
import { useFetchState } from 'hooks/useFetchState'
import { GuildPublic } from './GuildPublic' import { GuildPublic } from './GuildPublic'
import { usePagination } from 'hooks/usePagination'
export const JoinGuildsPublic: React.FC = () => { export const JoinGuildsPublic: React.FC = () => {
const [guilds, setGuilds] = useState<GuildPublicType[]>([]) const [search, setSearch] = useState('')
const [hasMore, setHasMore] = useState(true)
const [inputSearch, setInputSearch] = useState('')
const [fetchState, setFetchState] = useFetchState('idle')
const afterId = useRef<number | null>(null)
const { authentication } = useAuthentication() const { authentication } = useAuthentication()
const fetchGuilds = useCallback(async (): Promise<void> => { const { items, hasMore, nextPage, resetPagination } =
if (fetchState !== 'idle') { usePagination<GuildPublicType>({
return api: authentication.api,
} url: '/guilds/public'
setFetchState('loading')
const { data } = await authentication.api.get<GuildPublicType[]>(
`/guilds/public?limit=20&search=${inputSearch}${
afterId.current != null ? `&after=${afterId.current}` : ''
}`
)
afterId.current = data.length > 0 ? data[data.length - 1].id : null
setGuilds((oldGuilds) => {
return [...oldGuilds, ...data]
}) })
setHasMore(data.length > 0)
setFetchState('idle')
}, [authentication, fetchState, setFetchState, inputSearch])
useEffect(() => { useEffect(() => {
afterId.current = null resetPagination()
setGuilds([]) nextPage({ search })
fetchGuilds().catch((error) => { }, [resetPagination, nextPage, search])
console.error(error)
})
}, [inputSearch]) // eslint-disable-line react-hooks/exhaustive-deps
const handleChange = (event: React.ChangeEvent<HTMLInputElement>): void => { const handleChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
setInputSearch(event.target.value) setSearch(event.target.value)
} }
return ( return (
@ -59,13 +39,13 @@ export const JoinGuildsPublic: React.FC = () => {
<div className='w-full flex items-center justify-center p-12'> <div className='w-full flex items-center justify-center p-12'>
<InfiniteScroll <InfiniteScroll
className='guilds-public-list max-w-[1600px] grid grid-cols-1 xl:grid-cols-3 md:grid-cols-2 sm:grid-cols-1 gap-8 !overflow-hidden' className='guilds-public-list max-w-[1600px] grid grid-cols-1 xl:grid-cols-3 md:grid-cols-2 sm:grid-cols-1 gap-8 !overflow-hidden'
dataLength={guilds.length} dataLength={items.length}
next={fetchGuilds} next={nextPage}
scrollableTarget='application-page-content' scrollableTarget='application-page-content'
hasMore={hasMore} hasMore={hasMore}
loader={<Loader />} loader={<Loader />}
> >
{guilds.map((guild) => { {items.map((guild) => {
return <GuildPublic guild={guild} key={guild.id} /> return <GuildPublic guild={guild} key={guild.id} />
})} })}
</InfiniteScroll> </InfiniteScroll>

View File

@ -5,13 +5,14 @@ export interface IconLinkProps {
selected?: boolean selected?: boolean
href: string href: string
title?: string title?: string
className?: string
} }
export const IconLink: React.FC<IconLinkProps> = (props) => { export const IconLink: React.FC<IconLinkProps> = (props) => {
const { children, selected, href, title } = props const { children, selected, href, title, className } = props
return ( return (
<div className='w-full flex justify-center group'> <div className={classNames('w-full flex justify-center group', className)}>
<Link href={href}> <Link href={href}>
<a className='w-full flex justify-center relative group' title={title}> <a className='w-full flex justify-center relative group' title={title}>
{children} {children}

View File

@ -0,0 +1,36 @@
import { getGuildsHandler } from '../../../fixtures/guilds/get'
import { authenticationHandlers } from '../../../fixtures/handler'
const applicationPaths = [
'/application',
'/application/users/0',
'/application/guilds/create',
'/application/guilds/join',
'/application/0/0'
]
describe('Common > application/authentication', () => {
beforeEach(() => {
cy.task('stopMockServer')
})
it('should redirect the user to `/authentication/signin` if not signed in', () => {
for (const applicationPath of applicationPaths) {
cy.visit(applicationPath)
.location('pathname')
.should('eq', '/authentication/signin')
}
})
it('should not redirect the user if signed in', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildsHandler
]).setCookie('refreshToken', 'refresh-token')
for (const applicationPath of applicationPaths) {
cy.visit(applicationPath)
.location('pathname')
.should('eq', applicationPath)
}
})
})

View File

@ -0,0 +1,17 @@
import { getGuildsHandler } from '../../../fixtures/guilds/get'
import { authenticationHandlers } from '../../../fixtures/handler'
describe('Common > application/left-sidebar', () => {
beforeEach(() => {
cy.task('stopMockServer')
})
it('should shows all the guilds of the current user in left sidebar', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildsHandler
]).setCookie('refreshToken', 'refresh-token')
cy.visit('/application')
cy.get('.guilds-list').children().should('have.length', 2)
})
})

View File

@ -1,45 +1,31 @@
import { getGuildsHandler } from '../../../fixtures/guilds/get'
import { authenticationHandlers } from '../../../fixtures/handler' import { authenticationHandlers } from '../../../fixtures/handler'
const applicationPaths = [
'/application',
'/application/users/0',
'/application/guilds/create',
'/application/guilds/join',
'/application/0/0'
]
describe('Pages > /application', () => { describe('Pages > /application', () => {
beforeEach(() => { beforeEach(() => {
cy.task('stopMockServer') cy.task('stopMockServer')
}) })
it('should redirect the user to `/authentication/signin` if not signed in', () => { it('should redirect user to `/application/guilds/create` on click on "Create a Guild"', () => {
for (const applicationPath of applicationPaths) { cy.task('startMockServer', [...authenticationHandlers]).setCookie(
cy.visit(applicationPath) 'refreshToken',
.location('pathname') 'refresh-token'
.should('eq', '/authentication/signin') )
}
})
it('should not redirect the user if signed in', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildsHandler
]).setCookie('refreshToken', 'refresh-token')
for (const applicationPath of applicationPaths) {
cy.visit(applicationPath)
.location('pathname')
.should('eq', applicationPath)
}
})
it('should shows all the guilds of the current user in left sidebar', () => {
cy.task('startMockServer', [
...authenticationHandlers,
getGuildsHandler
]).setCookie('refreshToken', 'refresh-token')
cy.visit('/application') cy.visit('/application')
cy.get('.guilds-list').children().should('have.length', 2) cy.get('a[href="/application/guilds/create"]')
.click()
.location('pathname')
.should('eq', '/application/guilds/create')
})
it('should redirect user to `/application/guilds/join` on click on "Join a Guild"', () => {
cy.task('startMockServer', [...authenticationHandlers]).setCookie(
'refreshToken',
'refresh-token'
)
cy.visit('/application')
cy.get('a[href="/application/guilds/join"]')
.click()
.location('pathname')
.should('eq', '/application/guilds/join')
}) })
}) })

73
hooks/usePagination.ts Normal file
View File

@ -0,0 +1,73 @@
import { useState, useRef, useCallback } from 'react'
import { AxiosInstance } from 'axios'
import { FetchState } from './useFetchState'
export interface Query {
[key: string]: string
}
export type NextPageAsync = (query?: Query) => Promise<void>
export type NextPage = (query?: Query) => void
export interface UsePaginationOptions {
api: AxiosInstance
url: string
}
export interface UsePaginationResult<T> {
items: T[]
nextPage: NextPage
resetPagination: () => void
hasMore: boolean
}
export const usePagination = <T extends { id: number }>(
options: UsePaginationOptions
): UsePaginationResult<T> => {
const { api, url } = options
const [items, setItems] = useState<T[]>([])
const [hasMore, setHasMore] = useState(true)
const fetchState = useRef<FetchState>('idle')
const afterId = useRef<number | null>(null)
const nextPageAsync: NextPageAsync = useCallback(
async (query) => {
if (fetchState.current !== 'idle') {
return
}
fetchState.current = 'loading'
const searchParameters = new URLSearchParams(query)
searchParameters.append('limit', '20')
if (afterId.current != null) {
searchParameters.append('after', afterId.current.toString())
}
const { data: newItems } = await api.get<T[]>(
`${url}?${searchParameters.toString()}`
)
afterId.current =
newItems.length > 0 ? newItems[newItems.length - 1].id : null
setItems((oldItems) => {
return [...oldItems, ...newItems]
})
setHasMore(newItems.length > 0)
fetchState.current = 'idle'
},
[api, url]
)
const nextPage: NextPage = useCallback(
(query) => {
nextPageAsync(query).catch((error) => {
console.error(error)
})
},
[nextPageAsync]
)
const resetPagination = useCallback((): void => {
afterId.current = null
setItems([])
}, [])
return { items, hasMore, nextPage, resetPagination }
}

View File

@ -1,6 +1,6 @@
import { Head } from 'components/Head' import { Head } from 'components/Head'
import { Application } from 'components/Application' import { Application } from 'components/Application'
import { PopupGuild } from 'components/Application/PopupGuild/PopupGuild.stories' import { PopupGuild } from 'components/Application/PopupGuild'
import { import {
authenticationFromServerSide, authenticationFromServerSide,
AuthenticationProvider, AuthenticationProvider,