diff --git a/client/src/components/Journey/JourneyDetailPageGalleryView.test.tsx b/client/src/components/Journey/JourneyDetailPageGalleryView.test.tsx index 42b35bc94..87294107f 100644 --- a/client/src/components/Journey/JourneyDetailPageGalleryView.test.tsx +++ b/client/src/components/Journey/JourneyDetailPageGalleryView.test.tsx @@ -1,6 +1,7 @@ // FE-JRN-GALLERY-001 to FE-JRN-GALLERY-016 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { act } from 'react' import { http, HttpResponse } from 'msw' import userEvent from '@testing-library/user-event' import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render' @@ -22,6 +23,31 @@ const uploadGalleryPhotos = vi.fn( const JOURNEY_ID = 9 +// The global IntersectionObserver stub never fires; this one hands the callback +// back so the gallery's load-more sentinel can be driven into view on demand. +const galleryObservers: { cb: IntersectionObserverCallback; el: Element | null }[] = [] + +function triggerIntersection() { + for (const o of galleryObservers) { + o.cb([{ isIntersecting: true, target: o.el } as unknown as IntersectionObserverEntry], null as never) + } +} + +function stubIntersectionObserver() { + galleryObservers.length = 0 + vi.stubGlobal('IntersectionObserver', class { + observe: (el: Element) => void + disconnect = vi.fn() + unobserve = vi.fn() + takeRecords = vi.fn(() => []) + constructor(cb: IntersectionObserverCallback) { + const entry: { cb: IntersectionObserverCallback; el: Element | null } = { cb, el: null } + galleryObservers.push(entry) + this.observe = (el: Element) => { entry.el = el } + } + }) +} + function buildGalleryPhoto(overrides: Partial = {}): GalleryPhoto { return { id: 100, @@ -303,4 +329,27 @@ describe('GalleryView', () => { await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Some photos failed to upload', 'error', undefined)) expect(onRefresh).not.toHaveBeenCalled() }) + // #1614 — a long trip's gallery is hundreds of tiles; rendering them all at once + // is what made scrolling it a chore. + it('FE-JRN-GALLERY-017: renders a first page and grows as the trigger comes into view', () => { + stubIntersectionObserver() + const many = Array.from({ length: 145 }, (_, i) => buildGalleryPhoto({ id: 100 + i, photo_id: 100 + i })) + const { container } = mountGallery(many) + + const tiles = () => container.querySelectorAll('.aspect-square') + expect(tiles()).toHaveLength(60) + + // The observer is stubbed in this suite, so drive the trigger the way it would. + act(() => { triggerIntersection() }) + expect(tiles()).toHaveLength(120) + + act(() => { triggerIntersection() }) + expect(tiles()).toHaveLength(145) + }) + + it('FE-JRN-GALLERY-018: a gallery below one page shows no trigger at all', () => { + const { container } = mountGallery([buildGalleryPhoto(), buildGalleryPhoto({ id: 101, photo_id: 101 })]) + expect(container.querySelectorAll('.aspect-square')).toHaveLength(2) + expect(container.querySelector('.animate-spin')).toBeNull() + }) }) diff --git a/client/src/components/Journey/JourneyDetailPageGalleryView.tsx b/client/src/components/Journey/JourneyDetailPageGalleryView.tsx index 016d5d81d..03dfb2b75 100644 --- a/client/src/components/Journey/JourneyDetailPageGalleryView.tsx +++ b/client/src/components/Journey/JourneyDetailPageGalleryView.tsx @@ -10,6 +10,7 @@ import { getApiErrorMessage } from '../../types' import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore' import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers' import { ProviderPicker } from './JourneyDetailPageProviderPicker' +import { ScrollTrigger } from './JourneyDetailPageScrollTrigger' import EmptyState from '../shared/EmptyState' export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh, onRegisterUpload }: { @@ -54,6 +55,14 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot }, []) const allPhotos = gallery + // A long trip's gallery is hundreds of tiles, and rendering them all at once is + // what makes scrolling it a chore (#1614). The provider picker has grown its own + // way in for the same reason; this reuses it rather than inventing a second one. + const GALLERY_PAGE = 60 + const [visibleCount, setVisibleCount] = useState(GALLERY_PAGE) + const shownPhotos = allPhotos.slice(0, visibleCount) + // Deleting or adding photos must not leave the window stranded past the end. + useEffect(() => { setVisibleCount(c => Math.min(Math.max(c, GALLERY_PAGE), Math.max(allPhotos.length, GALLERY_PAGE))) }, [allPhotos.length]) const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title) @@ -143,7 +152,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot ) : (
- {allPhotos.map((photo, i) => ( + {shownPhotos.map((photo, i) => (
)} + {visibleCount < allPhotos.length && ( + setVisibleCount(c => c + GALLERY_PAGE)} loading={false} /> + )} {/* Provider Photo Picker Modal */} {showPicker && (