diff --git a/client/scripts/offline-baseline.json b/client/scripts/offline-baseline.json index 659d61731..a6b13ba15 100644 --- a/client/scripts/offline-baseline.json +++ b/client/scripts/offline-baseline.json @@ -47,9 +47,7 @@ "src/components/Files/FileManagerImageLightbox.tsx", "src/components/Files/docsync/useConflicts.ts", "src/components/Files/docsync/useDocSync.ts", - "src/components/Files/fileActions.ts", "src/components/Files/useFileManager.ts", - "src/components/Files/useFileTrash.ts", "src/components/Files/useMediaLightbox.ts", "src/components/Journey/ContributorInviteDialog.tsx", "src/components/Journey/JourneyDetailPageEntryCard.tsx", @@ -134,7 +132,6 @@ "src/mobile/screens/journey/MJourneyDetail.tsx", "src/mobile/screens/trip/plan/useMPlanTimeline.ts", "src/mobile/screens/trip/tabs/MCollabNotes.tsx", - "src/mobile/screens/trip/tabs/MFilesTab.tsx", "src/pages/JourneyDetailPage.tsx", "src/pages/OAuthAuthorizePage.tsx", "src/pages/TripPlannerPage.tsx", diff --git a/client/src/components/Files/FileManager.test.tsx b/client/src/components/Files/FileManager.test.tsx index 71b268f76..6505fbb92 100644 --- a/client/src/components/Files/FileManager.test.tsx +++ b/client/src/components/Files/FileManager.test.tsx @@ -168,7 +168,7 @@ describe('FileManager', () => { const starBtn = screen.getByRole('button', { name: /star/i }); await user.click(starBtn); - expect(filesApi.toggleStar).toHaveBeenCalledWith(1, 1); + await waitFor(() => expect(filesApi.toggleStar).toHaveBeenCalledWith(1, 1)); }); it('FE-COMP-FILEMANAGER-006: trash toggle loads and displays trashed files', async () => { @@ -207,7 +207,7 @@ describe('FileManager', () => { const restoreBtn = screen.getByRole('button', { name: /restore/i }); await user.click(restoreBtn); - expect(filesApi.restore).toHaveBeenCalledWith(1, 5); + await waitFor(() => expect(filesApi.restore).toHaveBeenCalledWith(1, 5)); }); it('FE-COMP-FILEMANAGER-008: permanent delete calls filesApi.permanentDelete after confirm', async () => { diff --git a/client/src/components/Files/FileManagerAssignModal.test.tsx b/client/src/components/Files/FileManagerAssignModal.test.tsx index 3e16286c9..481ff9c6f 100644 --- a/client/src/components/Files/FileManagerAssignModal.test.tsx +++ b/client/src/components/Files/FileManagerAssignModal.test.tsx @@ -26,6 +26,7 @@ import { AssignModal } from './FileManagerAssignModal' const setAssignFileId = vi.fn() const handleAssign = vi.fn(async (_fileId: number, _data: unknown) => {}) const refreshFiles = vi.fn(async () => {}) +const toastError = vi.fn() const file = (overrides: Partial = {}) => ({ id: 7, original_name: 'ticket.pdf', description: '', url: '/uploads/files/ticket.pdf', ...overrides }) as unknown as TripFile @@ -47,6 +48,7 @@ function state(overrides: Partial = {}): FileManagerState { tripId: 3, handleAssign, refreshFiles, + toast: { error: toastError }, ...overrides, } as unknown as FileManagerState } @@ -198,13 +200,14 @@ describe('AssignModal place list', () => { expect(refreshFiles).toHaveBeenCalled() }) - it('FE-W5ASG-014: a failing link request is swallowed', async () => { + it('FE-W5ASG-014: a failing link request shows the assign error and does not refresh', async () => { addLink.mockRejectedValueOnce(new Error('offline')) render() fireEvent.click(screen.getByRole('button', { name: 'Eiffel Tower' })) - await waitFor(() => expect(addLink).toHaveBeenCalled()) + await waitFor(() => expect(toastError).toHaveBeenCalledWith('files.toast.assignError')) + expect(addLink).toHaveBeenCalled() expect(refreshFiles).not.toHaveBeenCalled() }) @@ -236,16 +239,32 @@ describe('AssignModal place list', () => { expect(removeLink).not.toHaveBeenCalled() }) - it('FE-W5ASG-018: a failing links lookup is swallowed', async () => { + it('FE-W5ASG-018: a failing links lookup shows the assign error and does not refresh', async () => { getLinks.mockRejectedValueOnce(new Error('offline')) render() fireEvent.click(screen.getByRole('button', { name: 'Eiffel Tower' })) - await waitFor(() => expect(getLinks).toHaveBeenCalled()) + await waitFor(() => expect(toastError).toHaveBeenCalledWith('files.toast.assignError')) + expect(getLinks).toHaveBeenCalled() expect(refreshFiles).not.toHaveBeenCalled() }) + it('FE-W5ASG-035: offline, removing a further link says it needs a connection', async () => { + Object.defineProperty(navigator, 'onLine', { value: false, writable: true, configurable: true }) + const t = (key: string) => (key === 'files.linkNeedsConnection' ? 'Needs a connection' : key) + try { + render()} />) + + fireEvent.click(screen.getByRole('button', { name: 'Eiffel Tower' })) + + await waitFor(() => expect(toastError).toHaveBeenCalledWith('Needs a connection')) + expect(getLinks).not.toHaveBeenCalled() + } finally { + Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true }) + } + }) + it('FE-W5ASG-019: rows keep the linked highlight on mouse-out, free rows do not', () => { render() const linked = screen.getByRole('button', { name: 'Louvre' }) diff --git a/client/src/components/Files/FileManagerAssignModal.tsx b/client/src/components/Files/FileManagerAssignModal.tsx index 0f4c1f353..5fdd6a2a4 100644 --- a/client/src/components/Files/FileManagerAssignModal.tsx +++ b/client/src/components/Files/FileManagerAssignModal.tsx @@ -7,6 +7,7 @@ import { transportIcon } from './FileManager.helpers' import { planFileLinkToggle, runFileLinkRecordStep, type FileLinkField } from './fileActions' import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../shared/DialogShell' import { EditorField, INPUT } from '../shared/dialogParts' +import { translateApiError } from '../../i18n' export function AssignModal(S: FileManagerState) { const { files, assignFileId, setAssignFileId, t, days, assignments, places, reservations, tripId, handleAssign, refreshFiles } = S @@ -22,8 +23,9 @@ export function AssignModal(S: FileManagerState) { try { await runFileLinkRecordStep(tripId, file.id, field, targetId, step.kind) refreshFiles() - } catch { - // A failed link record change stays quiet. + } catch (err) { + // Offline a link record cannot be removed, and the hint says so. + S.toast.error(translateApiError(t, err, 'files.toast.assignError')) } } return ( diff --git a/client/src/components/Files/fileActions.test.ts b/client/src/components/Files/fileActions.test.ts index 3250af448..564fa2c6a 100644 --- a/client/src/components/Files/fileActions.test.ts +++ b/client/src/components/Files/fileActions.test.ts @@ -1,4 +1,4 @@ -// FE-FILES-ACTIONS-001 to FE-FILES-ACTIONS-020: the paste, link, field save and trash rules the +// FE-FILES-ACTIONS-001 to FE-FILES-ACTIONS-021: the paste, link, field save and trash rules the // desktop file manager and the phone's files tab and sheets share, with what each view keeps. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -244,3 +244,27 @@ describe('trashFileWithToast', () => { expect(fb.toast.error).toHaveBeenCalledWith('files.toast.deleteError'); }); }); + +describe('link toggles offline (#2601)', () => { + afterEach(() => { + Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true }); + vi.restoreAllMocks(); + }); + + it('FE-FILES-ACTIONS-021: the column and a new record wait in the queue, removing a record says it needs a connection', async () => { + Object.defineProperty(navigator, 'onLine', { value: false, writable: true, configurable: true }); + const update = vi.spyOn(filesApi, 'update'); + const addLink = vi.spyOn(filesApi, 'addLink'); + const getLinks = vi.spyOn(filesApi, 'getLinks'); + + await toggleFileLink(1, buildTripFile({ id: 9, place_id: null }), 'place_id', 5); + await toggleFileLink(1, buildTripFile({ id: 9, place_id: 1 }), 'place_id', 6); + await expect( + toggleFileLink(1, buildTripFile({ id: 9, place_id: 1, linked_place_ids: [6] }), 'place_id', 6) + ).rejects.toThrow('files.linkNeedsConnection'); + + expect(update).not.toHaveBeenCalled(); + expect(addLink).not.toHaveBeenCalled(); + expect(getLinks).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Files/fileActions.ts b/client/src/components/Files/fileActions.ts index 7ddf39774..0c0f633b0 100644 --- a/client/src/components/Files/fileActions.ts +++ b/client/src/components/Files/fileActions.ts @@ -1,4 +1,4 @@ -import { filesApi } from '../../api/client'; +import { fileRepo } from '../../repo/fileRepo'; import type { TranslationFn, TripFile } from '../../types'; import type { useToast } from '../shared/Toast'; @@ -71,7 +71,11 @@ interface FileLinkRecord { reservation_id?: number | string | null; } -/** Runs an `addLink` or `removeLink` step against the server. */ +/** + * Runs an `addLink` or `removeLink` step. Adding one waits in the offline queue; + * removing one needs the server to name the link record, so offline it throws + * the hint that says so. + */ export async function runFileLinkRecordStep( tripId: number, fileId: number, @@ -81,18 +85,16 @@ export async function runFileLinkRecordStep( rules: FileLinkRules = {} ): Promise { if (step === 'addLink') { - await filesApi.addLink( + await fileRepo.addLink( tripId, fileId, field === 'place_id' ? { place_id: targetId } : { reservation_id: targetId } ); return; } - const linksRes = (await filesApi.getLinks(tripId, fileId)) as { links?: FileLinkRecord[] }; - const link = (linksRes.links || []).find((l) => + await fileRepo.removeLinkTo(tripId, fileId, (l: FileLinkRecord) => rules.matchRecordsByNumber ? Number(l[field]) === targetId : l[field] === targetId ); - if (link) await filesApi.removeLink(tripId, fileId, link.id); } /** Toggles one place or booking on a file, whichever of the server calls that takes. */ @@ -104,7 +106,7 @@ export async function toggleFileLink( rules: FileLinkRules = {} ): Promise { const step = planFileLinkToggle(file, field, targetId, rules); - if (step.kind === 'update') await filesApi.update(tripId, file.id, step.data); + if (step.kind === 'update') await fileRepo.update(tripId, file.id, step.data); else await runFileLinkRecordStep(tripId, file.id, field, targetId, step.kind, rules); } @@ -128,12 +130,12 @@ export const PHONE_FILE_UPDATE_RULES: FileUpdateRules = { awaitRefresh: true }; export async function updateFileFields( tripId: number, fileId: number, - data: Parameters[2], + data: Parameters[2], { t, toast, refresh }: FileFeedback & { refresh: () => unknown }, rules: FileUpdateRules = {} ): Promise { try { - await filesApi.update(tripId, fileId, data); + await fileRepo.update(tripId, fileId, data); if (rules.awaitRefresh) await refresh(); else void refresh(); } catch { diff --git a/client/src/components/Files/useFileManager.ts b/client/src/components/Files/useFileManager.ts index 8f4c30e72..37cf7c954 100644 --- a/client/src/components/Files/useFileManager.ts +++ b/client/src/components/Files/useFileManager.ts @@ -2,7 +2,7 @@ import { useState, useCallback, useEffect } from 'react' import { useDropzone } from 'react-dropzone' import { useToast } from '../shared/Toast' import { useTranslation, translateApiError } from '../../i18n' -import { filesApi } from '../../api/client' +import { fileRepo } from '../../repo/fileRepo' import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' @@ -66,7 +66,7 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place const handleStar = async (fileId: number) => { try { - await filesApi.toggleStar(tripId, fileId) + await fileRepo.toggleStar(tripId, fileId) refreshFiles() } catch { /* */ } } diff --git a/client/src/components/Files/useFileTrash.ts b/client/src/components/Files/useFileTrash.ts index c2744e895..f7dc81e87 100644 --- a/client/src/components/Files/useFileTrash.ts +++ b/client/src/components/Files/useFileTrash.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; -import { filesApi } from '../../api/client'; import type { useTranslation } from '../../i18n'; +import { fileRepo } from '../../repo/fileRepo'; import type { TripFile } from '../../types'; import type { useToast } from '../shared/Toast'; @@ -31,7 +31,7 @@ export function useFileTrash({ tripId, t, toast, onRestored, open }: FileTrashOp const load = useCallback(async () => { setLoading(true); try { - const data = await filesApi.list(tripId, true); + const data = await fileRepo.listTrash(tripId); setFiles(data.files || []); } catch { // An unreachable trash shows as empty. @@ -43,8 +43,8 @@ export function useFileTrash({ tripId, t, toast, onRestored, open }: FileTrashOp if (!open) return; let cancelled = false; setLoading(true); - filesApi - .list(tripId, true) + fileRepo + .listTrash(tripId) .then((data: { files?: TripFile[] }) => { if (!cancelled) setFiles(data.files || []); }) @@ -59,7 +59,11 @@ export function useFileTrash({ tripId, t, toast, onRestored, open }: FileTrashOp const restore = async (fileId: number) => { try { - await filesApi.restore(tripId, fileId); + await fileRepo.restore( + tripId, + fileId, + files.find((f) => f.id === fileId) + ); setFiles((prev) => prev.filter((f) => f.id !== fileId)); onRestored(); toast.success(t('files.toast.restored')); @@ -70,7 +74,7 @@ export function useFileTrash({ tripId, t, toast, onRestored, open }: FileTrashOp const permanentDelete = async (fileId: number) => { try { - await filesApi.permanentDelete(tripId, fileId); + await fileRepo.permanentDelete(tripId, fileId); setFiles((prev) => prev.filter((f) => f.id !== fileId)); toast.success(t('files.toast.deleted')); } catch { @@ -80,7 +84,7 @@ export function useFileTrash({ tripId, t, toast, onRestored, open }: FileTrashOp const emptyTrash = async () => { try { - await filesApi.emptyTrash(tripId); + await fileRepo.emptyTrash(tripId); setFiles([]); toast.success(t('files.toast.trashEmptied')); } catch { diff --git a/client/src/components/Planner/BookingLinkAndFiles.tsx b/client/src/components/Planner/BookingLinkAndFiles.tsx index 8db0d1efd..9ce5a3690 100644 --- a/client/src/components/Planner/BookingLinkAndFiles.tsx +++ b/client/src/components/Planner/BookingLinkAndFiles.tsx @@ -1,7 +1,7 @@ import type { ChangeEvent, RefObject } from 'react' import { ExternalLink, FileText, Link2, X } from 'lucide-react' import { useTripStore } from '../../store/tripStore' -import { useTranslation } from '../../i18n' +import { translateApiError, useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import { openFile } from '../../utils/fileDownload' import { BookingFileButtons } from './BookingFileButtons' @@ -50,7 +50,7 @@ export function BookingLinkAndFiles({ const linkable = reservationId ? tripFiles.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)) : [] const detach = async (file: TripFile) => { if (!tripId || !reservationId) return - try { await unlinkFile(tripId, file, reservationId) } catch { toast.error(t('reservations.toast.updateError')) } + try { await unlinkFile(tripId, file, reservationId) } catch (err) { toast.error(translateApiError(t, err, 'reservations.toast.updateError')) } onDetached(file.id) } diff --git a/client/src/mobile/screens/trip/tabs/MFileLinkSheet.tsx b/client/src/mobile/screens/trip/tabs/MFileLinkSheet.tsx index d23c56288..3ef3d88a7 100644 --- a/client/src/mobile/screens/trip/tabs/MFileLinkSheet.tsx +++ b/client/src/mobile/screens/trip/tabs/MFileLinkSheet.tsx @@ -5,6 +5,7 @@ import MSheet from '../../../components/MSheet' import { PHONE_FILE_LINK_RULES, toggleFileLink, type FileLinkField } from '../../../../components/Files/fileActions' import { linkedFileIds } from '../../../../components/Files/fileListRules' import type { TripFile } from '../../../../types' +import { translateApiError } from '../../../../i18n' import type { TripPlanner } from '../MTripShell' import { Eyebrow, TileHeader } from '../sheets/MTripSheetUi' @@ -46,8 +47,8 @@ export default function MFileLinkSheet({ planner, file, onClose }: MFileLinkShee try { await toggleFileLink(tripId, shown, field, targetId, PHONE_FILE_LINK_RULES) refresh() - } catch { - toast.error(t('files.toast.assignError')) + } catch (err) { + toast.error(translateApiError(t, err, 'files.toast.assignError')) } finally { setBusyKey(null) } diff --git a/client/src/mobile/screens/trip/tabs/MFilesTab.tsx b/client/src/mobile/screens/trip/tabs/MFilesTab.tsx index 674d201b2..675f00172 100644 --- a/client/src/mobile/screens/trip/tabs/MFilesTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MFilesTab.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState, type ChangeEvent, type ClipboardEvent } fr import { Link, Loader2, MoreVertical, Star } from 'lucide-react' import MDancingTrek from '../../../components/MDancingTrek' import { useTranslation, translateApiError } from '../../../../i18n' -import { filesApi } from '../../../../api/client' +import { fileRepo } from '../../../../repo/fileRepo' import { openFile } from '../../../../utils/fileDownload' import { isMedia, formatSize } from '../../../../components/Files/FileManager.helpers' import type { TripFile } from '../../../../types' @@ -27,9 +27,8 @@ import { * (spec 03 §5.2). Upload and Trash are triggered from the shell's header * (shell.uploadFilesSignal / shell.openFilesTrashSignal, watched below, same * pattern as useTodoList.ts's addItemSignal) since those buttons live outside - * this panel. Star/rename-note/link run straight against filesApi (§7.3); - * add/delete run through planner.tripActions (§7.3 — the only two file - * mutations the store owns). + * this panel. Star/rename-note/link run through fileRepo, so offline they + * wait in the queue; add/delete run through planner.tripActions (§7.3). */ export default function MFilesTab({ planner, shell }: MTabScreenProps) { const { t } = planner @@ -117,10 +116,10 @@ export default function MFilesTab({ planner, shell }: MTabScreenProps) { lastDocSyncSignal.current = shell.openDocSyncSignal }, [shell.openDocSyncSignal]) - // ── Star toggle (ungated, §7.6) — direct filesApi call + store refresh, same as §7.3. ── + // ── Star toggle (ungated, §7.6): through fileRepo, then a store refresh. ── const toggleStar = async (file: TripFile) => { try { - await filesApi.toggleStar(planner.tripId, file.id) + await fileRepo.toggleStar(planner.tripId, file.id) planner.tripActions.loadFiles(planner.tripId) } catch { planner.toast.error(t('files.toast.assignError')) diff --git a/client/src/pages/tripPlanner/usePlaceEdits.test.ts b/client/src/pages/tripPlanner/usePlaceEdits.test.ts index 62db80d2b..6c91fbc9d 100644 --- a/client/src/pages/tripPlanner/usePlaceEdits.test.ts +++ b/client/src/pages/tripPlanner/usePlaceEdits.test.ts @@ -1,4 +1,4 @@ -// FE-TP-PLACEEDITS-001 to FE-TP-PLACEEDITS-016 +// FE-TP-PLACEEDITS-001 to FE-TP-PLACEEDITS-017 // // Writes to the trip's places, driven straight through usePlaceEdits. The store holds // the places and day lists the delete and category handlers capture for their undo, @@ -460,4 +460,25 @@ describe('usePlaceEdits', () => { expect(fixed.pushUndo).toHaveBeenCalledWith('undo.changeCategory', expect.any(Function), undefined, [1, 2]) }) + + it('FE-TP-PLACEEDITS-017: offline, a save with new files is refused before anything is written', async () => { + Object.defineProperty(navigator, 'onLine', { value: false, writable: true, configurable: true }) + try { + const { result } = renderEdits({ editingPlace: lake }) + let refused: unknown + await act(async () => { + await result.current.handleSavePlace({ name: 'Lake', _pendingFiles: [new File(['x'], 'a.pdf')] }).catch((err: unknown) => { refused = err }) + }) + + expect(refused).toEqual(new Error('files.placeFilesNeedConnection')) + expect(actions.updatePlace).not.toHaveBeenCalled() + expect(actions.addFile).not.toHaveBeenCalled() + + // Without new files the place saves offline as before. + await act(async () => { await result.current.handleSavePlace({ name: 'Lake' }) }) + expect(actions.updatePlace).toHaveBeenCalledWith(42, 1, { name: 'Lake' }) + } finally { + Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true }) + } + }) }) diff --git a/client/src/pages/tripPlanner/usePlaceEdits.ts b/client/src/pages/tripPlanner/usePlaceEdits.ts index a0ad1167a..8e6278d39 100644 --- a/client/src/pages/tripPlanner/usePlaceEdits.ts +++ b/client/src/pages/tripPlanner/usePlaceEdits.ts @@ -7,6 +7,7 @@ import type { useTourPlaceIds } from '../../hooks/useTourPlaceIds' import { translateApiError } from '../../i18n' import { useAuthStore } from '../../store/authStore' import { useTripStore, type TripStoreState } from '../../store/tripStore' +import { isEffectivelyOffline } from '../../sync/networkMode' import type { Accommodation, Place } from '../../types' import { capturePlaceVisits, deleteLosesDependents, restorePlace } from './placeUndo' import type { PlannerBase, PlannerHistory } from './plannerTypes' @@ -91,6 +92,9 @@ export function usePlaceEdits(options: PlaceEditsOptions) { const handleSavePlace = useCallback(async (data) => { const pendingFiles = data._pendingFiles + // A file cannot wait in the offline queue the way the place can, so the form + // stays open with them instead of saving a place that loses them. + if (pendingFiles?.length > 0 && isEffectivelyOffline()) throw new Error(t('files.placeFilesNeedConnection')) delete data._pendingFiles // Where a service stop added by hand belongs on the drive. The form worked it out // from the coordinates being saved, because until a place was chosen in it there diff --git a/client/src/repo/fileRepo.offline.test.ts b/client/src/repo/fileRepo.offline.test.ts new file mode 100644 index 000000000..9506233d1 --- /dev/null +++ b/client/src/repo/fileRepo.offline.test.ts @@ -0,0 +1,162 @@ +// FE-REPO-FILE-OFF-001 to FE-REPO-FILE-OFF-008 +// Offline write paths (#2601), fileRepo.test.ts covers the reads. +import 'fake-indexeddb/auto'; +import { http, HttpResponse } from 'msw'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildTripFile } from '../../tests/helpers/factories'; +import { server } from '../../tests/helpers/msw/server'; +import { filesApi } from '../api/client'; +import type { QueuedMutation } from '../db/offlineDb'; +import { clearAll, offlineDb } from '../db/offlineDb'; +import { setAuthed } from '../sync/authGate'; +import { mutationQueue } from '../sync/mutationQueue'; +import { fileRepo, NEEDS_CONNECTION } from './fileRepo'; + +function setOnline(v: boolean): void { + Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true }); +} + +async function queue(): Promise { + return (await offlineDb.mutationQueue.toArray()).sort((a, b) => a.createdAt - b.createdAt); +} + +beforeEach(async () => { + await clearAll(); + mutationQueue._resetFlushing(); + setOnline(false); +}); + +afterEach(() => { + vi.restoreAllMocks(); + setAuthed(false); + setOnline(true); +}); + +describe('fileRepo — offline', () => { + it('FE-REPO-FILE-OFF-001: a note and a place set offline land on the local file and wait in the queue', async () => { + await offlineDb.tripFiles.put( + buildTripFile({ id: 12, trip_id: 4, description: null, place_id: null, linked_place_ids: [7] }) + ); + + const { file } = await fileRepo.update(4, 12, { description: 'Gate B', place_id: 5 }); + + expect(file).toMatchObject({ id: 12, description: 'Gate B', place_id: 5, linked_place_ids: [7] }); + expect(await offlineDb.tripFiles.get(12)).toMatchObject({ description: 'Gate B', place_id: 5 }); + const [mutation] = await queue(); + expect(mutation).toMatchObject({ + method: 'PUT', + url: '/trips/4/files/12', + body: { description: 'Gate B', place_id: 5 }, + resource: 'tripFiles', + entityId: 12, + }); + }); + + it('FE-REPO-FILE-OFF-002: each star toggle flips the local star and queues one PATCH', async () => { + await offlineDb.tripFiles.put(buildTripFile({ id: 12, trip_id: 4, starred: 0 })); + + expect((await fileRepo.toggleStar(4, 12)).file.starred).toBe(1); + expect((await fileRepo.toggleStar(4, 12)).file.starred).toBe(0); + + const rows = await queue(); + expect(rows.map((m) => [m.method, m.url])).toEqual([ + ['PATCH', '/trips/4/files/12/star'], + ['PATCH', '/trips/4/files/12/star'], + ]); + }); + + it('FE-REPO-FILE-OFF-003: a file moved to the trash leaves the local list and its DELETE waits', async () => { + await offlineDb.tripFiles.bulkPut([buildTripFile({ id: 12, trip_id: 4 }), buildTripFile({ id: 13, trip_id: 4 })]); + + await fileRepo.delete(4, 12); + + expect((await fileRepo.list(4)).files.map((f) => f.id)).toEqual([13]); + const [mutation] = await queue(); + expect(mutation).toMatchObject({ method: 'DELETE', url: '/trips/4/files/12', resource: 'tripFiles', entityId: 12 }); + }); + + it('FE-REPO-FILE-OFF-004: a further link shows on the local file at once and its POST waits', async () => { + await offlineDb.tripFiles.put( + buildTripFile({ + id: 12, + trip_id: 4, + place_id: 1, + reservation_id: 2, + linked_place_ids: [], + linked_reservation_ids: [], + }) + ); + + await fileRepo.addLink(4, 12, { place_id: 5 }); + await fileRepo.addLink(4, 12, { reservation_id: 9 }); + + expect(await offlineDb.tripFiles.get(12)).toMatchObject({ linked_place_ids: [5], linked_reservation_ids: [9] }); + const rows = await queue(); + expect(rows.map((m) => [m.method, m.url, m.body])).toEqual([ + ['POST', '/trips/4/files/12/link', { place_id: 5 }], + ['POST', '/trips/4/files/12/link', { reservation_id: 9 }], + ]); + }); + + it('FE-REPO-FILE-OFF-005: restoring, deleting for good and emptying the trash wait in the queue in order', async () => { + const trashed = buildTripFile({ id: 20, trip_id: 4, deleted_at: '2026-10-01 10:00:00' }); + + await fileRepo.restore(4, 20, trashed); + await fileRepo.permanentDelete(4, 21); + await fileRepo.emptyTrash(4); + + expect(await offlineDb.tripFiles.get(20)).toMatchObject({ id: 20, deleted_at: null }); + const rows = await queue(); + expect(rows.map((m) => [m.method, m.url, m.resource])).toEqual([ + ['POST', '/trips/4/files/20/restore', 'tripFiles'], + ['DELETE', '/trips/4/files/21/permanent', 'tripFiles'], + ['DELETE', '/trips/4/files/trash/empty', undefined], + ]); + }); + + it('FE-REPO-FILE-OFF-006: an upload and removing a further link say they need a connection and queue nothing', async () => { + const upload = vi.spyOn(filesApi, 'upload'); + const getLinks = vi.spyOn(filesApi, 'getLinks'); + + await expect(fileRepo.upload(4, new FormData())).rejects.toThrow(NEEDS_CONNECTION.uploadKey); + await expect(fileRepo.removeLinkTo(4, 12, () => true)).rejects.toThrow(NEEDS_CONNECTION.linkKey); + + expect(upload).not.toHaveBeenCalled(); + expect(getLinks).not.toHaveBeenCalled(); + expect(await offlineDb.mutationQueue.count()).toBe(0); + }); +}); + +describe('fileRepo — online', () => { + it('FE-REPO-FILE-OFF-007: an edit of a file with an older queued write joins the queue behind it', async () => { + await offlineDb.tripFiles.put(buildTripFile({ id: 12, trip_id: 4, starred: 0 })); + await fileRepo.toggleStar(4, 12); + const update = vi.spyOn(filesApi, 'update'); + // Online now, but the flush that sendSoon starts must not run in this test. + setOnline(true); + vi.spyOn(mutationQueue, 'flush').mockResolvedValue(undefined); + + await fileRepo.update(4, 12, { description: 'Later' }); + + expect(update).not.toHaveBeenCalled(); + const rows = await queue(); + expect(rows.map((m) => m.method)).toEqual(['PATCH', 'PUT']); + }); + + it('FE-REPO-FILE-OFF-008: online writes go straight out and keep the link ids the answer leaves out', async () => { + setOnline(true); + await offlineDb.tripFiles.put(buildTripFile({ id: 12, trip_id: 4, linked_place_ids: [7], description: null })); + server.use( + http.put('/api/trips/4/files/12', () => + HttpResponse.json({ + file: { ...buildTripFile({ id: 12, trip_id: 4, description: 'Seen' }), linked_place_ids: undefined }, + }) + ) + ); + + await fileRepo.update(4, 12, { description: 'Seen' }); + + expect(await offlineDb.tripFiles.get(12)).toMatchObject({ description: 'Seen', linked_place_ids: [7] }); + expect(await offlineDb.mutationQueue.count()).toBe(0); + }); +}); diff --git a/client/src/repo/fileRepo.ts b/client/src/repo/fileRepo.ts index 3e6f43fc7..a07592ffd 100644 --- a/client/src/repo/fileRepo.ts +++ b/client/src/repo/fileRepo.ts @@ -1,8 +1,73 @@ import { filesApi } from '../api/client' import { offlineDb, replaceTripRows } from '../db/offlineDb' +import { mutationQueue, generateUUID } from '../sync/mutationQueue' +import { isEffectivelyOffline } from '../sync/networkMode' import { onlineThenCache } from './withOfflineFallback' import type { TripFile } from '../types' +import type { FileLink, FileLinkRequest, FileUpdateRequest } from '@trek/shared' +/** + * What the file writes that need the server throw offline: an upload, and the + * removal of a further link (see removeLinkTo). The messages are translation + * keys, so a view that shows the failure through translateApiError says why. + */ +export const NEEDS_CONNECTION = { + uploadKey: 'files.uploadNeedsConnection', + linkKey: 'files.linkNeedsConnection', +} as const + +/** + * A file the server answered with. The single-file routes answer the stored row + * without the ids of its further links, so the ones the local copy has stay. + */ +async function keepLinks(file: TripFile | undefined): Promise { + if (!file || typeof file.id !== 'number') return + const local = await offlineDb.tripFiles.get(file.id) + await offlineDb.tripFiles.put({ ...local, ...file }) +} + +/** Queue a write to one file, held behind any older write to it. */ +async function enqueueFileWrite( + tripId: number | string, + id: number, + method: 'POST' | 'PUT' | 'PATCH' | 'DELETE', + path: string, + body: unknown, +): Promise { + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method, + url: `/trips/${tripId}/files/${id}${path}`, + body, + resource: 'tripFiles', + entityId: id, + }) + mutationQueue.sendSoon() +} + +/** The local copy of a file with some fields changed, kept in Dexie. */ +async function patchLocal(tripId: number | string, id: number, change: (file: TripFile) => Partial): Promise { + const before = await offlineDb.tripFiles.get(id) + const file = { ...(before ?? {}), ...(before ? change(before) : {}), id, trip_id: Number(tripId) } as TripFile + await offlineDb.tripFiles.put(file) + return file +} + +function asId(value: unknown): number | null { + if (value === null || value === undefined || value === '') return null + const id = Number(value) + return Number.isFinite(id) ? id : null +} + +/** + * Files through the offline core. Reads fall back to the Dexie copy; a change to + * a file made offline (its note, its place or booking, the star, a link, the + * trash) lands there and in the mutation queue and replays once the connection + * is back. File writes carry no version token, so an online write to a file that + * still has an older one queued joins the queue behind it (mustQueue). An upload + * cannot wait in the queue, so offline it is refused with a hint. + */ export const fileRepo = { async list(tripId: number | string): Promise<{ files: TripFile[] }> { return onlineThenCache( @@ -17,4 +82,120 @@ export const fileRepo = { }), ) }, + + /** The trip's trashed files. Only the server has them. */ + async listTrash(tripId: number | string): Promise<{ files?: TripFile[] }> { + return filesApi.list(tripId, true) + }, + + async upload(tripId: number | string, formData: FormData): Promise<{ file: TripFile }> { + if (isEffectivelyOffline()) throw new Error(NEEDS_CONNECTION.uploadKey) + const result: { file: TripFile } = await filesApi.upload(tripId, formData) + await offlineDb.tripFiles.put(result.file) + return result + }, + + async update(tripId: number | string, id: number, data: FileUpdateRequest): Promise<{ file: TripFile }> { + if (!(await mutationQueue.mustQueue('tripFiles', id))) { + const result: { file: TripFile } = await filesApi.update(tripId, id, data) + await keepLinks(result.file) + return result + } + const file = await patchLocal(tripId, id, () => ({ + ...(data.description === undefined ? {} : { description: data.description || null }), + ...(data.place_id === undefined ? {} : { place_id: asId(data.place_id) }), + ...(data.reservation_id === undefined ? {} : { reservation_id: asId(data.reservation_id) }), + })) + await enqueueFileWrite(tripId, id, 'PUT', '', data) + return { file } + }, + + async toggleStar(tripId: number | string, id: number): Promise<{ file: TripFile }> { + if (!(await mutationQueue.mustQueue('tripFiles', id))) { + const result: { file: TripFile } = await filesApi.toggleStar(tripId, id) + await keepLinks(result.file) + return result + } + // The server flips the star, so each queued toggle flips it once more. + const file = await patchLocal(tripId, id, before => ({ starred: before.starred ? 0 : 1 })) + await enqueueFileWrite(tripId, id, 'PATCH', '/star', undefined) + return { file } + }, + + /** Moves a file to the trash. */ + async delete(tripId: number | string, id: number): Promise { + if (!(await mutationQueue.mustQueue('tripFiles', id))) { + await filesApi.delete(tripId, id) + await offlineDb.tripFiles.delete(id) + return + } + await offlineDb.tripFiles.delete(id) + await enqueueFileWrite(tripId, id, 'DELETE', '', undefined) + }, + + /** + * Brings a trashed file back. Offline the trash row the view shows, when it + * has one, is what the list gets until the server answers. + */ + async restore(tripId: number | string, id: number, trashed?: TripFile): Promise { + if (!(await mutationQueue.mustQueue('tripFiles', id))) { + const result: { file: TripFile } = await filesApi.restore(tripId, id) + await keepLinks(result.file) + return + } + if (trashed) await offlineDb.tripFiles.put({ ...trashed, deleted_at: null }) + await enqueueFileWrite(tripId, id, 'POST', '/restore', undefined) + }, + + async permanentDelete(tripId: number | string, id: number): Promise { + if (!(await mutationQueue.mustQueue('tripFiles', id))) { + await filesApi.permanentDelete(tripId, id) + return + } + await enqueueFileWrite(tripId, id, 'DELETE', '/permanent', undefined) + }, + + /** Empties the trash. Offline it waits in the queue behind the files moved there before. */ + async emptyTrash(tripId: number | string): Promise { + if (!isEffectivelyOffline()) { + await filesApi.emptyTrash(tripId) + return + } + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: Number(tripId), + method: 'DELETE', + url: `/trips/${tripId}/files/trash/empty`, + body: undefined, + }) + }, + + /** Links a file to one more place or booking, beside the one in its own column. */ + async addLink(tripId: number | string, id: number, link: FileLinkRequest): Promise { + if (!(await mutationQueue.mustQueue('tripFiles', id))) { + await filesApi.addLink(tripId, id, link) + return + } + const placeId = asId(link.place_id) + const reservationId = asId(link.reservation_id) + await patchLocal(tripId, id, before => ({ + ...(placeId === null ? {} : { linked_place_ids: [...(before.linked_place_ids || []), placeId] }), + ...(reservationId === null ? {} : { linked_reservation_ids: [...(before.linked_reservation_ids || []), reservationId] }), + })) + await enqueueFileWrite(tripId, id, 'POST', '/link', link) + }, + + /** + * Removes the further link `matches` picks. The route names the link row by its + * own id, which only the server knows, so this needs a connection; online it + * first lets the older queued writes to the file through, a link added offline + * among them. + */ + async removeLinkTo(tripId: number | string, id: number, matches: (link: FileLink) => boolean): Promise { + if (isEffectivelyOffline()) throw new Error(NEEDS_CONNECTION.linkKey) + if (await mutationQueue.mustQueue('tripFiles', id)) await mutationQueue.flush() + const { links = [] } = (await filesApi.getLinks(tripId, id)) as { links?: FileLink[] } + const link = links.find(matches) + if (link) await filesApi.removeLink(tripId, id, link.id) + }, } diff --git a/client/src/store/slices/filesSlice.test.ts b/client/src/store/slices/filesSlice.test.ts index 3750d7013..3ffd30b43 100644 --- a/client/src/store/slices/filesSlice.test.ts +++ b/client/src/store/slices/filesSlice.test.ts @@ -1,9 +1,10 @@ -// FE-STORE-FILES-001 to FE-STORE-FILES-007 +// FE-STORE-FILES-001 to FE-STORE-FILES-010 import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildTripFile } from '../../../tests/helpers/factories'; import { useTripStore } from '../tripStore'; +import { clearAll, offlineDb } from '../../db/offlineDb'; /** * unlinkFileFromReservation (#2084): a file can sit on a booking two ways, through @@ -117,3 +118,52 @@ describe('filesSlice.unlinkFileFromReservation', () => { expect(useTripStore.getState().files.map(f => f.id)).toEqual([99]); }); }); + +/** + * Offline (#2601): a file moved to the trash or taken off a booking waits in the + * queue instead of failing, and an upload says it needs a connection. + */ +describe('filesSlice offline', () => { + function setOnline(v: boolean): void { + Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true }); + } + + beforeEach(async () => { + resetAllStores(); + await clearAll(); + setOnline(false); + }); + + afterEach(() => { + setOnline(true); + }); + + it('FE-STORE-FILES-008: a file moved to the trash offline leaves the list and its DELETE waits', async () => { + seedStore(useTripStore, { files: [buildTripFile({ id: 7, trip_id: 1 }), buildTripFile({ id: 8, trip_id: 1 })] }); + + await useTripStore.getState().deleteFile(1, 7); + + expect(useTripStore.getState().files.map((f) => f.id)).toEqual([8]); + const [mutation] = await offlineDb.mutationQueue.toArray(); + expect(mutation).toMatchObject({ method: 'DELETE', url: '/trips/1/files/7' }); + }); + + it('FE-STORE-FILES-009: a file uploaded on the booking comes off it offline without asking for its link rows', async () => { + const file = buildTripFile({ id: 7, trip_id: 1, reservation_id: 9, linked_reservation_ids: [] }); + await offlineDb.tripFiles.put(file); + + await useTripStore.getState().unlinkFileFromReservation(1, file, 9); + + const [mutation] = await offlineDb.mutationQueue.toArray(); + expect(mutation).toMatchObject({ method: 'PUT', url: '/trips/1/files/7', body: { reservation_id: null } }); + expect(useTripStore.getState().files).toEqual([expect.objectContaining({ id: 7, reservation_id: null })]); + }); + + it('FE-STORE-FILES-010: an upload offline throws the hint key and keeps the list', async () => { + seedStore(useTripStore, { files: [buildTripFile({ id: 7, trip_id: 1 })] }); + + await expect(useTripStore.getState().addFile(1, new FormData())).rejects.toThrow('files.uploadNeedsConnection'); + + expect(useTripStore.getState().files.map((f) => f.id)).toEqual([7]); + }); +}); diff --git a/client/src/store/slices/filesSlice.ts b/client/src/store/slices/filesSlice.ts index 8df5d4299..43ff703b3 100644 --- a/client/src/store/slices/filesSlice.ts +++ b/client/src/store/slices/filesSlice.ts @@ -1,5 +1,5 @@ -import { filesApi } from '../../api/client' import { fileRepo } from '../../repo/fileRepo' +import { isEffectivelyOffline } from '../../sync/networkMode' import type { StoreApi } from 'zustand' import type { TripStoreState } from '../tripStore' import type { TripFile } from '../../types' @@ -31,7 +31,7 @@ export const createFilesSlice = (set: SetState, get: GetState): FilesSlice => ({ addFile: async (tripId, formData) => { try { - const data = await filesApi.upload(tripId, formData) + const data = await fileRepo.upload(tripId, formData) set(state => ({ files: [data.file, ...state.files] })) return data.file } catch (err: unknown) { @@ -41,7 +41,7 @@ export const createFilesSlice = (set: SetState, get: GetState): FilesSlice => ({ deleteFile: async (tripId, id) => { try { - await filesApi.delete(tripId, id) + await fileRepo.delete(tripId, id) set(state => ({ files: state.files.filter(f => f.id !== id) })) } catch (err: unknown) { throw new Error(getApiErrorMessage(err, 'Error deleting file')) @@ -50,7 +50,7 @@ export const createFilesSlice = (set: SetState, get: GetState): FilesSlice => ({ linkFile: async (tripId, id, link) => { try { - await filesApi.addLink(tripId, id, link) + await fileRepo.addLink(tripId, id, link) } catch (err: unknown) { throw new Error(getApiErrorMessage(err, 'Error linking file')) } @@ -60,10 +60,12 @@ export const createFilesSlice = (set: SetState, get: GetState): FilesSlice => ({ unlinkFileFromReservation: async (tripId, file, reservationId) => { try { // Uploaded on the booking, the file points at it itself; linked later, a link row does. - if (file.reservation_id === reservationId) await filesApi.update(tripId, file.id, { reservation_id: null }) - const { links = [] } = (await filesApi.getLinks(tripId, file.id)) as { links?: { id: number; reservation_id: number | null }[] } - const link = links.find(l => l.reservation_id === reservationId) - if (link) await filesApi.removeLink(tripId, file.id, link.id) + if (file.reservation_id === reservationId) await fileRepo.update(tripId, file.id, { reservation_id: null }) + // A link row is named by its own id, which only the server can look up. Offline + // that is needed only when the list says the file has one for this booking. + if ((file.linked_reservation_ids || []).includes(reservationId) || !isEffectivelyOffline()) { + await fileRepo.removeLinkTo(tripId, file.id, l => l.reservation_id === reservationId) + } } catch (err: unknown) { throw new Error(getApiErrorMessage(err, 'Error unlinking file')) } finally { diff --git a/client/src/sync/mutationQueue.ts b/client/src/sync/mutationQueue.ts index 661fde557..384e81521 100644 --- a/client/src/sync/mutationQueue.ts +++ b/client/src/sync/mutationQueue.ts @@ -15,7 +15,7 @@ import type { QueuedMutation } from '../db/offlineDb' import type { Table } from 'dexie' import { roadtripPreferencesResponseSchema, assignmentSchema, tourCreateResponseSchema, type TourCreateResponse } from '@trek/shared' import { cacheAssignment } from '../db/cacheAssignment' -import type { BudgetItem, BudgetItemMember, Reservation } from '../types' +import type { BudgetItem, BudgetItemMember, Reservation, TripFile } from '../types' // Map Dexie table names used in `resource` field → actual Dexie tables. function getTable(resource: string): Table | undefined { @@ -289,6 +289,18 @@ async function reconcileReservation(mutation: QueuedMutation, data: unknown, idM await offlineDb.reservations.put({ ...local, ...reservation, ...(answer.travelers ? { travelers: answer.travelers } : {}) }) } +/** + * A replayed file write. Only `file` names the file: the link route answers + * `{ success, links }`, and the file routes answer the stored row without the + * ids of its further links, so the ones the local copy holds stay. + */ +async function reconcileTripFile(data: unknown): Promise { + const file = (data && typeof data === 'object' ? data : {}) as { file?: TripFile } + if (!file.file || typeof file.file.id !== 'number') return + const local = await offlineDb.tripFiles.get(file.file.id) + await offlineDb.tripFiles.put({ ...local, ...file.file }) +} + /** * A replayed tour write. The answer names the tour by its place, so the * offline place and facet move from the temporary id to the real one. The @@ -529,6 +541,8 @@ export const mutationQueue = { await reconcileBudgetItem(mutation, response.data, idMap) } else if (mutation.resource === 'reservations' && mutation.method !== 'DELETE') { await reconcileReservation(mutation, response.data, idMap) + } else if (mutation.resource === 'tripFiles' && mutation.method !== 'DELETE') { + await reconcileTripFile(response.data) } else if (mutation.method !== 'DELETE' && mutation.resource) { const table = getTable(mutation.resource) if (table && response.data && typeof response.data === 'object') { diff --git a/client/tests/unit/mobile/trip/MFileLinkSheet.test.tsx b/client/tests/unit/mobile/trip/MFileLinkSheet.test.tsx index 7fbbc3370..f50d50f47 100644 --- a/client/tests/unit/mobile/trip/MFileLinkSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MFileLinkSheet.test.tsx @@ -81,7 +81,7 @@ describe('MFileLinkSheet', () => { it('FE-MOB-FLINK-005: the first place link goes into place_id', async () => { const { planner } = renderSheet() fireEvent.click(screen.getByRole('button', { name: 'Fushimi Inari' })) - expect(filesApi.update).toHaveBeenCalledWith(1, 7, { place_id: 11 }) + await waitFor(() => expect(filesApi.update).toHaveBeenCalledWith(1, 7, { place_id: 11 })) await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1)) expect(filesApi.addLink).not.toHaveBeenCalled() }) @@ -118,7 +118,7 @@ describe('MFileLinkSheet', () => { it('FE-MOB-FLINK-010: the first reservation link goes into reservation_id', async () => { const { planner } = renderSheet() fireEvent.click(screen.getByRole('button', { name: 'Hotel Granvia' })) - expect(filesApi.update).toHaveBeenCalledWith(1, 7, { reservation_id: 21 }) + await waitFor(() => expect(filesApi.update).toHaveBeenCalledWith(1, 7, { reservation_id: 21 })) await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1)) }) diff --git a/client/tests/unit/mobile/trip/MFileMenuSheet.test.tsx b/client/tests/unit/mobile/trip/MFileMenuSheet.test.tsx index 714194ff6..32d259e17 100644 --- a/client/tests/unit/mobile/trip/MFileMenuSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MFileMenuSheet.test.tsx @@ -65,17 +65,17 @@ describe('MFileMenuSheet', () => { const input = screen.getByPlaceholderText('files.notePlaceholder') fireEvent.change(input, { target: { value: ' Seat 22C ' } }) fireEvent.blur(input) - expect(filesApi.update).toHaveBeenCalledWith(1, 7, { description: 'Seat 22C' }) + await waitFor(() => expect(filesApi.update).toHaveBeenCalledWith(1, 7, { description: 'Seat 22C' })) await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1)) }) - it('FE-MOB-FMENU-005: Enter commits the note by blurring the field', () => { + it('FE-MOB-FMENU-005: Enter commits the note by blurring the field', async () => { renderSheet() const input = screen.getByPlaceholderText('files.notePlaceholder') input.focus() fireEvent.change(input, { target: { value: 'Gate B' } }) fireEvent.keyDown(input, { key: 'Enter' }) - expect(filesApi.update).toHaveBeenCalledWith(1, 7, { description: 'Gate B' }) + await waitFor(() => expect(filesApi.update).toHaveBeenCalledWith(1, 7, { description: 'Gate B' })) }) it('FE-MOB-FMENU-006: an unchanged note is not sent', () => { diff --git a/client/tests/unit/mobile/trip/MFileTrashSheet.test.tsx b/client/tests/unit/mobile/trip/MFileTrashSheet.test.tsx index 903ff5c50..8c418e49c 100644 --- a/client/tests/unit/mobile/trip/MFileTrashSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MFileTrashSheet.test.tsx @@ -88,7 +88,7 @@ describe('MFileTrashSheet', () => { const { planner } = renderSheet() await settled() fireEvent.click(screen.getAllByRole('button', { name: 'files.restore' })[0]) - expect(filesApi.restore).toHaveBeenCalledWith(1, 31) + await waitFor(() => expect(filesApi.restore).toHaveBeenCalledWith(1, 31)) await waitFor(() => expect(screen.queryByText('old.pdf')).not.toBeInTheDocument()) expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1) expect(planner.toast.success).toHaveBeenCalledWith('files.toast.restored') @@ -112,7 +112,7 @@ describe('MFileTrashSheet', () => { const confirm = screen.getByRole('dialog', { name: 'common.delete' }) expect(within(confirm).getByText('files.confirm.permanentDelete')).toBeInTheDocument() fireEvent.click(within(confirm).getByRole('button', { name: 'common.delete' })) - expect(filesApi.permanentDelete).toHaveBeenCalledWith(1, 31) + await waitFor(() => expect(filesApi.permanentDelete).toHaveBeenCalledWith(1, 31)) await waitFor(() => expect(screen.queryByText('old.pdf')).not.toBeInTheDocument()) expect(planner.toast.success).toHaveBeenCalledWith('files.toast.deleted') expect(planner.tripActions.loadFiles).not.toHaveBeenCalled() diff --git a/client/tests/unit/mobile/trip/MFilesTab.test.tsx b/client/tests/unit/mobile/trip/MFilesTab.test.tsx index 1231b5269..ae5dec715 100644 --- a/client/tests/unit/mobile/trip/MFilesTab.test.tsx +++ b/client/tests/unit/mobile/trip/MFilesTab.test.tsx @@ -191,7 +191,7 @@ describe('MFilesTab', () => { const { planner } = renderTab() expect(screen.getByRole('button', { name: 'files.unstar' })).toBeInTheDocument() fireEvent.click(screen.getAllByRole('button', { name: 'files.star' })[0]) - expect(filesApi.toggleStar).toHaveBeenCalledWith(1, 1) + await waitFor(() => expect(filesApi.toggleStar).toHaveBeenCalledWith(1, 1)) await waitFor(() => expect(planner.tripActions.loadFiles).toHaveBeenCalledWith(1)) }) diff --git a/client/tests/unit/sync/mutationQueue.files.test.ts b/client/tests/unit/sync/mutationQueue.files.test.ts new file mode 100644 index 000000000..cc0a1c48f --- /dev/null +++ b/client/tests/unit/sync/mutationQueue.files.test.ts @@ -0,0 +1,100 @@ +/** + * mutationQueue replay of offline file writes (#2601): the file answers carry no + * ids of further links, so a replayed edit or star keeps the ones the local copy + * holds, the link answer ({ success, links }) writes no row, and a queued trash + * move takes the file off the local list for good. + */ +import 'fake-indexeddb/auto'; +import { http, HttpResponse } from 'msw'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { clearAll, offlineDb } from '../../../src/db/offlineDb'; +import { fileRepo } from '../../../src/repo/fileRepo'; +import { setAuthed } from '../../../src/sync/authGate'; +import { mutationQueue } from '../../../src/sync/mutationQueue'; +import { buildTripFile } from '../../helpers/factories'; +import { server } from '../../helpers/msw/server'; + +function setOnline(v: boolean): void { + Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true }); +} + +beforeEach(async () => { + await clearAll(); + mutationQueue._resetFlushing(); + setAuthed(true); + setOnline(false); +}); + +afterEach(() => { + vi.restoreAllMocks(); + setAuthed(false); + setOnline(true); +}); + +describe('mutationQueue.flush — files', () => { + it('FE-SYNC-FILES-001: a replayed note, star and link keep the further links and send in order', async () => { + await offlineDb.tripFiles.put( + buildTripFile({ + id: 12, + trip_id: 4, + description: null, + starred: 0, + linked_place_ids: [7], + linked_reservation_ids: [], + }) + ); + await fileRepo.update(4, 12, { description: 'Gate B' }); + await fileRepo.toggleStar(4, 12); + await fileRepo.addLink(4, 12, { reservation_id: 9 }); + const sent: string[] = []; + // The single-file routes answer the stored row without the link ids. + const row = (fields: object) => ({ file: { ...buildTripFile({ id: 12, trip_id: 4 }), ...fields } }); + server.use( + http.put('/api/trips/4/files/12', () => { + sent.push('PUT'); + return HttpResponse.json(row({ description: 'Gate B', starred: 0 })); + }), + http.patch('/api/trips/4/files/12/star', () => { + sent.push('PATCH'); + return HttpResponse.json(row({ description: 'Gate B', starred: 1 })); + }), + http.post('/api/trips/4/files/12/link', () => { + sent.push('POST'); + return HttpResponse.json({ success: true, links: [{ id: 3, file_id: 12, reservation_id: 9 }] }); + }) + ); + setOnline(true); + + await mutationQueue.flush(); + + expect(sent).toEqual(['PUT', 'PATCH', 'POST']); + expect(await offlineDb.mutationQueue.count()).toBe(0); + expect(await offlineDb.tripFiles.get(12)).toMatchObject({ + description: 'Gate B', + starred: 1, + linked_place_ids: [7], + linked_reservation_ids: [9], + }); + // The link answer named no file, so no other row appeared. + expect(await offlineDb.tripFiles.count()).toBe(1); + }); + + it('FE-SYNC-FILES-002: a replayed trash move and a restore leave the local list as the server has it', async () => { + await offlineDb.tripFiles.put(buildTripFile({ id: 12, trip_id: 4 })); + await fileRepo.delete(4, 12); + await fileRepo.restore(4, 20, buildTripFile({ id: 20, trip_id: 4, deleted_at: '2026-10-01 10:00:00' })); + server.use( + http.delete('/api/trips/4/files/12', () => HttpResponse.json({ success: true })), + http.post('/api/trips/4/files/20/restore', () => + HttpResponse.json({ file: buildTripFile({ id: 20, trip_id: 4, deleted_at: null, original_name: 'back.pdf' }) }) + ) + ); + setOnline(true); + + await mutationQueue.flush(); + + expect(await offlineDb.mutationQueue.count()).toBe(0); + expect(await offlineDb.tripFiles.get(12)).toBeUndefined(); + expect(await offlineDb.tripFiles.get(20)).toMatchObject({ original_name: 'back.pdf', deleted_at: null }); + }); +}); diff --git a/shared/src/i18n/ar/files.ts b/shared/src/i18n/ar/files.ts index d87107c88..e1335e0c6 100644 --- a/shared/src/i18n/ar/files.ts +++ b/shared/src/i18n/ar/files.ts @@ -76,5 +76,9 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'حذف جميع ملفات سلة المهملات نهائيًا؟ لا يمكن التراجع عن ذلك.', 'files.noteLabel': 'ملاحظة', 'files.notePlaceholder': 'أضف ملاحظة...', + 'files.uploadNeedsConnection': 'يحتاج الرفع إلى اتصال. حاول مرة أخرى عندما يعود TREK إلى الاتصال.', + 'files.linkNeedsConnection': 'تحتاج إزالة هذا الربط إلى اتصال. حاول مرة أخرى عندما يعود TREK إلى الاتصال.', + 'files.placeFilesNeedConnection': + 'تحتاج الملفات إلى اتصال لرفعها. أزل الملفات الجديدة لحفظ المكان دون اتصال، أو احفظ عند عودتك إلى الاتصال.', }; export default files; diff --git a/shared/src/i18n/az/files.ts b/shared/src/i18n/az/files.ts index 8a4c8bc57..1433ce799 100644 --- a/shared/src/i18n/az/files.ts +++ b/shared/src/i18n/az/files.ts @@ -76,6 +76,11 @@ const files: TranslationStrings = { 'files.noteLabel': 'Qeyd', 'files.notePlaceholder': 'Qeyd əlavə edin...', + 'files.uploadNeedsConnection': 'Yükləmək üçün bağlantı lazımdır. TREK yenidən onlayn olduqda bir daha cəhd edin.', + 'files.linkNeedsConnection': + 'Bu əlaqəni silmək üçün bağlantı lazımdır. TREK yenidən onlayn olduqda bir daha cəhd edin.', + 'files.placeFilesNeedConnection': + 'Faylları yükləmək üçün bağlantı lazımdır. Məkanı oflayn saxlamaq üçün yeni faylları silin və ya yenidən onlayn olduqda saxlayın.', }; export default files; diff --git a/shared/src/i18n/br/files.ts b/shared/src/i18n/br/files.ts index 0215b0bf8..3b1296c6b 100644 --- a/shared/src/i18n/br/files.ts +++ b/shared/src/i18n/br/files.ts @@ -64,5 +64,10 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Excluir permanentemente todos os arquivos na lixeira? Não é possível desfazer.', 'files.noteLabel': 'Nota', 'files.notePlaceholder': 'Adicione uma nota...', + 'files.uploadNeedsConnection': 'O envio precisa de conexão. Tente de novo quando o TREK voltar a ficar online.', + 'files.linkNeedsConnection': + 'Remover este vínculo precisa de conexão. Tente de novo quando o TREK voltar a ficar online.', + 'files.placeFilesNeedConnection': + 'Os arquivos precisam de conexão para serem enviados. Remova os novos para salvar o lugar offline ou salve quando estiver online de novo.', }; export default files; diff --git a/shared/src/i18n/ca/files.ts b/shared/src/i18n/ca/files.ts index d407b3463..43a797451 100644 --- a/shared/src/i18n/ca/files.ts +++ b/shared/src/i18n/ca/files.ts @@ -65,5 +65,10 @@ const files: TranslationStrings = { 'files.linkEmpty': 'Encara no hi ha llocs ni reserves per enllaçar', 'files.menu': 'Més opcions', 'files.uploadErrorSize': 'El fitxer és massa gran (màx. {max} MB)', + 'files.uploadNeedsConnection': 'Per pujar cal connexió. Torna-ho a provar quan TREK torni a estar en línia.', + 'files.linkNeedsConnection': + 'Per treure aquest enllaç cal connexió. Torna-ho a provar quan TREK torni a estar en línia.', + 'files.placeFilesNeedConnection': + 'Els fitxers necessiten connexió per pujar-se. Treu els nous per desar el lloc sense connexió, o desa quan tornis a estar en línia.', }; export default files; diff --git a/shared/src/i18n/cs/files.ts b/shared/src/i18n/cs/files.ts index edf67cd35..ad1c88921 100644 --- a/shared/src/i18n/cs/files.ts +++ b/shared/src/i18n/cs/files.ts @@ -67,5 +67,9 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Trvale smazat všechny soubory v koši? Tuto akci nelze vrátit.', 'files.noteLabel': 'Poznámka', 'files.notePlaceholder': 'Přidat poznámku...', + 'files.uploadNeedsConnection': 'Nahrávání vyžaduje připojení. Zkus to znovu, až bude TREK zase online.', + 'files.linkNeedsConnection': 'Odebrání tohoto propojení vyžaduje připojení. Zkus to znovu, až bude TREK zase online.', + 'files.placeFilesNeedConnection': + 'Soubory potřebují k nahrání připojení. Odeber nové, abys místo uložil offline, nebo ulož, až budeš zase online.', }; export default files; diff --git a/shared/src/i18n/de/files.ts b/shared/src/i18n/de/files.ts index feb35ce58..72c617226 100644 --- a/shared/src/i18n/de/files.ts +++ b/shared/src/i18n/de/files.ts @@ -64,5 +64,11 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Alle Dateien im Papierkorb endgültig löschen? Das kann nicht rückgängig gemacht werden.', 'files.noteLabel': 'Notiz', 'files.notePlaceholder': 'Notiz hinzufügen...', + 'files.uploadNeedsConnection': + 'Zum Hochladen braucht es eine Verbindung. Versuch es noch einmal, sobald TREK wieder online ist.', + 'files.linkNeedsConnection': + 'Diese Verknüpfung lässt sich nur mit Verbindung entfernen. Versuch es noch einmal, sobald TREK wieder online ist.', + 'files.placeFilesNeedConnection': + 'Dateien brauchen zum Hochladen eine Verbindung. Entferne die neuen, um den Ort offline zu speichern, oder speichere, sobald du wieder online bist.', }; export default files; diff --git a/shared/src/i18n/en/files.ts b/shared/src/i18n/en/files.ts index 23256e1af..503206901 100644 --- a/shared/src/i18n/en/files.ts +++ b/shared/src/i18n/en/files.ts @@ -64,5 +64,9 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Permanently delete all trashed files? This cannot be undone.', 'files.noteLabel': 'Note', 'files.notePlaceholder': 'Add a note...', + 'files.uploadNeedsConnection': 'Uploading needs a connection. Try again once TREK is back online.', + 'files.linkNeedsConnection': 'Removing this link needs a connection. Try again once TREK is back online.', + 'files.placeFilesNeedConnection': + 'Files need a connection to upload. Remove the new ones to save the place offline, or save once you are back online.', }; export default files; diff --git a/shared/src/i18n/es/files.ts b/shared/src/i18n/es/files.ts index 06c29531b..687750f5b 100644 --- a/shared/src/i18n/es/files.ts +++ b/shared/src/i18n/es/files.ts @@ -64,5 +64,11 @@ const files: TranslationStrings = { 'files.toast.restoreError': 'Error al restaurar', 'files.confirm.permanentDelete': 'Eliminar este archivo permanentemente? No se puede deshacer.', 'files.confirm.emptyTrash': 'Eliminar todos los archivos de la papelera? No se puede deshacer.', + 'files.uploadNeedsConnection': + 'Para subir hace falta conexión. Vuelve a intentarlo cuando TREK esté de nuevo en línea.', + 'files.linkNeedsConnection': + 'Para quitar este vínculo hace falta conexión. Vuelve a intentarlo cuando TREK esté de nuevo en línea.', + 'files.placeFilesNeedConnection': + 'Los archivos necesitan conexión para subirse. Quita los nuevos para guardar el lugar sin conexión, o guarda cuando vuelvas a estar en línea.', }; export default files; diff --git a/shared/src/i18n/et/files.ts b/shared/src/i18n/et/files.ts index 32ee06436..bd488738d 100644 --- a/shared/src/i18n/et/files.ts +++ b/shared/src/i18n/et/files.ts @@ -64,5 +64,9 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Kas kustutada kõik prügikasti failid jäädavalt? Seda ei saa tagasi võtta.', 'files.noteLabel': 'Märkus', 'files.notePlaceholder': 'Lisa märkus...', + 'files.uploadNeedsConnection': 'Üleslaadimiseks on vaja ühendust. Proovi uuesti, kui TREK on taas võrgus.', + 'files.linkNeedsConnection': 'Selle lingi eemaldamiseks on vaja ühendust. Proovi uuesti, kui TREK on taas võrgus.', + 'files.placeFilesNeedConnection': + 'Failide üleslaadimiseks on vaja ühendust. Eemalda uued, et koht võrguta salvestada, või salvesta, kui oled taas võrgus.', }; export default files; diff --git a/shared/src/i18n/fr/files.ts b/shared/src/i18n/fr/files.ts index d14de2e11..1a5138479 100644 --- a/shared/src/i18n/fr/files.ts +++ b/shared/src/i18n/fr/files.ts @@ -65,5 +65,9 @@ const files: TranslationStrings = { 'Supprimer définitivement tous les fichiers de la corbeille ? Cette action est irréversible.', 'files.noteLabel': 'Note', 'files.notePlaceholder': 'Ajouter une note…', + 'files.uploadNeedsConnection': 'L’envoi nécessite une connexion. Réessaie quand TREK sera de nouveau en ligne.', + 'files.linkNeedsConnection': 'Retirer ce lien nécessite une connexion. Réessaie quand TREK sera de nouveau en ligne.', + 'files.placeFilesNeedConnection': + 'Les fichiers ont besoin d’une connexion pour être envoyés. Retire les nouveaux pour enregistrer le lieu hors ligne, ou enregistre une fois de nouveau en ligne.', }; export default files; diff --git a/shared/src/i18n/gr/files.ts b/shared/src/i18n/gr/files.ts index d16b97d59..22a8bcf0a 100644 --- a/shared/src/i18n/gr/files.ts +++ b/shared/src/i18n/gr/files.ts @@ -64,5 +64,10 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Οριστική διαγραφή όλων των αρχείων στον κάδο; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.', 'files.noteLabel': 'Σημείωση', 'files.notePlaceholder': 'Προσθήκη σημείωσης...', + 'files.uploadNeedsConnection': 'Η μεταφόρτωση χρειάζεται σύνδεση. Δοκίμασε ξανά μόλις το TREK συνδεθεί ξανά.', + 'files.linkNeedsConnection': + 'Η αφαίρεση αυτής της σύνδεσης χρειάζεται σύνδεση στο διαδίκτυο. Δοκίμασε ξανά μόλις το TREK συνδεθεί ξανά.', + 'files.placeFilesNeedConnection': + 'Τα αρχεία χρειάζονται σύνδεση για να μεταφορτωθούν. Αφαίρεσε τα νέα για να αποθηκεύσεις το μέρος εκτός σύνδεσης ή αποθήκευσε μόλις συνδεθείς ξανά.', }; export default files; diff --git a/shared/src/i18n/hu/files.ts b/shared/src/i18n/hu/files.ts index 0aea88248..c5cf332bc 100644 --- a/shared/src/i18n/hu/files.ts +++ b/shared/src/i18n/hu/files.ts @@ -64,5 +64,9 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Véglegesen törlöd az összes kukába helyezett fájlt? Ez nem vonható vissza.', 'files.noteLabel': 'Megjegyzés', 'files.notePlaceholder': 'Megjegyzés hozzáadása...', + 'files.uploadNeedsConnection': 'A feltöltéshez kapcsolat kell. Próbáld újra, amikor a TREK ismét online.', + 'files.linkNeedsConnection': 'A hivatkozás eltávolításához kapcsolat kell. Próbáld újra, amikor a TREK ismét online.', + 'files.placeFilesNeedConnection': + 'A fájlok feltöltéséhez kapcsolat kell. Távolítsd el az újakat, hogy a helyet offline mentsd, vagy ments, amikor újra online vagy.', }; export default files; diff --git a/shared/src/i18n/id/files.ts b/shared/src/i18n/id/files.ts index 9ed47eb32..29421c585 100644 --- a/shared/src/i18n/id/files.ts +++ b/shared/src/i18n/id/files.ts @@ -61,5 +61,9 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Hapus semua file di sampah secara permanen? Tindakan ini tidak bisa dibatalkan.', 'files.noteLabel': 'Catatan', 'files.notePlaceholder': 'Tambahkan catatan...', + 'files.uploadNeedsConnection': 'Mengunggah butuh koneksi. Coba lagi setelah TREK kembali online.', + 'files.linkNeedsConnection': 'Menghapus tautan ini butuh koneksi. Coba lagi setelah TREK kembali online.', + 'files.placeFilesNeedConnection': + 'File butuh koneksi untuk diunggah. Hapus file baru untuk menyimpan tempat secara offline, atau simpan setelah kamu online lagi.', }; export default files; diff --git a/shared/src/i18n/it/files.ts b/shared/src/i18n/it/files.ts index 6f61afd21..af119421d 100644 --- a/shared/src/i18n/it/files.ts +++ b/shared/src/i18n/it/files.ts @@ -66,5 +66,10 @@ const files: TranslationStrings = { 'Eliminare in modo permanente tutti i file nel cestino? Questa operazione non può essere annullata.', 'files.noteLabel': 'Nota', 'files.notePlaceholder': 'Aggiungi una nota...', + 'files.uploadNeedsConnection': 'Il caricamento richiede una connessione. Riprova quando TREK sarà di nuovo online.', + 'files.linkNeedsConnection': + 'Rimuovere questo collegamento richiede una connessione. Riprova quando TREK sarà di nuovo online.', + 'files.placeFilesNeedConnection': + 'I file richiedono una connessione per essere caricati. Rimuovi quelli nuovi per salvare il luogo offline, oppure salva quando sei di nuovo online.', }; export default files; diff --git a/shared/src/i18n/ja/files.ts b/shared/src/i18n/ja/files.ts index 23bbcd15a..68772954c 100644 --- a/shared/src/i18n/ja/files.ts +++ b/shared/src/i18n/ja/files.ts @@ -61,5 +61,9 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'ゴミ箱内のファイルをすべて完全に削除しますか?元に戻せません。', 'files.noteLabel': 'メモ', 'files.notePlaceholder': 'メモを追加...', + 'files.uploadNeedsConnection': 'アップロードには接続が必要です。TREKがオンラインに戻ったらもう一度お試しください。', + 'files.linkNeedsConnection': 'このリンクの解除には接続が必要です。TREKがオンラインに戻ったらもう一度お試しください。', + 'files.placeFilesNeedConnection': + 'ファイルのアップロードには接続が必要です。新しいファイルを外して場所をオフラインで保存するか、オンラインに戻ってから保存してください。', }; export default files; diff --git a/shared/src/i18n/ko/files.ts b/shared/src/i18n/ko/files.ts index 8de20a72e..85823e8f9 100644 --- a/shared/src/i18n/ko/files.ts +++ b/shared/src/i18n/ko/files.ts @@ -61,5 +61,10 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': '휴지통의 모든 파일을 영구 삭제할까요? 이 작업은 취소할 수 없습니다.', 'files.noteLabel': '메모', 'files.notePlaceholder': '메모 추가...', + 'files.uploadNeedsConnection': '업로드하려면 연결이 필요합니다. TREK이 다시 온라인이 되면 다시 시도하세요.', + 'files.linkNeedsConnection': + '이 연결을 해제하려면 인터넷 연결이 필요합니다. TREK이 다시 온라인이 되면 다시 시도하세요.', + 'files.placeFilesNeedConnection': + '파일을 업로드하려면 연결이 필요합니다. 새 파일을 빼고 장소를 오프라인으로 저장하거나, 다시 온라인이 되면 저장하세요.', }; export default files; diff --git a/shared/src/i18n/nl/files.ts b/shared/src/i18n/nl/files.ts index 41dddf1a2..8f26febec 100644 --- a/shared/src/i18n/nl/files.ts +++ b/shared/src/i18n/nl/files.ts @@ -65,5 +65,10 @@ const files: TranslationStrings = { 'Alle bestanden in de prullenbak permanent verwijderen? Dit kan niet ongedaan worden gemaakt.', 'files.noteLabel': 'Notitie', 'files.notePlaceholder': 'Notitie toevoegen...', + 'files.uploadNeedsConnection': 'Uploaden heeft een verbinding nodig. Probeer het opnieuw zodra TREK weer online is.', + 'files.linkNeedsConnection': + 'Deze koppeling verwijderen heeft een verbinding nodig. Probeer het opnieuw zodra TREK weer online is.', + 'files.placeFilesNeedConnection': + 'Bestanden hebben een verbinding nodig om te uploaden. Verwijder de nieuwe om de plek offline op te slaan, of sla op zodra je weer online bent.', }; export default files; diff --git a/shared/src/i18n/pl/files.ts b/shared/src/i18n/pl/files.ts index 2304221eb..94e45930c 100644 --- a/shared/src/i18n/pl/files.ts +++ b/shared/src/i18n/pl/files.ts @@ -71,5 +71,10 @@ const files: TranslationStrings = { 'Czy na pewno chcesz trwale usunąć wszystkie pliki z kosza? Tej operacji nie można cofnąć.', 'files.noteLabel': 'Notatka', 'files.notePlaceholder': 'Dodaj notatkę...', + 'files.uploadNeedsConnection': 'Przesyłanie wymaga połączenia. Spróbuj ponownie, gdy TREK znów będzie online.', + 'files.linkNeedsConnection': + 'Usunięcie tego powiązania wymaga połączenia. Spróbuj ponownie, gdy TREK znów będzie online.', + 'files.placeFilesNeedConnection': + 'Pliki wymagają połączenia, aby je przesłać. Usuń nowe, aby zapisać miejsce offline, albo zapisz, gdy znów będziesz online.', }; export default files; diff --git a/shared/src/i18n/ru/files.ts b/shared/src/i18n/ru/files.ts index 8efd13eb5..a5c2afc09 100644 --- a/shared/src/i18n/ru/files.ts +++ b/shared/src/i18n/ru/files.ts @@ -70,5 +70,10 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Безвозвратно удалить все файлы из корзины? Это действие нельзя отменить.', 'files.noteLabel': 'Заметка', 'files.notePlaceholder': 'Добавить заметку...', + 'files.uploadNeedsConnection': 'Для загрузки нужно подключение. Попробуйте ещё раз, когда TREK снова будет онлайн.', + 'files.linkNeedsConnection': + 'Чтобы убрать эту привязку, нужно подключение. Попробуйте ещё раз, когда TREK снова будет онлайн.', + 'files.placeFilesNeedConnection': + 'Для загрузки файлов нужно подключение. Уберите новые, чтобы сохранить место офлайн, или сохраните, когда снова будете онлайн.', }; export default files; diff --git a/shared/src/i18n/sk/files.ts b/shared/src/i18n/sk/files.ts index 53153735e..c6ad6b889 100644 --- a/shared/src/i18n/sk/files.ts +++ b/shared/src/i18n/sk/files.ts @@ -67,5 +67,10 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Trvalo zmazať všetky súbory v koši? Túto akciu nie je možné vrátiť.', 'files.noteLabel': 'Poznámka', 'files.notePlaceholder': 'Pridať poznámku...', + 'files.uploadNeedsConnection': 'Nahrávanie potrebuje pripojenie. Skús to znova, keď bude TREK opäť online.', + 'files.linkNeedsConnection': + 'Odstránenie tohto prepojenia potrebuje pripojenie. Skús to znova, keď bude TREK opäť online.', + 'files.placeFilesNeedConnection': + 'Súbory potrebujú na nahranie pripojenie. Odstráň nové, aby si miesto uložil offline, alebo ulož, keď budeš opäť online.', }; export default files; diff --git a/shared/src/i18n/sv/files.ts b/shared/src/i18n/sv/files.ts index 92d3f86b4..037d472d8 100644 --- a/shared/src/i18n/sv/files.ts +++ b/shared/src/i18n/sv/files.ts @@ -64,5 +64,9 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Vill du radera alla filer i papperskorgen permanent? Detta går inte att ångra.', 'files.noteLabel': 'Notering', 'files.notePlaceholder': 'Lägg till en notering...', + 'files.uploadNeedsConnection': 'Uppladdning kräver en anslutning. Försök igen när TREK är online igen.', + 'files.linkNeedsConnection': 'Att ta bort den här länken kräver en anslutning. Försök igen när TREK är online igen.', + 'files.placeFilesNeedConnection': + 'Filer behöver en anslutning för att laddas upp. Ta bort de nya för att spara platsen offline, eller spara när du är online igen.', }; export default files; diff --git a/shared/src/i18n/th/files.ts b/shared/src/i18n/th/files.ts index ffcde09f4..d295ba442 100644 --- a/shared/src/i18n/th/files.ts +++ b/shared/src/i18n/th/files.ts @@ -61,5 +61,9 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'ลบไฟล์ในถังขยะทั้งหมดอย่างถาวรใช่ไหม สิ่งนี้ไม่สามารถยกเลิกได้', 'files.noteLabel': 'หมายเหตุ', 'files.notePlaceholder': 'เพิ่มบันทึก...', + 'files.uploadNeedsConnection': 'การอัปโหลดต้องใช้การเชื่อมต่อ ลองอีกครั้งเมื่อ TREK กลับมาออนไลน์', + 'files.linkNeedsConnection': 'การนำลิงก์นี้ออกต้องใช้การเชื่อมต่อ ลองอีกครั้งเมื่อ TREK กลับมาออนไลน์', + 'files.placeFilesNeedConnection': + 'ไฟล์ต้องใช้การเชื่อมต่อเพื่ออัปโหลด นำไฟล์ใหม่ออกเพื่อบันทึกสถานที่แบบออฟไลน์ หรือบันทึกเมื่อกลับมาออนไลน์', }; export default files; diff --git a/shared/src/i18n/tr/files.ts b/shared/src/i18n/tr/files.ts index 4eb3f63f5..57e5ec26e 100644 --- a/shared/src/i18n/tr/files.ts +++ b/shared/src/i18n/tr/files.ts @@ -64,5 +64,10 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Çöp kutusundaki tüm dosyalar kalıcı olarak silinsin mi? Bu işlem geri alınamaz.', 'files.noteLabel': 'Not', 'files.notePlaceholder': 'Not ekleyin...', + 'files.uploadNeedsConnection': 'Yükleme için bağlantı gerekiyor. TREK yeniden çevrimiçi olduğunda tekrar dene.', + 'files.linkNeedsConnection': + 'Bu bağlantıyı kaldırmak için internet bağlantısı gerekiyor. TREK yeniden çevrimiçi olduğunda tekrar dene.', + 'files.placeFilesNeedConnection': + 'Dosyaları yüklemek için bağlantı gerekiyor. Yeri çevrimdışı kaydetmek için yenilerini kaldır ya da yeniden çevrimiçi olduğunda kaydet.', }; export default files; diff --git a/shared/src/i18n/uk/files.ts b/shared/src/i18n/uk/files.ts index db4cb7bed..d11d26b74 100644 --- a/shared/src/i18n/uk/files.ts +++ b/shared/src/i18n/uk/files.ts @@ -70,5 +70,10 @@ const files: TranslationStrings = { 'files.notePlaceholder': 'Додати нотатку...', 'files.assignTransport': 'Транспорт', 'files.sourceTransport': 'Транспорт', + 'files.uploadNeedsConnection': 'Для завантаження потрібне з’єднання. Спробуйте ще раз, коли TREK знову буде онлайн.', + 'files.linkNeedsConnection': + 'Щоб прибрати цей зв’язок, потрібне з’єднання. Спробуйте ще раз, коли TREK знову буде онлайн.', + 'files.placeFilesNeedConnection': + 'Для завантаження файлів потрібне з’єднання. Приберіть нові, щоб зберегти місце офлайн, або збережіть, коли знову будете онлайн.', }; export default files; diff --git a/shared/src/i18n/vi/files.ts b/shared/src/i18n/vi/files.ts index 2ebcc6904..f4255173c 100644 --- a/shared/src/i18n/vi/files.ts +++ b/shared/src/i18n/vi/files.ts @@ -61,5 +61,9 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': 'Xóa vĩnh viễn tất cả các tập tin vào thùng rác? Điều này không thể hoàn tác được.', 'files.noteLabel': 'Ghi chú', 'files.notePlaceholder': 'Thêm ghi chú...', + 'files.uploadNeedsConnection': 'Tải lên cần có kết nối. Hãy thử lại khi TREK trực tuyến trở lại.', + 'files.linkNeedsConnection': 'Gỡ liên kết này cần có kết nối. Hãy thử lại khi TREK trực tuyến trở lại.', + 'files.placeFilesNeedConnection': + 'Tệp cần có kết nối để tải lên. Hãy bỏ các tệp mới để lưu địa điểm khi ngoại tuyến, hoặc lưu khi bạn trực tuyến trở lại.', }; export default files; diff --git a/shared/src/i18n/zh-TW/files.ts b/shared/src/i18n/zh-TW/files.ts index d1802bf05..788871ca3 100644 --- a/shared/src/i18n/zh-TW/files.ts +++ b/shared/src/i18n/zh-TW/files.ts @@ -61,5 +61,8 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': '永久刪除回收站中的所有檔案?此操作無法撤銷。', 'files.noteLabel': '備註', 'files.notePlaceholder': '新增備註...', + 'files.uploadNeedsConnection': '上傳需要網路連線。請在 TREK 恢復連線後再試一次。', + 'files.linkNeedsConnection': '移除此連結需要網路連線。請在 TREK 恢復連線後再試一次。', + 'files.placeFilesNeedConnection': '檔案需要網路連線才能上傳。移除新檔案即可離線儲存此地點,或在恢復連線後再儲存。', }; export default files; diff --git a/shared/src/i18n/zh/files.ts b/shared/src/i18n/zh/files.ts index 5c1e8e98f..950f9e98c 100644 --- a/shared/src/i18n/zh/files.ts +++ b/shared/src/i18n/zh/files.ts @@ -61,5 +61,8 @@ const files: TranslationStrings = { 'files.confirm.emptyTrash': '永久删除回收站中的所有文件?此操作无法撤销。', 'files.noteLabel': '备注', 'files.notePlaceholder': '添加备注...', + 'files.uploadNeedsConnection': '上传需要网络连接。请在 TREK 恢复在线后重试。', + 'files.linkNeedsConnection': '移除此关联需要网络连接。请在 TREK 恢复在线后重试。', + 'files.placeFilesNeedConnection': '文件需要网络连接才能上传。移除新文件即可离线保存该地点,或在恢复在线后再保存。', }; export default files;