From 298560c02e3c2662e128c9988c46e77adeea2a16 Mon Sep 17 00:00:00 2001 From: Anton Tranelis <31516529+antontranelis@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:37:28 +0100 Subject: [PATCH] fix(lib): infinite scroll in items index page (#438) Co-authored-by: mahula Co-authored-by: Ulf Gebhardt --- .../Templates/OverlayItemsIndexPage.spec.tsx | 171 ++++++++++++++++++ .../Templates/OverlayItemsIndexPage.tsx | 102 +++++++---- .../Templates/filterSortAndPaginate.spec.ts | 145 +++++++++++++++ .../Templates/filterSortAndPaginate.ts | 48 +++++ 4 files changed, 428 insertions(+), 38 deletions(-) create mode 100644 lib/src/Components/Templates/OverlayItemsIndexPage.spec.tsx create mode 100644 lib/src/Components/Templates/filterSortAndPaginate.spec.ts create mode 100644 lib/src/Components/Templates/filterSortAndPaginate.ts diff --git a/lib/src/Components/Templates/OverlayItemsIndexPage.spec.tsx b/lib/src/Components/Templates/OverlayItemsIndexPage.spec.tsx new file mode 100644 index 00000000..102cca71 --- /dev/null +++ b/lib/src/Components/Templates/OverlayItemsIndexPage.spec.tsx @@ -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 }) =>
{i.name}
, +})) +vi.mock('./MapOverlayPage', () => ({ + MapOverlayPage: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) +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( + + + , + ) + } + + 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) + }) + }) +}) diff --git a/lib/src/Components/Templates/OverlayItemsIndexPage.tsx b/lib/src/Components/Templates/OverlayItemsIndexPage.tsx index f5aec8a1..aaf19ec3 100644 --- a/lib/src/Components/Templates/OverlayItemsIndexPage.tsx +++ b/lib/src/Components/Templates/OverlayItemsIndexPage.tsx @@ -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(false) const [addItemPopupOpen, setAddItemPopupOpen] = useState(false) - + const [itemsToShow, setItemsToShow] = useState(30) const tabRef = useRef(null) + const sentinelRef = useRef(null) + const scrollContainerRef = useRef(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) => { evt.preventDefault() const formItem: Item = {} as Item @@ -130,44 +173,18 @@ export const OverlayItemsIndexPage = ({ -
+
- {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) => ( -
- deleteItem(i)} - /> -
- ))} + {visibleItems.map((i) => ( +
+ deleteItem(i)} + /> +
+ ))} {addItemPopupOpen && (
submitNewItem(e)}>
@@ -208,6 +225,15 @@ export const OverlayItemsIndexPage = ({ )}
+ {hasMore && ( +
+ +
+ )}
diff --git a/lib/src/Components/Templates/filterSortAndPaginate.spec.ts b/lib/src/Components/Templates/filterSortAndPaginate.spec.ts new file mode 100644 index 00000000..cee6bdb9 --- /dev/null +++ b/lib/src/Components/Templates/filterSortAndPaginate.spec.ts @@ -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 & { 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) + }) + }) +}) diff --git a/lib/src/Components/Templates/filterSortAndPaginate.ts b/lib/src/Components/Templates/filterSortAndPaginate.ts new file mode 100644 index 00000000..95979b95 --- /dev/null +++ b/lib/src/Components/Templates/filterSortAndPaginate.ts @@ -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 } +}