fix(journey): the desktop gallery Upload button shows the files counting up again

Uploading photos into a journey gallery on desktop gave no sign that
anything was happening until the toast at the end; the Upload button
stayed clickable the whole time. Up to v4.0.0 the button sat inside the
gallery and showed a spinner with "Uploading done/total" while it was
disabled. When the button moved up into the page header (onRegisterUpload)
that indicator was dropped, and the count kept being tracked with no
reader.

useGalleryUpload now also returns the progress it tracks, the gallery
hands it up through onUploadProgress into useJourneyDetail, and the
page's Upload button is disabled and shows the spinner with
journey.editor.uploadingProgress while the files go, as it did before.

Red before the fix: src/pages/JourneyDetailPage.wiring.test.tsx
FE-JRN-DETWIRE-043 failed with "expected undefined to be [Function
Mock]": the gallery was never given an onUploadProgress callback and
the button never showed a count. FE-JRN-GALLERY-021 and
FE-JRN-GALLERYUPLOAD-007 cover the gallery and the hook. No existing
assertions changed.
This commit is contained in:
Maurice
2026-10-09 17:45:00 +02:00
parent 49e618cbc7
commit d118423570
8 changed files with 88 additions and 16 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"src": {
"@typescript-eslint/no-explicit-any": 155,
"@typescript-eslint/no-unused-expressions": 2,
"@typescript-eslint/no-unused-vars": 159,
"@typescript-eslint/no-unused-vars": 158,
"no-empty": 19,
"no-useless-assignment": 7,
"no-useless-escape": 1,
@@ -1,4 +1,4 @@
// FE-JRN-GALLERY-001 to FE-JRN-GALLERY-020
// FE-JRN-GALLERY-001 to FE-JRN-GALLERY-021
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { act, useRef, useState } from 'react'
@@ -79,11 +79,12 @@ const entries: JourneyEntry[] = [
/** Mirrors the page wiring: the gallery reports its connected providers up,
and the host renders one button per provider next to Upload. The picker's
Add goes through the same hook the page takes from useJourneyDetail. */
function GalleryHarness({ gallery, onPhotoClick, onRefresh, onRegisterUpload }: {
function GalleryHarness({ gallery, onPhotoClick, onRefresh, onRegisterUpload, onUploadProgress }: {
gallery: GalleryPhoto[]
onPhotoClick: (photos: GalleryPhoto[], index: number) => void
onRefresh: () => void
onRegisterUpload?: (fn: () => void) => void
onUploadProgress?: (progress: { done: number; total: number } | null) => void
}) {
const [providers, setProviders] = useState<{ id: string; name: string }[]>([])
const browseRef = useRef<((provider: string) => void) | null>(null)
@@ -103,6 +104,7 @@ function GalleryHarness({ gallery, onPhotoClick, onRefresh, onRegisterUpload }:
onRefresh={onRefresh}
onAddProviderPhotos={addPickedPhotos}
onRegisterUpload={onRegisterUpload}
onUploadProgress={onUploadProgress}
onRegisterProviders={(p, browse) => { setProviders(p); browseRef.current = browse }}
/>
</>
@@ -112,10 +114,11 @@ function GalleryHarness({ gallery, onPhotoClick, onRefresh, onRegisterUpload }:
function mountGallery(gallery: GalleryPhoto[], onRegisterUpload?: (fn: () => void) => void) {
const onPhotoClick = vi.fn()
const onRefresh = vi.fn()
const onUploadProgress = vi.fn()
const utils = render(
<GalleryHarness gallery={gallery} onPhotoClick={onPhotoClick} onRefresh={onRefresh} onRegisterUpload={onRegisterUpload} />,
<GalleryHarness gallery={gallery} onPhotoClick={onPhotoClick} onRefresh={onRefresh} onRegisterUpload={onRegisterUpload} onUploadProgress={onUploadProgress} />,
)
return { ...utils, onPhotoClick, onRefresh }
return { ...utils, onPhotoClick, onRefresh, onUploadProgress }
}
function useConnectedImmich() {
@@ -349,6 +352,19 @@ describe('GalleryView', () => {
expect(input.value).toBe('')
})
it('FE-JRN-GALLERY-021: hands the upload count up while the files go, and clears it after', async () => {
const { container, onRefresh, onUploadProgress } = mountGallery([])
expect(onUploadProgress).toHaveBeenLastCalledWith(null)
onUploadProgress.mockClear()
const input = container.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(input, { target: { files: [new File(['a'], 'a.jpg', { type: 'image/jpeg' })] } })
await waitFor(() => expect(onRefresh).toHaveBeenCalledTimes(1))
await waitFor(() => expect(onUploadProgress).toHaveBeenLastCalledWith(null))
expect(onUploadProgress).toHaveBeenCalledWith({ done: 0, total: 1 })
})
it('FE-JRN-GALLERY-015: reports partially failed uploads but still refreshes', async () => {
const failedFile = new File(['b'], 'b.jpg', { type: 'image/jpeg' })
uploadGalleryPhotos.mockResolvedValueOnce({ succeeded: [], failed: [failedFile] })
@@ -12,7 +12,7 @@ import { useGalleryUpload } from './useGalleryUpload'
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
import EmptyState from '../shared/EmptyState'
export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh, onAddProviderPhotos, onRegisterUpload, onRegisterProviders }: {
export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh, onAddProviderPhotos, onRegisterUpload, onRegisterProviders, onUploadProgress }: {
entries: JourneyEntry[]
gallery: GalleryPhoto[]
journeyId: number
@@ -23,13 +23,16 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
/** What the picker's Add does: the host's useProviderPhotoAdds, shared with the phone screen. */
onAddProviderPhotos: (journeyId: number, provider: string, groups: ProviderPhotoGroup[], entryId: number | null) => Promise<unknown>
onRegisterUpload?: (fn: () => void) => void
/** The upload's file count while it runs, null once it is through: the page's Upload button shows it. */
onUploadProgress?: (progress: { done: number; total: number } | null) => void
onRegisterProviders?: (providers: { id: string; name: string }[], browse: (provider: string) => void) => void
}) {
const { t } = useTranslation()
const [showPicker, setShowPicker] = useState(false)
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
const { uploading: galleryUploading, handleGalleryUpload } =
const { progress: galleryProgress, handleGalleryUpload } =
useGalleryUpload({ journeyId, onUploaded: onRefresh, trackProgress: true })
useEffect(() => { onUploadProgress?.(galleryProgress) }, [galleryProgress, onUploadProgress])
const toast = useToast()
// The providers enabled AND connected for the current user, handed up: the page
@@ -1,4 +1,4 @@
// FE-JRN-GALLERYUPLOAD-001 to FE-JRN-GALLERYUPLOAD-006: the gallery upload behind both
// FE-JRN-GALLERYUPLOAD-001 to FE-JRN-GALLERYUPLOAD-007: the gallery upload behind both
// the desktop gallery and the phone journey screen.
import { act, renderHook } from '@testing-library/react';
import type React from 'react';
@@ -111,4 +111,32 @@ describe('useGalleryUpload', () => {
expect(toast.success).toHaveBeenCalledWith('view:journey.photosUploaded');
expect(addToast).not.toHaveBeenCalled();
});
it('FE-JRN-GALLERYUPLOAD-007: progress counts the files up while they go and is gone after', async () => {
let finish: () => void = () => {};
let report: (p: unknown) => void = () => {};
upload.mockImplementationOnce(
(_id: number, _files: File[], cbs?: { onProgress?: (p: unknown) => void }) =>
new Promise((resolve) => {
report = (p) => cbs?.onProgress?.(p);
finish = () => resolve({ succeeded: [], failed: [] });
})
);
const { result } = setup({ trackProgress: true });
expect(result.current.progress).toBeNull();
let running: Promise<void> = Promise.resolve();
await act(async () => {
running = result.current.handleGalleryUpload(
changeEvent([new File(['a'], 'a.jpg'), new File(['b'], 'b.jpg')]).event
);
});
expect(result.current.progress).toEqual({ done: 0, total: 2 });
act(() => report({ done: 1, total: 2 }));
expect(result.current.progress).toEqual({ done: 1, total: 2 });
await act(async () => {
finish();
await running;
});
expect(result.current.progress).toBeNull();
});
});
@@ -14,8 +14,9 @@ export interface GalleryUploadOptions {
/** Reload the journey once the upload is through, whatever came of it. */
onUploaded: () => void;
/**
* Hand the upload a progress callback, as the desktop gallery always has. The count
* is only tracked; no view shows it.
* Hand the upload a progress callback, so `progress` counts the files up while they go
* (the desktop gallery's upload button shows it). Without it the count stays at none
* done until the batch is through.
*/
trackProgress?: boolean;
/**
@@ -76,5 +77,5 @@ export function useGalleryUpload({
e.target.value = '';
};
return { uploading: progress !== null, handleGalleryUpload };
return { uploading: progress !== null, progress, handleGalleryUpload };
}
+16 -4
View File
@@ -11,7 +11,7 @@ import EmptyState from '../components/shared/EmptyState'
import { Outlet } from 'react-router'
import {
ArrowLeft, MoreHorizontal, List, Grid, MapPin,
Plus, ChevronUp, ChevronDown, Eye, EyeOff, BookOpen, Image, Search, X,
Plus, ChevronUp, ChevronDown, Eye, EyeOff, BookOpen, Image, Search, X, Loader2,
} from 'lucide-react'
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import JourneyDayDawarich from '../components/Journey/JourneyDayDawarich'
@@ -56,6 +56,7 @@ function JourneyDetailPageDesktop() {
query, setQuery, dismissSuggestion, restoreSuggestions, openAtEntryId,
dawarichByDate, dawarichBusyId, acceptDawarich, dismissDawarich,
mapRef, fullMapRef, galleryUploadRef, galleryProviders, setGalleryProviders, galleryBrowseRef,
galleryUploadProgress, setGalleryUploadProgress,
activeLocationId, handleMarkerClick, handleLocationClick,
mapEntries, sidebarMapItems, tripDates, isMobile, tracks, mapPhotos, openMapPhotos,
feedEdge, scrollFeedTo,
@@ -145,11 +146,21 @@ function JourneyDetailPageDesktop() {
))}
<button type="button"
onClick={() => galleryUploadRef.current?.()}
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-full text-[13px] font-semibold transition-transform hover:-translate-y-0.5"
disabled={galleryUploadProgress !== null}
className="inline-flex items-center gap-1.5 h-9 px-4 rounded-full text-[13px] font-semibold transition-transform hover:-translate-y-0.5 disabled:opacity-70 disabled:hover:translate-y-0"
style={{ background: 'var(--vg-ink)', color: 'var(--vg-bg)' }}
>
<Plus size={16} strokeWidth={2.4} />
{t('common.upload')}
{galleryUploadProgress ? (
<>
<Loader2 size={16} strokeWidth={2.4} className="animate-spin" />
{t('journey.editor.uploadingProgress', { done: String(galleryUploadProgress.done), total: String(galleryUploadProgress.total) })}
</>
) : (
<>
<Plus size={16} strokeWidth={2.4} />
{t('common.upload')}
</>
)}
</button>
</div>
) : null
@@ -600,6 +611,7 @@ function JourneyDetailPageDesktop() {
)}
<GalleryView
onRegisterUpload={(fn) => { galleryUploadRef.current = fn }}
onUploadProgress={setGalleryUploadProgress}
onRegisterProviders={(providers, browse) => { setGalleryProviders(providers); galleryBrowseRef.current = browse }}
entries={current.entries}
gallery={current.gallery || []}
@@ -1,4 +1,4 @@
// FE-JRN-DETWIRE-001 to FE-JRN-DETWIRE-042
// FE-JRN-DETWIRE-001 to FE-JRN-DETWIRE-043
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, within } from '../../tests/helpers/render';
import { journeyApi } from '../api/client';
@@ -106,6 +106,7 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
acceptDawarich: vi.fn(async () => {}), dismissDawarich: vi.fn(),
mapRef: { current: null }, fullMapRef: { current: null }, galleryUploadRef: { current: null },
galleryProviders: [], setGalleryProviders: vi.fn(), galleryBrowseRef: { current: null },
galleryUploadProgress: null, setGalleryUploadProgress: vi.fn(),
activeLocationId: null, handleMarkerClick: vi.fn(), handleLocationClick: vi.fn(),
mapEntries: [], sidebarMapItems: [], tripDates: new Set<string>(), isMobile: false,
feedEdge: { atTop: true, atBottom: true }, scrollFeedTo: vi.fn(),
@@ -601,4 +602,12 @@ describe('JourneyDetailPage wiring', () => {
process.off('unhandledRejection', unhandled);
}
});
it('FE-JRN-DETWIRE-043: the gallery upload button counts the files up while they go', () => {
const { hook } = setup({ view: 'gallery', galleryUploadProgress: { done: 1, total: 3 } });
expect(mocks.captured.gallery.onUploadProgress).toBe(hook.setGalleryUploadProgress);
const button = screen.getByText('journey.editor.uploadingProgress').closest('button')!;
expect(button).toBeDisabled();
expect(screen.queryByText('common.upload')).not.toBeInTheDocument();
});
});
@@ -40,6 +40,8 @@ export function useJourneyDetail() {
const fullMapRef = useRef<JourneyMapHandle>(null)
/** The gallery hands its file picker up here, so the hero button can open it. */
const galleryUploadRef = useRef<(() => void) | null>(null)
/** The gallery's upload count while it runs, for that same hero button. */
const [galleryUploadProgress, setGalleryUploadProgress] = useState<{ done: number; total: number } | null>(null)
/** Same deal for photo providers: the gallery probes which ones are
connected and reports them up, so the header can render an Immich/
Synology button next to Upload. */
@@ -561,6 +563,7 @@ export function useJourneyDetail() {
dawarichByDate, dawarichBusyId: dawarich.busyId, acceptDawarich, dismissDawarich,
openAtEntryId,
mapRef, fullMapRef, galleryUploadRef, galleryProviders, setGalleryProviders, galleryBrowseRef,
galleryUploadProgress, setGalleryUploadProgress,
activeLocationId, handleMarkerClick, handleLocationClick,
mapEntries, sidebarMapItems, tripDates, isMobile, tracks: mapTracks, dawarichTrail,
mapPhotos, openMapPhotos,