mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2026-03-01 12:44:17 +00:00
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:
parent
ab515c20ad
commit
298560c02e
171
lib/src/Components/Templates/OverlayItemsIndexPage.spec.tsx
Normal file
171
lib/src/Components/Templates/OverlayItemsIndexPage.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
145
lib/src/Components/Templates/filterSortAndPaginate.spec.ts
Normal file
145
lib/src/Components/Templates/filterSortAndPaginate.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
48
lib/src/Components/Templates/filterSortAndPaginate.ts
Normal file
48
lib/src/Components/Templates/filterSortAndPaginate.ts
Normal 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 }
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user