diff --git a/client/scripts/eslint-baseline.json b/client/scripts/eslint-baseline.json index 6fca38f6f..a54f8e616 100644 --- a/client/scripts/eslint-baseline.json +++ b/client/scripts/eslint-baseline.json @@ -4,12 +4,12 @@ "@typescript-eslint/no-misused-promises": 847, "no-console": 88, "complexity": 218, - "@typescript-eslint/no-explicit-any": 146, + "@typescript-eslint/no-explicit-any": 144, "trek/no-swallowed-catch": 129, "max-params": 32, "trek/store-without-selector": 67, "react-hooks/refs": 555, - "react-hooks/set-state-in-effect": 194, + "react-hooks/set-state-in-effect": 193, "@typescript-eslint/no-floating-promises": 164, "react-hooks/exhaustive-deps": 116, "@typescript-eslint/no-unused-vars": 124, diff --git a/client/src/components/Journey/MobileEntryView.test.tsx b/client/src/components/Journey/MobileEntryView.test.tsx index d1eb3a2fc..22f64c229 100644 --- a/client/src/components/Journey/MobileEntryView.test.tsx +++ b/client/src/components/Journey/MobileEntryView.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-020 +// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-022 import { render, screen, fireEvent } from '../../../tests/helpers/render' import { resetAllStores, seedStore } from '../../../tests/helpers/store' @@ -136,6 +136,18 @@ describe('MobileEntryView', () => { expect(container.querySelector('svg.lucide-camera')).toBeNull() }) + it('FE-COMP-JENTRYVIEW-021: the hero holds the proportions of the photo and shows its thumbnail before the original arrives (#2907)', () => { + const { container } = renderView(buildEntry({ photos: [{ ...buildPhoto(4), width: 3000, height: 2000 }] })) + const hero = container.querySelector('img') as HTMLImageElement + expect(hero.style.aspectRatio).toBe('3000 / 2000') + expect(hero.style.backgroundImage).toContain('/api/photos/4/thumbnail') + }) + + it('FE-COMP-JENTRYVIEW-022: a photo without recorded dimensions still reserves a 4:3 hero', () => { + const { container } = renderView(buildEntry({ photos: [buildPhoto(4)] })) + expect((container.querySelector('img') as HTMLImageElement).style.aspectRatio).toBe('4 / 3') + }) + it('FE-COMP-JENTRYVIEW-008: several photos add a counter and a thumbnail strip', () => { const { container } = renderView(buildEntry({ photos: [buildPhoto(4), buildPhoto(5), buildPhoto(6)] })) expect(screen.getByText('3 photos')).toBeInTheDocument() diff --git a/client/src/components/Journey/MobileEntryView.tsx b/client/src/components/Journey/MobileEntryView.tsx index d8c6bbed8..7640fb8a4 100644 --- a/client/src/components/Journey/MobileEntryView.tsx +++ b/client/src/components/Journey/MobileEntryView.tsx @@ -4,6 +4,7 @@ import { Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ThumbsUp, ThumbsDown, } from 'lucide-react' +import type { CSSProperties } from 'react' import JournalBody from './JournalBody' import { useTranslation } from '../../i18n' import { formatLocationName } from '../../utils/formatters' @@ -39,6 +40,22 @@ function photoUrl( return `/api/photos/${p.photo_id}/${size}` } +/** + * The hero's box before its picture arrives (#2907): the photo's own proportions + * when the upload recorded them, else 4:3, so the story below does not jump down + * once the original has loaded. The thumbnail, usually cached by the strip or the + * feed already, fills that box meanwhile. + */ +function heroPlaceholderStyle(photo: JourneyPhoto, thumbUrl: string): CSSProperties { + const ratio = photo.width && photo.height ? `${photo.width} / ${photo.height}` : '4 / 3' + return { + aspectRatio: ratio, + backgroundImage: `url("${encodeURI(thumbUrl)}")`, + backgroundSize: 'cover', + backgroundPosition: 'center', + } +} + interface Props { entry: JourneyEntry readOnly?: boolean @@ -114,8 +131,10 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo )} @@ -143,7 +162,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo key={p.id || i} type="button" aria-label={p.caption || t('journey.photos')} - className={`relative h-16 w-16 flex-shrink-0 cursor-pointer overflow-hidden rounded-lg border-0 p-0 hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all ${p.media_type === 'video' ? 'bg-black' : 'bg-transparent' + className={`relative h-16 w-16 flex-shrink-0 cursor-pointer overflow-hidden rounded-lg border-0 p-0 hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all ${p.media_type === 'video' ? 'bg-black' : 'bg-surface-secondary' }`} onClick={() => onPhotoClick(photos, i)} > diff --git a/client/src/components/Journey/PhotoLightbox.test.tsx b/client/src/components/Journey/PhotoLightbox.test.tsx index 89d6ee1a7..9cd898883 100644 --- a/client/src/components/Journey/PhotoLightbox.test.tsx +++ b/client/src/components/Journey/PhotoLightbox.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-027 +// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-029 // Plyr needs a real media pipeline, so the player is stubbed here. vi.mock('./VideoPlayer', () => ({ @@ -326,4 +326,28 @@ describe('PhotoLightbox', () => { expect(screen.getByText('Google', { selector: 'p' })).toBeInTheDocument(); expect(screen.queryByRole('link')).not.toBeInTheDocument(); }); + it('FE-COMP-LIGHTBOX-028: the thumbnail and a spinner stand in until the original has loaded (#2907)', () => { + const { container } = render( + , + ); + const placeholder = screen.getByTestId('lightbox-loading'); + expect(placeholder.querySelector('img')).toHaveAttribute('src', '/a/thumbnail'); + expect(placeholder.querySelector('svg.animate-spin')).toBeInTheDocument(); + + fireEvent.load(container.ownerDocument.querySelector('img[src="/a/original"]') as HTMLImageElement); + expect(screen.queryByTestId('lightbox-loading')).not.toBeInTheDocument(); + }); + + it('FE-COMP-LIGHTBOX-029: the next photo waits for its own original, and a failed one drops the spinner too', () => { + render( + , + ); + fireEvent.load(document.querySelector('img[src="/a.jpg"]') as HTMLImageElement); + expect(screen.queryByTestId('lightbox-loading')).not.toBeInTheDocument(); + + fireEvent.keyDown(window, { key: 'ArrowRight' }); + expect(screen.getByTestId('lightbox-loading')).toBeInTheDocument(); + fireEvent.error(document.querySelector('img[src="/b.jpg"]') as HTMLImageElement); + expect(screen.queryByTestId('lightbox-loading')).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/Journey/PhotoLightbox.tsx b/client/src/components/Journey/PhotoLightbox.tsx index 96c4e4b2a..0c890afec 100644 --- a/client/src/components/Journey/PhotoLightbox.tsx +++ b/client/src/components/Journey/PhotoLightbox.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react' -import { ChevronLeft, ChevronRight, X, ZoomIn, ZoomOut } from 'lucide-react' +import { ChevronLeft, ChevronRight, Loader2, X, ZoomIn, ZoomOut } from 'lucide-react' import VideoPlayer from './VideoPlayerLazy' import { usePhotoZoom } from './usePhotoZoom' import { Tooltip } from '../shared/Tooltip' @@ -8,6 +8,12 @@ import { isRtlLanguage, useTranslation } from '../../i18n' interface LightboxPhoto { id: string src: string + /** + * A small rendition of the same photo, usually cached already by the grid that + * opened the lightbox. It stands in while the original loads, which on a slow + * server or connection can take seconds (#2907). + */ + thumbSrc?: string caption?: string | null /** Where the caption's credit points to (a photo's source page), opened in a new tab. */ captionHref?: string | null @@ -52,6 +58,9 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label } // Zoom starts over on every photo (#1484). const zoom = usePhotoZoom(photo?.id) const isImage = !!photo && photo.mediaType !== 'video' + // The id whose original has finished (or failed) loading; any other photo is still on its way. + const [loadedId, setLoadedId] = useState(null) + const loading = isImage && loadedId !== photo.id const hasPrev = idx > 0 const hasNext = idx < photos.length - 1 @@ -187,6 +196,8 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label } src={photo.src} alt={photo.caption || ''} draggable={false} + onLoad={() => setLoadedId(photo.id)} + onError={() => setLoadedId(photo.id)} {...zoom.mouse} style={{ maxWidth: '92vw', maxHeight: '92vh', @@ -198,6 +209,17 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label } /> )} + {loading && ( + + )} + {/* Next button */} {hasNext && ( @@ -215,12 +200,14 @@ export default function MJourneyEntrySheet({ : 'border-dashed border-[color:var(--m-rowbr)] text-m-muted' }`} > - - {captureOnly ? t('journey.share.gallery') : t('journey.editor.fromGallery')} + + {captureOnly ? t('journey.share.gallery') : t('journey.editor.fromGallery')} {/* Immich/Synology, the same source the desktop editor offers (#1808). - Only shown once a provider is actually connected — on a phone a - button that can only say "nothing connected" is not worth its width. */} + Only shown once a provider is actually connected: on a phone a + button that can only say "nothing connected" is not worth its width. + The icons keep their size and the labels truncate, so a third + button never squeezes the other two (#2908). */} {!captureOnly && providers.length > 0 && ( )} @@ -627,11 +614,12 @@ export default function MJourneyEntrySheet({ key={m.id} type="button" disabled={readOnly} + aria-pressed={active} onClick={() => setMood(active ? '' : m.id)} className={`flex items-center gap-[5px] rounded-full border px-3 py-[7px] text-[0.71875rem] font-semibold ${ active ? '' : 'border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted' }`} - style={active ? { background: `${m.color}24`, color: m.color, borderColor: `${m.color}4D` } : undefined} + style={active ? moodChipActiveStyle(m.color) : undefined} > {t(m.labelKey)} diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index a372cc0b1..59724c202 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -213,7 +213,7 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe onClose={() => setViewingEntry(null)} onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }} onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }} - onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })} + onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), thumbSrc: photoUrl(p, 'thumbnail'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })} /> )} @@ -602,7 +602,7 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe readOnly={!canEditEntries} onEdit={() => setEditingEntry(entry)} onDelete={() => setDeleteTarget(entry)} - onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })} + onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), thumbSrc: photoUrl(p, 'thumbnail'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })} /> )} @@ -656,7 +656,7 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe userId={useAuthStore.getState().user?.id || 0} trips={current.trips} canEdit={canEditEntries} - onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })} + onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), thumbSrc: photoUrl(p, 'thumbnail'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: idx })} onRefresh={() => loadJourney(Number(id))} onAddProviderPhotos={addPickedProviderPhotos} /> @@ -735,7 +735,7 @@ export function JourneyDetailPageDesktop({ journeyId, embedded = false }: Journe const entry = current.entries.find(e => String(e.id) === entryId) if (!entry?.photos?.length) return setLightbox({ - photos: entry.photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), + photos: entry.photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), thumbSrc: photoUrl(p, 'thumbnail'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index, }) }} diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx index b0b25d90c..8a1e7690f 100644 --- a/client/src/pages/JourneyPublicPage.tsx +++ b/client/src/pages/JourneyPublicPage.tsx @@ -72,6 +72,20 @@ function photoUrl(p: { photo_id: number }, shareToken: string, kind: 'thumbnail' return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`; } +type SharedPhoto = { id: number | string; photo_id: number; caption?: string | null; media_type?: string | null }; + +// The lightbox opens on the original and shows the thumbnail the page already +// loaded until it arrives, instead of a black screen (#2907). +function lightboxPhoto(p: SharedPhoto, shareToken: string) { + return { + id: String(p.id), + src: photoUrl(p, shareToken, 'original'), + thumbSrc: photoUrl(p, shareToken, 'thumbnail'), + caption: p.caption ?? null, + mediaType: p.media_type, + }; +} + // The share route answers 404 for the thumbnail of a clip that has no poster, and // an pointed at it draws the broken-image glyph (#2341). The black ground // the layouts give a video stands in for the picture. @@ -172,12 +186,7 @@ export default function JourneyPublicPage() { const picked = allPhotos.filter((p) => wanted.has(String(p.id))); if (!token || picked.length === 0) return; setLightbox({ - photos: picked.map((p) => ({ - id: String(p.id), - src: photoUrl(p, token, 'original'), - caption: p.caption, - mediaType: p.media_type, - })), + photos: picked.map((p) => lightboxPhoto(p, token)), index: 0, }); }; @@ -243,12 +252,7 @@ export default function JourneyPublicPage() { const prosArr = entry.pros_cons?.pros ?? []; const consArr = entry.pros_cons?.cons ?? []; const hasProscons = prosArr.length > 0 || consArr.length > 0; - const lightboxPhotos = photos.map((p) => ({ - id: String(p.id), - src: photoUrl(p, token!, 'original'), - caption: p.caption, - mediaType: p.media_type, - })); + const lightboxPhotos = photos.map((p) => lightboxPhoto(p, token!)); const isActive = activeEntryId === String(entry.id); return ( @@ -559,12 +563,7 @@ export default function JourneyPublicPage() { className="relative block aspect-square w-full cursor-pointer overflow-hidden rounded-lg" onClick={() => setLightbox({ - photos: allPhotos.map((p) => ({ - id: String(p.id), - src: photoUrl(p, token!, 'original'), - caption: p.caption, - mediaType: p.media_type, - })), + photos: allPhotos.map((p) => lightboxPhoto(p, token!)), index: idx, }) } @@ -760,12 +759,7 @@ export default function JourneyPublicPage() { onDelete={() => { }} onPhotoClick={(photos, idx) => setLightbox({ - photos: photos.map((p) => ({ - id: String(p.id), - src: photoUrl(p as any, token!, 'original'), - caption: (p as any).caption ?? null, - mediaType: p.media_type, - })), + photos: photos.map((p) => lightboxPhoto(p, token!)), index: idx, }) } diff --git a/client/src/pages/journeyDetail/useJourneyDetail.ts b/client/src/pages/journeyDetail/useJourneyDetail.ts index 3476cf0eb..ab39a56d8 100644 --- a/client/src/pages/journeyDetail/useJourneyDetail.ts +++ b/client/src/pages/journeyDetail/useJourneyDetail.ts @@ -470,7 +470,7 @@ export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailO const photos = (current?.gallery ?? []).filter(p => wanted.has(String(p.id))) if (!photos.length) return setLightbox({ - photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), + photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), thumbSrc: photoUrl(p, 'thumbnail'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id, mediaType: p.media_type })), index: 0, }) }, [current?.gallery]) diff --git a/client/src/pages/journeyPublic/useJourneyPublic.ts b/client/src/pages/journeyPublic/useJourneyPublic.ts index 94ac85249..f09fd72ae 100644 --- a/client/src/pages/journeyPublic/useJourneyPublic.ts +++ b/client/src/pages/journeyPublic/useJourneyPublic.ts @@ -26,7 +26,7 @@ export function useJourneyPublic() { const [error, setError] = useState(false) const isMobile = useIsMobile() const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline') - const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; caption?: string | null; mediaType?: string | null }[]; index: number } | null>(null) + const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; thumbSrc?: string; caption?: string | null; mediaType?: string | null }[]; index: number } | null>(null) const [showLangPicker, setShowLangPicker] = useState(false) const locale = useSettingsStore(s => s.settings.language) || 'en' const mapRef = useRef(null) diff --git a/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx b/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx index e27d96aab..9ea08562a 100644 --- a/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx +++ b/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx @@ -4,7 +4,6 @@ import { http, HttpResponse } from 'msw'; import { localIsoDate } from '../../../../src/utils/localDate'; import userEvent from '@testing-library/user-event'; import { mapsApi, weatherApi } from '../../../../src/api/client'; -import { useAddonStore } from '../../../../src/store/addonStore'; import MJourneyEntrySheet from '../../../../src/mobile/screens/journey/MJourneyEntrySheet'; import type { GalleryPhoto, JourneyEntry, JourneyPhoto } from '../../../../src/store/journeyStore'; import type { ResilientResult, UploadProgress } from '../../../../src/utils/uploadQueue'; @@ -252,6 +251,7 @@ interface MountOptions { withProviderHook?: boolean; upload?: UploadFn; strict?: boolean; + photoProviders?: { id: string; name: string }[]; } const defaultUpload: UploadFn = async (_entryId, files, cbs) => { @@ -274,6 +274,7 @@ function mountSheet(sheetEntry: JourneyEntry, opts: MountOptions = {}) { readOnly={opts.readOnly ?? false} userId={42} trips={[]} + photoProviders={opts.photoProviders} onClose={onClose} onSave={onSave} onUploadPhotos={onUploadPhotos} @@ -745,6 +746,18 @@ describe('MJourneyEntrySheet full editor', () => { expect(onSave.mock.calls[0][0]).toMatchObject({ mood: null, weather: null }); }); + it('FE-MOB-JENTRY-2905: the picked neutral mood is framed in its own colour and pressed, unlike the chips at rest', async () => { + const user = userEvent.setup(); + mountSheet(buildEntry({ id: 5 })); + + await user.click(screen.getByRole('button', { name: 'Neutral' })); + const neutral = screen.getByRole('button', { name: 'Neutral' }); + expect(neutral).toHaveAttribute('aria-pressed', 'true'); + expect(neutral.className).not.toContain('text-m-muted'); + expect(neutral.style.borderColor).toBe('rgb(154, 154, 161)'); + expect(screen.getByRole('button', { name: 'Good' })).toHaveAttribute('aria-pressed', 'false'); + }); + it('FE-MOB-JENTRY-022: picks a searched location and stores its coordinates', async () => { server.use(http.post('/api/maps/search', () => HttpResponse.json({ places: [ @@ -1161,15 +1174,10 @@ describe('MJourneyEntrySheet quick capture edge cases', () => { // FE-MOB-JENTRY-041 to FE-MOB-JENTRY-047 — external photo providers in the phone // editor (#1808), mirroring the desktop EntryEditor mechanics. -const immichAddon = { id: 'immich', name: 'Immich', type: 'photo_provider', icon: 'camera', enabled: true }; - -function seedAddons(addons: Array>) { - useAddonStore.setState({ addons: addons as never, loaded: true }); -} +const immich = [{ id: 'immich', name: 'Immich' }]; function useConnectedImmich() { server.use( - http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })), http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [{ id: 'asset-1', takenAt: '2026-03-15T09:00:00.000Z', mediaType: 'image' }], hasMore: false, @@ -1185,27 +1193,32 @@ describe('MJourneyEntrySheet external photos', () => { afterEach(() => { delete window.__addToast; - useAddonStore.setState({ addons: [], loaded: false }); }); - it('FE-MOB-JENTRY-041: hides the button while no provider is connected', async () => { - let probes = 0; - seedAddons([immichAddon]); - server.use(http.get('/api/integrations/memories/immich/status', () => { - probes += 1; - return HttpResponse.json({ connected: false }); - })); + it('FE-MOB-JENTRY-041: hides the button while no provider is connected', () => { mountSheet(buildEntry({ id: 5 })); - - await waitFor(() => expect(probes).toBe(1)); expect(screen.queryByRole('button', { name: 'External photos' })).not.toBeInTheDocument(); }); + it('FE-MOB-JENTRY-2908: a connected provider is there on the first render, without a probe of its own', () => { + let probes = 0; + server.use(http.get('/api/integrations/memories/immich/status', () => { + probes += 1; + return HttpResponse.json({ connected: true }); + })); + mountSheet(buildEntry({ id: 5 }), { photoProviders: immich }); + + const external = screen.getByRole('button', { name: 'External photos' }); + // The icons keep their width when a third button shares the row. + expect(external.querySelector('svg')).toHaveClass('shrink-0'); + expect(screen.getByRole('button', { name: 'From Gallery' }).querySelector('svg')).toHaveClass('shrink-0'); + expect(probes).toBe(0); + }); + it('FE-MOB-JENTRY-042: opens the embedded picker for a connected provider', async () => { - seedAddons([immichAddon, { id: 'budget', name: 'Budget', type: 'feature', icon: 'wallet', enabled: true }]); useConnectedImmich(); const user = userEvent.setup(); - mountSheet(buildEntry({ id: 5, location_lat: 41.9, location_lng: 12.5, location_name: 'Rome' })); + mountSheet(buildEntry({ id: 5, location_lat: 41.9, location_lng: 12.5, location_name: 'Rome' }), { photoProviders: immich }); await user.click(await screen.findByRole('button', { name: 'External photos' })); @@ -1216,20 +1229,18 @@ describe('MJourneyEntrySheet external photos', () => { }); it('FE-MOB-JENTRY-043: stays out of the quick capture sheet', async () => { - seedAddons([immichAddon]); useConnectedImmich(); Object.defineProperty(navigator, 'geolocation', { configurable: true, value: undefined }); - mountSheet(buildEntry(), { quickCapture: true }); + mountSheet(buildEntry(), { quickCapture: true, photoProviders: immich }); expect(await screen.findByRole('button', { name: 'Gallery' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'External photos' })).not.toBeInTheDocument(); }); it('FE-MOB-JENTRY-044: queues a picked photo and sends it after the save', async () => { - seedAddons([immichAddon]); useConnectedImmich(); const user = userEvent.setup(); - const { onSave, onAddProviderPhotos, onDone } = mountSheet(buildEntry({ id: 5 })); + const { onSave, onAddProviderPhotos, onDone } = mountSheet(buildEntry({ id: 5 }), { photoProviders: immich }); await user.click(await screen.findByRole('button', { name: 'External photos' })); await user.click(await screen.findByAltText('')); @@ -1249,10 +1260,9 @@ describe('MJourneyEntrySheet external photos', () => { }); it('FE-MOB-JENTRY-045: drops the queue again from the panel header', async () => { - seedAddons([immichAddon]); useConnectedImmich(); const user = userEvent.setup(); - const { onAddProviderPhotos } = mountSheet(buildEntry({ id: 5 })); + const { onAddProviderPhotos } = mountSheet(buildEntry({ id: 5 }), { photoProviders: immich }); await user.click(await screen.findByRole('button', { name: 'External photos' })); await user.click(await screen.findByAltText('')); @@ -1265,11 +1275,10 @@ describe('MJourneyEntrySheet external photos', () => { }); it('FE-MOB-JENTRY-046: a failed provider add keeps the queue and does not create a second entry', async () => { - seedAddons([immichAddon]); useConnectedImmich(); const user = userEvent.setup(); // A brand new entry: the first save creates it, the provider write then fails. - const { onSave, onAddProviderPhotos, onDone } = mountSheet(buildEntry()); + const { onSave, onAddProviderPhotos, onDone } = mountSheet(buildEntry(), { photoProviders: immich }); onAddProviderPhotos.mockRejectedValueOnce(new Error('immich down')); await user.click(await screen.findByRole('button', { name: 'External photos' })); @@ -1293,10 +1302,9 @@ describe('MJourneyEntrySheet external photos', () => { }); it('FE-MOB-JENTRY-047: a skeleton with only provider photos is promoted to an entry', async () => { - seedAddons([immichAddon]); useConnectedImmich(); const user = userEvent.setup(); - const { onSave } = mountSheet(buildEntry({ id: 9, type: 'skeleton', title: 'Venice' })); + const { onSave } = mountSheet(buildEntry({ id: 9, type: 'skeleton', title: 'Venice' }), { photoProviders: immich }); await user.click(await screen.findByRole('button', { name: 'External photos' })); await user.click(await screen.findByAltText(''));