Skip to content

Commit

Permalink
Merge pull request #431 from systemli/add-message-pagination
Browse files Browse the repository at this point in the history
⚡️ Add Infinite Scroll to MessageList
  • Loading branch information
0x46616c6b authored Jan 27, 2023
2 parents e73f0be + bda578c commit eff71f0
Show file tree
Hide file tree
Showing 6 changed files with 201 additions and 25 deletions.
17 changes: 15 additions & 2 deletions src/api/Message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,22 @@ export function useMessageApi(token: string) {
}

const getMessages = (
ticker: number
ticker: number,
before?: number,
after?: number,
limit = 10
): Promise<Response<MessagesResponseData>> => {
return fetch(`${ApiUrl}/admin/tickers/${ticker}/messages`, {
let params = `limit=${limit}`

if (before) {
params += `&before=${before}`
}

if (after) {
params += `&after=${after}`
}

return fetch(`${ApiUrl}/admin/tickers/${ticker}/messages?${params}`, {
headers: headers,
}).then(response => response.json())
}
Expand Down
68 changes: 58 additions & 10 deletions src/components/message/MessageList.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import React, { FC } from 'react'
import { useQuery } from '@tanstack/react-query'
import React, { FC, useEffect } from 'react'
import { useInfiniteQuery } from '@tanstack/react-query'
import { Ticker } from '../../api/Ticker'
import { useMessageApi } from '../../api/Message'
import Message from './Message'
import useAuth from '../useAuth'
import ErrorView from '../../views/ErrorView'
import Loader from '../Loader'
import { Button, CircularProgress } from '@mui/material'

interface Props {
ticker: Ticker
Expand All @@ -14,15 +15,49 @@ interface Props {
const MessageList: FC<Props> = ({ ticker }) => {
const { token } = useAuth()
const { getMessages } = useMessageApi(token)
const { isLoading, error, data } = useQuery(['messages', ticker.id], () =>
getMessages(ticker.id)
)

if (isLoading) {
const fetchMessages = ({ pageParam = 0 }) => {
return getMessages(ticker.id, pageParam)
}

const { data, fetchNextPage, isFetchingNextPage, hasNextPage, status } =
useInfiniteQuery(['messages', ticker.id], fetchMessages, {
getNextPageParam: lastPage => {
return lastPage.data.messages.length === 10
? lastPage.data.messages.slice(-1).pop()?.id
: undefined
},
})

useEffect(() => {
let fetching = false

const handleScroll = async (e: Event) => {
const target = e.target as Document
if (target === null || target.scrollingElement === null) {
return
}

const { scrollHeight, scrollTop, clientHeight } = target.scrollingElement
if (!fetching && scrollHeight - scrollTop <= clientHeight * 1.2) {
fetching = true
if (hasNextPage) await fetchNextPage()
fetching = false
}
}

document.addEventListener('scroll', handleScroll)

return () => {
document.removeEventListener('scroll', handleScroll)
}
}, [fetchNextPage, hasNextPage])

if (status === 'loading') {
return <Loader />
}

if (error || data === undefined) {
if (status === 'error') {
return (
<ErrorView queryKey={['messages', ticker.id]}>
Unable to fetch messages from server.
Expand All @@ -32,9 +67,22 @@ const MessageList: FC<Props> = ({ ticker }) => {

return (
<>
{data.data.messages.map(message => (
<Message key={message.id} message={message} ticker={ticker} />
))}
{data.pages.map(group =>
group.data.messages.map(message => (
<Message key={message.id} message={message} ticker={ticker} />
))
)}
{isFetchingNextPage ? (
<CircularProgress size="3rem" />
) : hasNextPage ? (
<Button
disabled={!hasNextPage || isFetchingNextPage}
onClick={() => fetchNextPage()}
variant="outlined"
>
Load More
</Button>
) : null}
</>
)
}
Expand Down
18 changes: 12 additions & 6 deletions src/components/ticker/Ticker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ import {
import TickerModalForm from './TickerModalForm'
import TickerDangerZoneCard from './TickerDangerZoneCard'
import TickerUsersCard from './TickerUsersCard'
import useAuth from '../useAuth'

interface Props {
ticker: Model
}

const Ticker: FC<Props> = ({ ticker }) => {
const { user } = useAuth()
const [formModalOpen, setFormModalOpen] = useState<boolean>(false)

return (
Expand Down Expand Up @@ -63,12 +65,16 @@ const Ticker: FC<Props> = ({ ticker }) => {
xs={12}
>
<TickerCard ticker={ticker} />
<Box sx={{ mt: 2 }}>
<TickerUsersCard ticker={ticker} />
</Box>
<Box sx={{ mt: 2 }}>
<TickerDangerZoneCard ticker={ticker} />
</Box>
{user?.roles.includes('admin') ? (
<>
<Box sx={{ mt: 2 }}>
<TickerUsersCard ticker={ticker} />
</Box>
<Box sx={{ mt: 2 }}>
<TickerDangerZoneCard ticker={ticker} />
</Box>
</>
) : null}
</Grid>
<Grid container item md={8} rowSpacing={2} xs={12}>
<Grid item xs={12}>
Expand Down
6 changes: 2 additions & 4 deletions src/components/ticker/TickerDangerZoneCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,14 @@ import { Ticker } from '../../api/Ticker'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBiohazard, faTrash } from '@fortawesome/free-solid-svg-icons'
import TickerResetModal from './TickerResetModal'
import useAuth from '../useAuth'

interface Props {
ticker: Ticker
}
const TickerDangerZoneCard: FC<Props> = ({ ticker }) => {
const { user } = useAuth()
const [resetOpen, setResetOpen] = useState<boolean>(false)

return user?.roles.includes('admin') ? (
return (
<Card>
<CardContent>
<Typography component="h5" sx={{ mb: 2 }} variant="h5">
Expand All @@ -36,7 +34,7 @@ const TickerDangerZoneCard: FC<Props> = ({ ticker }) => {
</Box>
</CardContent>
</Card>
) : null
)
}

export default TickerDangerZoneCard
6 changes: 3 additions & 3 deletions src/components/ticker/TickerUsersCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface Props {
}

const TickerUsersCard: FC<Props> = ({ ticker }) => {
const { token, user } = useAuth()
const { token } = useAuth()
const { getTickerUsers } = useTickerApi(token)
const [formOpen, setFormOpen] = useState<boolean>(false)
const { isLoading, error, data } = useQuery(
Expand All @@ -39,7 +39,7 @@ const TickerUsersCard: FC<Props> = ({ ticker }) => {

const users = data.data.users

return user?.roles.includes('admin') ? (
return (
<Card>
<CardContent>
<Typography component="h5" sx={{ mb: 2 }} variant="h5">
Expand All @@ -65,7 +65,7 @@ const TickerUsersCard: FC<Props> = ({ ticker }) => {
/>
</CardContent>
</Card>
) : null
)
}

export default TickerUsersCard
111 changes: 111 additions & 0 deletions src/views/TickerView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import React from 'react'
import sign from 'jwt-encode'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router'
import { AuthProvider } from '../components/useAuth'
import TickerView from './TickerView'
import ProtectedRoute from '../components/ProtectedRoute'

describe('TickerView', function () {
const jwt = sign(
{
id: 1,
email: '[email protected]',
roles: ['user'],
exp: new Date().getTime() / 1000 + 600,
},
'secret'
)

const tickerResponse = JSON.stringify({
data: {
ticker: {
id: 1,
creation_time: new Date(),
domain: 'localhost',
title: 'Ticker Title',
description: 'Description',
active: true,
information: {},
mastodon: {},
twitter: {},
telegram: {},
location: {},
},
},
})
const messagesResponse = JSON.stringify({
data: {
messages: [
{
id: 1,
ticker: 1,
text: 'Message',
creation_time: new Date(),
geo_information: '{"type":"FeatureCollection","features":[]}',
attachments: [],
},
],
},
})

beforeEach(() => {
jest.spyOn(window.localStorage.__proto__, 'getItem').mockReturnValue(jwt)
fetchMock.resetMocks()
})

function setup() {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={['/ticker/1']}>
<AuthProvider>
<Routes>
<Route
element={<ProtectedRoute outlet={<TickerView />} role="user" />}
path="/ticker/:tickerId"
/>
</Routes>
</AuthProvider>
</MemoryRouter>
</QueryClientProvider>
)
}

test('renders a ticker with messages', async function () {
fetchMock.mockIf(/^http:\/\/localhost:8080\/.*$/, (request: Request) => {
if (request.url.endsWith('/admin/tickers/1')) {
return Promise.resolve(tickerResponse)
}
if (request.url.endsWith('/admin/tickers/1/messages?limit=10')) {
return Promise.resolve(messagesResponse)
}

return Promise.resolve(
JSON.stringify({
data: [],
status: 'error',
error: 'error message',
})
)
})

setup()

// Loader for the Ticker
expect(screen.getByText(/loading/i)).toBeInTheDocument()
expect(await screen.findByText('Ticker Title')).toBeInTheDocument()

// Loader for the Messages
expect(screen.getByText(/loading/i)).toBeInTheDocument()
screen.debug()
expect(await screen.findByText('Message')).toBeInTheDocument()
})
})

0 comments on commit eff71f0

Please sign in to comment.