fix(lib): infinite scroll in items index page (#438)

Co-authored-by: mahula <lenzmath@posteo.de>
Co-authored-by: Ulf Gebhardt <ulf.gebhardt@webcraft-media.de>
This commit is contained in:
Anton Tranelis 2026-02-06 20:37:28 +01:00 committed by GitHub
parent ab515c20ad
commit 298560c02e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 428 additions and 38 deletions

View File

@ -0,0 +1,171 @@
/* eslint-disable camelcase */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { render, screen, act, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { OverlayItemsIndexPage } from './OverlayItemsIndexPage'
import type { Item } from '#types/Item'
vi.mock('./ItemCard', () => ({
ItemCard: ({ i }: { i: Item }) => <div data-testid={`item-${i.id}`}>{i.name}</div>,
}))
vi.mock('./MapOverlayPage', () => ({
MapOverlayPage: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('#components/Map/Subcomponents/Controls/Control', () => ({
Control: () => null,
}))
vi.mock('#components/Map/Subcomponents/Controls/SearchControl', () => ({
SearchControl: () => null,
}))
vi.mock('#components/Map/Subcomponents/Controls/TagsControl', () => ({
TagsControl: () => null,
}))
vi.mock('#components/Profile/Subcomponents/PlusButton', () => ({
PlusButton: () => null,
}))
vi.mock('#components/Map/Subcomponents/ItemPopupComponents', () => ({
PopupStartEndInput: () => null,
}))
vi.mock('#components/Input', () => ({
TextInput: () => null,
}))
vi.mock('react-toastify', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}))
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom')
return { ...actual, useNavigate: () => vi.fn() }
})
vi.mock('#components/Auth/useAuth')
vi.mock('#components/Map/hooks/useFilter')
vi.mock('#components/Map/hooks/useItems')
vi.mock('#components/Map/hooks/useLayers')
vi.mock('#components/Map/hooks/useTags')
let observerCallback: IntersectionObserverCallback
const mockObserve = vi.fn()
const mockDisconnect = vi.fn()
class MockIntersectionObserver {
// eslint-disable-next-line promise/prefer-await-to-callbacks
constructor(cb: IntersectionObserverCallback) {
observerCallback = cb
}
observe = mockObserve
disconnect = mockDisconnect
unobserve = vi.fn()
}
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
function makeItems(count: number): Item[] {
return Array.from({ length: count }, (_, i) => ({
id: `item-${String(i)}`,
name: `Item ${String(i)}`,
date_created: new Date(2025, 0, count - i).toISOString(),
layer: { name: 'places' } as Item['layer'],
})) as Item[]
}
const mockLayer = {
name: 'places',
menuText: 'Places',
itemType: { show_start_end_input: false },
userProfileLayer: false,
api: { createItem: vi.fn(), deleteItem: vi.fn() },
}
function triggerIntersection() {
observerCallback(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
}
describe('OverlayItemsIndexPage infinite scroll', () => {
beforeEach(async () => {
vi.clearAllMocks()
const { useAuth } = await import('#components/Auth/useAuth')
const { useFilterTags } = await import('#components/Map/hooks/useFilter')
const { useItems, useAddItem, useRemoveItem } = await import('#components/Map/hooks/useItems')
const { useLayers } = await import('#components/Map/hooks/useLayers')
const { useTags, useAddTag, useGetItemTags } = await import('#components/Map/hooks/useTags')
vi.mocked(useAuth).mockReturnValue({ user: null } as any)
vi.mocked(useFilterTags).mockReturnValue([])
vi.mocked(useItems).mockReturnValue(makeItems(50))
vi.mocked(useAddItem).mockReturnValue(vi.fn())
vi.mocked(useRemoveItem).mockReturnValue(vi.fn())
vi.mocked(useLayers).mockReturnValue([mockLayer] as any)
vi.mocked(useTags).mockReturnValue([])
vi.mocked(useAddTag).mockReturnValue(vi.fn())
vi.mocked(useGetItemTags).mockReturnValue((() => []) as any)
})
function renderPage() {
return render(
<MemoryRouter>
<OverlayItemsIndexPage url='/places' layerName='places' />
</MemoryRouter>,
)
}
it('renders the first batch of 30 items with a sentinel', () => {
renderPage()
expect(screen.getAllByTestId(/^item-/)).toHaveLength(30)
expect(screen.getByTestId('scroll-sentinel')).toBeInTheDocument()
})
it('loads more items when the sentinel becomes visible', async () => {
renderPage()
expect(mockObserve).toHaveBeenCalled()
expect(screen.getAllByTestId(/^item-/)).toHaveLength(30)
act(() => {
triggerIntersection()
})
await waitFor(() => {
expect(screen.getAllByTestId(/^item-/)).toHaveLength(50)
})
})
it('removes the sentinel after all items are loaded', async () => {
renderPage()
expect(mockObserve).toHaveBeenCalled()
act(() => {
triggerIntersection()
})
await waitFor(() => {
expect(screen.queryByTestId('scroll-sentinel')).not.toBeInTheDocument()
})
})
it('only loads one batch when triggered twice before re-render', async () => {
const { useItems } = await import('#components/Map/hooks/useItems')
vi.mocked(useItems).mockReturnValue(makeItems(80))
renderPage()
expect(mockObserve).toHaveBeenCalled()
expect(screen.getAllByTestId(/^item-/)).toHaveLength(30)
act(() => {
triggerIntersection()
triggerIntersection()
})
await waitFor(() => {
expect(screen.getAllByTestId(/^item-/)).toHaveLength(54)
})
})
})

View File

@ -22,6 +22,7 @@ import { PlusButton } from '#components/Profile/Subcomponents/PlusButton'
import { hashTagRegex } from '#utils/HashTagRegex'
import { randomColor } from '#utils/RandomColor'
import { filterSortAndPaginate } from './filterSortAndPaginate'
import { ItemCard } from './ItemCard'
import { MapOverlayPage } from './MapOverlayPage'
@ -40,8 +41,11 @@ export const OverlayItemsIndexPage = ({
}) => {
const [loading, setLoading] = useState<boolean>(false)
const [addItemPopupOpen, setAddItemPopupOpen] = useState<boolean>(false)
const [itemsToShow, setItemsToShow] = useState<number>(30)
const tabRef = useRef<HTMLFormElement>(null)
const sentinelRef = useRef<HTMLDivElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const isLoadingMoreRef = useRef(false)
function scroll() {
tabRef.current?.scrollIntoView()
@ -66,6 +70,45 @@ export const OverlayItemsIndexPage = ({
const layer = layers.find((l) => l.name === layerName)
const { visibleItems, hasMore } = filterSortAndPaginate(
items,
layerName,
filterTags,
getItemTags,
itemsToShow,
)
useEffect(() => {
const sentinel = sentinelRef.current
const scrollContainer = scrollContainerRef.current
if (!sentinel || !scrollContainer) return
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0]
if (entry.isIntersecting && !isLoadingMoreRef.current && hasMore) {
isLoadingMoreRef.current = true
setItemsToShow((prev) => prev + 24)
}
},
{
root: scrollContainer,
rootMargin: '400px',
threshold: 0.1,
},
)
observer.observe(sentinel)
return () => {
observer.disconnect()
}
}, [hasMore, visibleItems.length])
useEffect(() => {
isLoadingMoreRef.current = false
}, [visibleItems.length])
const submitNewItem = async (evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault()
const formItem: Item = {} as Item
@ -130,44 +173,18 @@ export const OverlayItemsIndexPage = ({
<TagsControl />
</Control>
</div>
<div className='tw:overflow-scroll fade tw:flex-1'>
<div ref={scrollContainerRef} className='tw:overflow-scroll fade tw:flex-1'>
<div className='tw:columns-1 tw:md:columns-2 tw:lg:columns-3 tw:2xl:columns-4 tw:gap-6 tw:pt-4'>
{items
.filter((i) => i.layer?.name === layerName)
.filter((item) =>
filterTags.length === 0
? item
: filterTags.some((tag) =>
getItemTags(item).some(
(filterTag) =>
filterTag.name.toLocaleLowerCase() === tag.name.toLocaleLowerCase(),
),
),
)
.sort((a, b) => {
// Convert date_created to milliseconds, handle undefined by converting to lowest possible date (0 milliseconds)
const dateA = a.date_updated
? new Date(a.date_updated).getTime()
: a.date_created
? new Date(a.date_created).getTime()
: 0
const dateB = b.date_updated
? new Date(b.date_updated).getTime()
: b.date_created
? new Date(b.date_created).getTime()
: 0
return dateB - dateA // Subtracts milliseconds which are numbers
})
.map((i, k) => (
<div key={k} className='tw:break-inside-avoid tw:mb-6'>
<ItemCard
i={i}
loading={loading}
url={url}
deleteCallback={() => deleteItem(i)}
/>
</div>
))}
{visibleItems.map((i) => (
<div key={i.id} className='tw:break-inside-avoid tw:mb-6'>
<ItemCard
i={i}
loading={loading}
url={url}
deleteCallback={() => deleteItem(i)}
/>
</div>
))}
{addItemPopupOpen && (
<form ref={tabRef} autoComplete='off' onSubmit={(e) => submitNewItem(e)}>
<div className='tw:cursor-pointer tw:break-inside-avoid card tw:border-[1px] tw:border-base-300 card-body tw:shadow-xl tw:bg-base-100 tw:text-base-content tw:p-6 tw:mb-10'>
@ -208,6 +225,15 @@ export const OverlayItemsIndexPage = ({
</form>
)}
</div>
{hasMore && (
<div
ref={sentinelRef}
data-testid='scroll-sentinel'
className='tw:w-full tw:py-8 tw:flex tw:justify-center'
>
<span className='loading loading-spinner loading-lg'></span>
</div>
)}
</div>
</div>
</MapOverlayPage>

View File

@ -0,0 +1,145 @@
/* eslint-disable camelcase */ // Directus database fields use snake_case
import { describe, it, expect } from 'vitest'
import { filterSortAndPaginate } from './filterSortAndPaginate'
import type { Item } from '#types/Item'
import type { Tag } from '#types/Tag'
function makeItem(overrides: Partial<Item> & { id: string; layerName?: string }): Item {
const { layerName, ...rest } = overrides
return {
...rest,
layer: layerName ? ({ name: layerName } as Item['layer']) : undefined,
} as Item
}
function getItemTags(item: Item): Tag[] {
if (!item.text) return []
const matches = item.text.match(/#([a-zA-ZÀ-ÖØ-öø-ʸ0-9_-]+)/g)
if (!matches) return []
return matches.map((m) => ({
id: m,
name: m.slice(1),
color: '#000',
}))
}
const PLACES = 'places'
const EVENTS = 'events'
const items: Item[] = [
makeItem({ id: '1', layerName: PLACES, text: '#nature', date_updated: '2025-03-01T00:00:00Z' }),
makeItem({ id: '2', layerName: PLACES, text: '#food', date_created: '2025-02-01T00:00:00Z' }),
makeItem({
id: '3',
layerName: PLACES,
text: '#nature #food',
date_updated: '2025-01-01T00:00:00Z',
}),
makeItem({ id: '4', layerName: EVENTS, text: '#nature', date_updated: '2025-04-01T00:00:00Z' }),
makeItem({ id: '5', layerName: PLACES, text: '', date_created: '2025-05-01T00:00:00Z' }),
makeItem({ id: '6', text: '#nature' }), // no layer
]
describe('filterSortAndPaginate', () => {
describe('layer filtering', () => {
it('returns only items matching the given layer name', () => {
const { visibleItems } = filterSortAndPaginate(items, PLACES, [], getItemTags, 100)
expect(visibleItems.every((i) => i.layer?.name === PLACES)).toBe(true)
expect(visibleItems).toHaveLength(4) // ids 1,2,3,5
})
it('excludes items with no layer', () => {
const { visibleItems } = filterSortAndPaginate(items, PLACES, [], getItemTags, 100)
expect(visibleItems.find((i) => i.id === '6')).toBeUndefined()
})
})
describe('tag filtering', () => {
it('returns all layer items when no filter tags are active', () => {
const { visibleItems } = filterSortAndPaginate(items, PLACES, [], getItemTags, 100)
expect(visibleItems).toHaveLength(4)
})
it('keeps only items that have at least one matching tag', () => {
const filterTags: Tag[] = [{ id: 't1', name: 'food', color: '#000' }]
const { visibleItems } = filterSortAndPaginate(items, PLACES, filterTags, getItemTags, 100)
expect(visibleItems.map((i) => i.id).sort()).toEqual(['2', '3'])
})
it('excludes items with no matching tags', () => {
const filterTags: Tag[] = [{ id: 't1', name: 'food', color: '#000' }]
const { visibleItems } = filterSortAndPaginate(items, PLACES, filterTags, getItemTags, 100)
expect(visibleItems.find((i) => i.id === '1')).toBeUndefined()
})
it('matches tags case-insensitively', () => {
const filterTags: Tag[] = [{ id: 't1', name: 'Nature', color: '#000' }]
const { visibleItems } = filterSortAndPaginate(items, PLACES, filterTags, getItemTags, 100)
// ids 1 (#nature) and 3 (#nature #food)
expect(visibleItems.map((i) => i.id)).toContain('1')
expect(visibleItems.map((i) => i.id)).toContain('3')
})
it('items with no text (no tags) are excluded when filter is active', () => {
const filterTags: Tag[] = [{ id: 't1', name: 'nature', color: '#000' }]
const { visibleItems } = filterSortAndPaginate(items, PLACES, filterTags, getItemTags, 100)
expect(visibleItems.find((i) => i.id === '5')).toBeUndefined()
})
})
describe('sorting (newest first)', () => {
it('sorts by date_updated descending', () => {
const { visibleItems } = filterSortAndPaginate(items, PLACES, [], getItemTags, 100)
const ids = visibleItems.map((i) => i.id)
expect(ids).toEqual(['5', '1', '2', '3'])
})
it('falls back to date_created when date_updated is absent', () => {
const a = makeItem({ id: 'a', layerName: PLACES, date_created: '2025-06-01T00:00:00Z' })
const b = makeItem({ id: 'b', layerName: PLACES, date_updated: '2025-05-01T00:00:00Z' })
const { visibleItems } = filterSortAndPaginate([a, b], PLACES, [], getItemTags, 100)
expect(visibleItems[0].id).toBe('a')
})
it('items with no dates sort to the end', () => {
const dated = makeItem({ id: 'd', layerName: PLACES, date_created: '2025-01-01T00:00:00Z' })
const undated = makeItem({ id: 'u', layerName: PLACES })
const { visibleItems } = filterSortAndPaginate([undated, dated], PLACES, [], getItemTags, 100)
expect(visibleItems[0].id).toBe('d')
expect(visibleItems[1].id).toBe('u')
})
})
describe('pagination', () => {
it('limits visible items to itemsToShow', () => {
const { visibleItems, hasMore } = filterSortAndPaginate(items, PLACES, [], getItemTags, 2)
expect(visibleItems).toHaveLength(2)
expect(hasMore).toBe(true)
})
it('hasMore is false when all items fit within the limit', () => {
const { hasMore } = filterSortAndPaginate(items, PLACES, [], getItemTags, 100)
expect(hasMore).toBe(false)
})
it('hasMore is false when itemsToShow equals the item count exactly', () => {
const { visibleItems, hasMore } = filterSortAndPaginate(items, PLACES, [], getItemTags, 4)
expect(visibleItems).toHaveLength(4)
expect(hasMore).toBe(false)
})
it('returns empty array when no items match', () => {
const { visibleItems, hasMore } = filterSortAndPaginate(
items,
'nonexistent',
[],
getItemTags,
30,
)
expect(visibleItems).toEqual([])
expect(hasMore).toBe(false)
})
})
})

View File

@ -0,0 +1,48 @@
import type { Item } from '#types/Item'
import type { Tag } from '#types/Tag'
/**
* Pure helper that filters items by layer and tags, sorts by date (newest first),
* and returns a paginated slice.
*
* Extracted from OverlayItemsIndexPage for testability.
*
* @category Templates
*/
export function filterSortAndPaginate(
items: Item[],
layerName: string,
filterTags: Tag[],
getItemTags: (item: Item) => Tag[],
itemsToShow: number,
): { visibleItems: Item[]; hasMore: boolean } {
const filteredAndSortedItems = items
.filter((i) => i.layer?.name === layerName)
.filter((item) =>
filterTags.length === 0
? true
: filterTags.some((tag) =>
getItemTags(item).some(
(itemTag) => itemTag.name.toLocaleLowerCase() === tag.name.toLocaleLowerCase(),
),
),
)
.sort((a, b) => {
const dateA = a.date_updated
? new Date(a.date_updated).getTime()
: a.date_created
? new Date(a.date_created).getTime()
: 0
const dateB = b.date_updated
? new Date(b.date_updated).getTime()
: b.date_created
? new Date(b.date_created).getTime()
: 0
return dateB - dateA
})
const visibleItems = filteredAndSortedItems.slice(0, itemsToShow)
const hasMore = filteredAndSortedItems.length > itemsToShow
return { visibleItems, hasMore }
}