fix(files): file notes, links, stars and the trash changed offline wait in the queue and sync once the connection is back

Every file write went straight to the API, so offline a star snapped back
without a word and moving a file to the trash failed with "Failed to delete
file". Notes, the place or booking of a file, a further link, the star, the
trash move, restore, delete for good and emptying the trash now go through
fileRepo and the mutation queue like the other trip data: the local copy
changes at once, and an online write to a file that still has an older one
queued joins the queue behind it, since file writes carry no version token.
The desktop file manager, its assign dialog and the phone's files tab,
menu, link and trash sheets all write through it.

The single file answers carry no ids of further links, so a replayed edit,
star or restore keeps the ones the local copy holds, and the link answer
({ success, links }) writes no row.

What only works online says so instead of failing: an upload shows that it
needs a connection, a place saved with newly picked files is stopped with a
hint, and removing a further link (its row id is known only to the server)
asks to try again once online.

Refs #2601
This commit is contained in:
Maurice
2026-10-12 19:32:16 +02:00
parent 64ceecefb3
commit 92c48fba22
50 changed files with 763 additions and 59 deletions
-3
View File
@@ -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",
@@ -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 () => {
@@ -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<TripFile> = {}) =>
({ 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> = {}): 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(<AssignModal {...state({ places, files: [file({ place_id: 1 })] })} />)
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(<AssignModal {...state({ places, files: [file({ place_id: 1, linked_place_ids: [2] })] })} />)
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(<AssignModal {...state({ t, places, files: [file({ place_id: 1, linked_place_ids: [2] })] } as Partial<FileManagerState>)} />)
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(<AssignModal {...state({ places, files: [file({ place_id: 1 })] })} />)
const linked = screen.getByRole('button', { name: 'Louvre' })
@@ -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 (
@@ -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();
});
});
+11 -9
View File
@@ -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<void> {
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<void> {
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<typeof filesApi.update>[2],
data: Parameters<typeof fileRepo.update>[2],
{ t, toast, refresh }: FileFeedback & { refresh: () => unknown },
rules: FileUpdateRules = {}
): Promise<void> {
try {
await filesApi.update(tripId, fileId, data);
await fileRepo.update(tripId, fileId, data);
if (rules.awaitRefresh) await refresh();
else void refresh();
} catch {
@@ -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 { /* */ }
}
+11 -7
View File
@@ -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 {
@@ -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)
}
@@ -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)
}
@@ -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'))
@@ -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 })
}
})
})
@@ -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
+162
View File
@@ -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<QueuedMutation[]> {
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);
});
});
+181
View File
@@ -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<void> {
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<void> {
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<TripFile>): Promise<TripFile> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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)
},
}
+51 -1
View File
@@ -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]);
});
});
+10 -8
View File
@@ -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 {
+15 -1
View File
@@ -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<void> {
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') {
@@ -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))
})
@@ -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', () => {
@@ -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()
@@ -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))
})
@@ -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 });
});
});
+4
View File
@@ -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;
+5
View File
@@ -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;
+5
View File
@@ -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;
+5
View File
@@ -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;
+4
View File
@@ -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;
+6
View File
@@ -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;
+4
View File
@@ -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;
+6
View File
@@ -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;
+4
View File
@@ -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;
+4
View File
@@ -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;
+5
View File
@@ -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;
+4
View File
@@ -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;
+4
View File
@@ -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;
+5
View File
@@ -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;
+4
View File
@@ -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;
+5
View File
@@ -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;
+5
View File
@@ -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;
+5
View File
@@ -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;
+5
View File
@@ -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;
+5
View File
@@ -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;
+4
View File
@@ -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;
+4
View File
@@ -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;
+5
View File
@@ -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;
+5
View File
@@ -70,5 +70,10 @@ const files: TranslationStrings = {
'files.notePlaceholder': 'Додати нотатку...',
'files.assignTransport': 'Транспорт',
'files.sourceTransport': 'Транспорт',
'files.uploadNeedsConnection': 'Для завантаження потрібне з’єднання. Спробуйте ще раз, коли TREK знову буде онлайн.',
'files.linkNeedsConnection':
'Щоб прибрати цей зв’язок, потрібне з’єднання. Спробуйте ще раз, коли TREK знову буде онлайн.',
'files.placeFilesNeedConnection':
'Для завантаження файлів потрібне з’єднання. Приберіть нові, щоб зберегти місце офлайн, або збережіть, коли знову будете онлайн.',
};
export default files;
+4
View File
@@ -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;
+3
View File
@@ -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;
+3
View File
@@ -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;