From 973773e0038ebd80673f8f9989e8950c7dbf8827 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 12:09:07 +0200 Subject: [PATCH] refactor(files): share the file filter, link list, note save and trash between desktop and phone fileListRules now holds the type category of a file, the filter tab match and the list of places and bookings a file is linked to (own column plus extra links, transport or booking). useFileManager and FileManagerRow on desktop and filesModel on the phone run on it. fileActions gains the field save with its reload and assign error, which the desktop assign dialog and the phone note edit both use, and the trash call with its toasts, used by the desktop delete and the phone menu sheet. The phone sheet no longer imports filesApi. Differences that stay, each as an explicit option: PHONE_FILE_FILTER_RULES sorts pdf, image and doc by type category (videos count as images, doc is every file that is no PDF and no media, a pass with a PDF mime type is a doc) and counts a note id of 0 as collab, while desktop reads the mime type (images only, doc is Word, Excel and text). DESKTOP_FILE_LINK_LIST_RULES skips an own place or booking column of 0, the phone only a null one. PHONE_FILE_UPDATE_RULES waits for the reload after a save, so a failed reload shows the assign error there; desktop starts it and moves on. DESKTOP_FILE_TRASH_TOAST_RULES falls back to English when the trash toast translation is empty. --- .../src/components/Files/FileManagerRow.tsx | 17 +- .../src/components/Files/fileActions.test.ts | 110 ++++++++++++- client/src/components/Files/fileActions.ts | 62 ++++++- .../components/Files/fileListRules.test.ts | 152 ++++++++++++++++++ client/src/components/Files/fileListRules.ts | 122 ++++++++++++++ client/src/components/Files/useFileManager.ts | 33 +--- .../screens/trip/tabs/MFileLinkSheet.tsx | 10 +- .../screens/trip/tabs/MFileMenuSheet.tsx | 10 +- .../mobile/screens/trip/tabs/filesModel.ts | 64 ++------ .../tests/unit/mobile/trip/filesModel.test.ts | 2 +- 10 files changed, 472 insertions(+), 110 deletions(-) create mode 100644 client/src/components/Files/fileListRules.test.ts create mode 100644 client/src/components/Files/fileListRules.ts diff --git a/client/src/components/Files/FileManagerRow.tsx b/client/src/components/Files/FileManagerRow.tsx index 7f3fb261c..7bbf87f44 100644 --- a/client/src/components/Files/FileManagerRow.tsx +++ b/client/src/components/Files/FileManagerRow.tsx @@ -7,6 +7,7 @@ import { AuthedImg } from './FileManagerAuthedImg' import { AvatarChip } from './FileManagerAvatarChip' import { Tooltip } from '../shared/Tooltip' import { SourceBadge } from './FileManagerSourceBadge' +import { DESKTOP_FILE_LINK_LIST_RULES, linkedFileTargets } from './fileListRules' export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolean }) { const { @@ -14,15 +15,7 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea handleStar, handleRestore, handlePermanentDelete, handleDelete, openFile, setAssignFileId, } = p const FileIcon = getFileIcon(file.mime_type) - const allLinkedPlaceIds = new Set() - if (file.place_id) allLinkedPlaceIds.add(file.place_id) - for (const pid of (file.linked_place_ids || [])) allLinkedPlaceIds.add(pid) - const linkedPlaces = [...allLinkedPlaceIds].map(pid => places?.find(p => p.id === pid)).filter(Boolean) - // All linked reservations (primary + file_links) - const allLinkedResIds = new Set() - if (file.reservation_id) allLinkedResIds.add(file.reservation_id) - for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid) - const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean) + const linked = linkedFileTargets(file, places, reservations, TRANSPORT_TYPES, DESKTOP_FILE_LINK_LIST_RULES) return (
{formatSize(file.file_size)}} {formatDateWithLocale(file.created_at, locale)} - {linkedPlaces.map(p => ( + {linked.places.map(p => ( ))} - {linkedReservations.map(r => ( - TRANSPORT_TYPES.has(r.type) + {linked.reservations.map(({ reservation: r, transport }) => ( + transport ? : ))} diff --git a/client/src/components/Files/fileActions.test.ts b/client/src/components/Files/fileActions.test.ts index 2b3bac87a..3250af448 100644 --- a/client/src/components/Files/fileActions.test.ts +++ b/client/src/components/Files/fileActions.test.ts @@ -1,15 +1,19 @@ -// FE-FILES-ACTIONS-001 to FE-FILES-ACTIONS-012: the paste and link rules the desktop file -// manager and the phone's files tab and link sheet share, with the id reading each keeps. +// FE-FILES-ACTIONS-001 to FE-FILES-ACTIONS-020: 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'; import { buildTripFile } from '../../../tests/helpers/factories'; import { filesApi } from '../../api/client'; import { + DESKTOP_FILE_TRASH_TOAST_RULES, PHONE_FILE_LINK_RULES, + PHONE_FILE_UPDATE_RULES, filesFromClipboard, planFileLinkToggle, runFileLinkRecordStep, toggleFileLink, + trashFileWithToast, + updateFileFields, } from './fileActions'; function clipboard(items: { kind: string; file: File | null }[]): DataTransfer { @@ -138,3 +142,105 @@ describe('running a link toggle', () => { ); }); }); + +function feedback(t: (key: string) => string = (key) => key) { + return { t, toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() } }; +} + +describe('updateFileFields', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('FE-FILES-ACTIONS-013: saves the fields and starts the reload without waiting on desktop', async () => { + vi.spyOn(filesApi, 'update').mockResolvedValue({}); + const fb = feedback(); + let finishReload: () => void = () => {}; + const refresh = vi.fn(() => new Promise((resolve) => (finishReload = resolve))); + await updateFileFields(1, 7, { place_id: 3 }, { ...fb, refresh }); + expect(filesApi.update).toHaveBeenCalledWith(1, 7, { place_id: 3 }); + expect(refresh).toHaveBeenCalledTimes(1); + expect(fb.toast.error).not.toHaveBeenCalled(); + finishReload(); + }); + + it('FE-FILES-ACTIONS-014: desktop resolves before a slow reload is done', async () => { + vi.spyOn(filesApi, 'update').mockResolvedValue({}); + const order: string[] = []; + const refresh = vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + order.push('reloaded'); + }); + const reload = updateFileFields(1, 7, { description: 'x' }, { ...feedback(), refresh }).then(() => + order.push('resolved') + ); + await reload; + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(order).toEqual(['resolved', 'reloaded']); + }); + + it('FE-FILES-ACTIONS-015: the phone waits for the reload before it resolves', async () => { + vi.spyOn(filesApi, 'update').mockResolvedValue({}); + const fb = feedback(); + const order: string[] = []; + const refresh = vi.fn(async () => { + await Promise.resolve(); + order.push('reloaded'); + }); + await updateFileFields(1, 7, { description: 'seat 14A' }, { ...fb, refresh }, PHONE_FILE_UPDATE_RULES); + order.push('resolved'); + expect(order).toEqual(['reloaded', 'resolved']); + expect(filesApi.update).toHaveBeenCalledWith(1, 7, { description: 'seat 14A' }); + }); + + it('FE-FILES-ACTIONS-016: on the phone a failed reload shows the assign error', async () => { + vi.spyOn(filesApi, 'update').mockResolvedValue({}); + const fb = feedback(); + const refresh = vi.fn(() => Promise.reject(new Error('offline'))); + await updateFileFields(1, 7, { description: 'x' }, { ...fb, refresh }, PHONE_FILE_UPDATE_RULES); + expect(fb.toast.error).toHaveBeenCalledWith('files.toast.assignError'); + }); + + it('FE-FILES-ACTIONS-017: a failed save shows the assign error and skips the reload in both views', async () => { + vi.spyOn(filesApi, 'update').mockRejectedValue(new Error('500')); + for (const rules of [undefined, PHONE_FILE_UPDATE_RULES]) { + const fb = feedback(); + const refresh = vi.fn(); + await updateFileFields(1, 7, { reservation_id: null }, { ...fb, refresh }, rules); + expect(refresh).not.toHaveBeenCalled(); + expect(fb.toast.error).toHaveBeenCalledWith('files.toast.assignError'); + } + }); +}); + +describe('trashFileWithToast', () => { + it('FE-FILES-ACTIONS-018: a trashed file says so and then calls onTrashed', async () => { + const fb = feedback(); + const remove = vi.fn().mockResolvedValue(undefined); + const onTrashed = vi.fn(); + await trashFileWithToast(remove, { ...fb, onTrashed }); + expect(remove).toHaveBeenCalledTimes(1); + expect(fb.toast.success).toHaveBeenCalledWith('files.toast.trashed'); + expect(onTrashed).toHaveBeenCalledTimes(1); + expect(fb.toast.error).not.toHaveBeenCalled(); + }); + + it('FE-FILES-ACTIONS-019: an empty translation falls back to English on desktop only', async () => { + const desktop = feedback(() => ''); + await trashFileWithToast(vi.fn().mockResolvedValue(undefined), desktop, DESKTOP_FILE_TRASH_TOAST_RULES); + expect(desktop.toast.success).toHaveBeenCalledWith('Moved to trash'); + + const phone = feedback(() => ''); + await trashFileWithToast(vi.fn().mockResolvedValue(undefined), phone); + expect(phone.toast.success).toHaveBeenCalledWith(''); + }); + + it('FE-FILES-ACTIONS-020: a failed delete shows the delete error and skips onTrashed', async () => { + const fb = feedback(); + const onTrashed = vi.fn(); + await trashFileWithToast(vi.fn().mockRejectedValue(new Error('403')), { ...fb, onTrashed }); + expect(fb.toast.success).not.toHaveBeenCalled(); + expect(onTrashed).not.toHaveBeenCalled(); + expect(fb.toast.error).toHaveBeenCalledWith('files.toast.deleteError'); + }); +}); diff --git a/client/src/components/Files/fileActions.ts b/client/src/components/Files/fileActions.ts index 1705dc03a..7ddf39774 100644 --- a/client/src/components/Files/fileActions.ts +++ b/client/src/components/Files/fileActions.ts @@ -1,5 +1,6 @@ import { filesApi } from '../../api/client'; -import type { TripFile } from '../../types'; +import type { TranslationFn, TripFile } from '../../types'; +import type { useToast } from '../shared/Toast'; /** The files a paste carries, in clipboard order; text and other non-file items are skipped. */ export function filesFromClipboard(clipboardData: DataTransfer | null | undefined): File[] { @@ -106,3 +107,62 @@ export async function toggleFileLink( if (step.kind === 'update') await filesApi.update(tripId, file.id, step.data); else await runFileLinkRecordStep(tripId, file.id, field, targetId, step.kind, rules); } + +interface FileFeedback { + t: TranslationFn; + toast: ReturnType; +} + +/** + * How a field save reloads the files, which differs between the views. The desktop manager + * starts its reload and moves on; the phone's file sheet waits for the reload, so a failed + * reload shows the same error as a failed save and its busy marker stays until it is done. + */ +export interface FileUpdateRules { + awaitRefresh?: boolean; +} + +export const PHONE_FILE_UPDATE_RULES: FileUpdateRules = { awaitRefresh: true }; + +/** Saves fields of a file (its note, its place or booking) and reloads the files; a failure shows the assign error. */ +export async function updateFileFields( + tripId: number, + fileId: number, + data: Parameters[2], + { t, toast, refresh }: FileFeedback & { refresh: () => unknown }, + rules: FileUpdateRules = {} +): Promise { + try { + await filesApi.update(tripId, fileId, data); + if (rules.awaitRefresh) await refresh(); + else void refresh(); + } catch { + toast.error(t('files.toast.assignError')); + } +} + +/** + * The trash toast, which differs between the views: the desktop manager falls back to an + * English text when the translation is empty, the phone shows the translation as it is. + */ +export interface FileTrashToastRules { + trashedFallback?: string; +} + +export const DESKTOP_FILE_TRASH_TOAST_RULES: FileTrashToastRules = { trashedFallback: 'Moved to trash' }; + +/** Moves a file to the trash through the view's delete call and says how it went. */ +export async function trashFileWithToast( + remove: () => Promise, + { t, toast, onTrashed }: FileFeedback & { onTrashed?: () => void }, + rules: FileTrashToastRules = {} +): Promise { + try { + await remove(); + const trashed = t('files.toast.trashed'); + toast.success(rules.trashedFallback ? trashed || rules.trashedFallback : trashed); + onTrashed?.(); + } catch { + toast.error(t('files.toast.deleteError')); + } +} diff --git a/client/src/components/Files/fileListRules.test.ts b/client/src/components/Files/fileListRules.test.ts new file mode 100644 index 000000000..1b4c33f8e --- /dev/null +++ b/client/src/components/Files/fileListRules.test.ts @@ -0,0 +1,152 @@ +// FE-FILES-LIST-001 to FE-FILES-LIST-012: the filter tabs and the link list the desktop file +// manager and the phone's files tab share, with the type and id reading each view keeps. +import { describe, expect, it } from 'vitest'; + +import { buildPlace, buildReservation, buildTripFile } from '../../../tests/helpers/factories'; +import { + DESKTOP_FILE_LINK_LIST_RULES, + PHONE_FILE_FILTER_RULES, + getFileTypeCategory, + linkedFileIds, + linkedFileTargets, + matchesFileFilter, +} from './fileListRules'; + +const TRANSPORT = new Set(['flight', 'train']); + +describe('getFileTypeCategory', () => { + it('FE-FILES-LIST-001: a pass wins over its mime type, video counts as image, sheets are xls', () => { + expect(getFileTypeCategory({ mime_type: 'application/pdf', original_name: 'ticket.pkpass' })).toBe('pass'); + expect(getFileTypeCategory({ mime_type: 'application/pdf', original_name: 'a.pdf' })).toBe('pdf'); + expect(getFileTypeCategory({ mime_type: 'video/mp4', original_name: 'a.mp4' })).toBe('image'); + expect(getFileTypeCategory({ mime_type: 'text/csv', original_name: 'export' })).toBe('xls'); + expect(getFileTypeCategory({ mime_type: 'application/zip', original_name: 'a.zip' })).toBe('other'); + }); +}); + +describe('matchesFileFilter', () => { + const pdf = buildTripFile({ mime_type: 'application/pdf', original_name: 'a.pdf' }); + const passAsPdf = buildTripFile({ mime_type: 'application/pdf', original_name: 'ticket.pkpass' }); + const photo = buildTripFile({ mime_type: 'image/jpeg', original_name: 'a.jpg' }); + const video = buildTripFile({ mime_type: 'video/mp4', original_name: 'a.mp4' }); + const word = buildTripFile({ mime_type: 'application/msword', original_name: 'a.doc' }); + const text = buildTripFile({ mime_type: 'text/plain', original_name: 'a.txt' }); + const zip = buildTripFile({ mime_type: 'application/zip', original_name: 'a.zip' }); + const all = [pdf, passAsPdf, photo, video, word, text, zip]; + const ids = (filter: string, rules?: typeof PHONE_FILE_FILTER_RULES) => + all.filter((f) => matchesFileFilter(f, filter, rules)).map((f) => f.id); + + it('FE-FILES-LIST-002: desktop reads the mime type for pdf, image and doc', () => { + expect(ids('pdf')).toEqual([pdf.id, passAsPdf.id]); + expect(ids('image')).toEqual([photo.id]); + expect(ids('doc')).toEqual([word.id, text.id]); + }); + + it('FE-FILES-LIST-003: the phone partitions by type category', () => { + expect(ids('pdf', PHONE_FILE_FILTER_RULES)).toEqual([pdf.id]); + expect(ids('image', PHONE_FILE_FILTER_RULES)).toEqual([photo.id, video.id]); + expect(ids('doc', PHONE_FILE_FILTER_RULES)).toEqual([passAsPdf.id, word.id, text.id, zip.id]); + }); + + it('FE-FILES-LIST-004: all and an unknown filter show everything in both views', () => { + for (const rules of [undefined, PHONE_FILE_FILTER_RULES]) { + expect(ids('all', rules)).toEqual(all.map((f) => f.id)); + expect(ids('bogus', rules)).toEqual(all.map((f) => f.id)); + } + }); + + it('FE-FILES-LIST-005: starred follows the flag in both views', () => { + for (const rules of [undefined, PHONE_FILE_FILTER_RULES]) { + expect(matchesFileFilter(buildTripFile({ starred: 1 }), 'starred', rules)).toBe(true); + expect(matchesFileFilter(buildTripFile({ starred: 0 }), 'starred', rules)).toBe(false); + } + }); + + it('FE-FILES-LIST-006: a note id of 0 is collab on the phone only', () => { + const zeroNote = buildTripFile({ note_id: 0 }); + expect(matchesFileFilter(zeroNote, 'collab')).toBe(false); + expect(matchesFileFilter(zeroNote, 'collab', PHONE_FILE_FILTER_RULES)).toBe(true); + for (const rules of [undefined, PHONE_FILE_FILTER_RULES]) { + expect(matchesFileFilter(buildTripFile({ note_id: 4 }), 'collab', rules)).toBe(true); + expect(matchesFileFilter(buildTripFile({ note_id: null }), 'collab', rules)).toBe(false); + } + }); +}); + +describe('linkedFileTargets', () => { + const zeroPlace = buildPlace({ id: 0, name: 'Zero' }); + const louvre = buildPlace({ id: 1, name: 'Louvre' }); + const orsay = buildPlace({ id: 2, name: 'Orsay' }); + const flight = buildReservation({ id: 10, type: 'flight', title: 'LH 123' }); + const hotel = buildReservation({ id: 11, type: 'hotel', title: 'Hotel' }); + const places = [zeroPlace, louvre, orsay]; + const reservations = [flight, hotel]; + + it('FE-FILES-LIST-007: own column first, then each extra link once, unknown ids left out', () => { + const file = buildTripFile({ + place_id: 2, + linked_place_ids: [1, 2, 99], + reservation_id: 11, + linked_reservation_ids: [10, 11], + }); + for (const rules of [undefined, DESKTOP_FILE_LINK_LIST_RULES]) { + const linked = linkedFileTargets(file, places, reservations, TRANSPORT, rules); + expect(linked.places).toEqual([orsay, louvre]); + expect(linked.reservations).toEqual([ + { reservation: hotel, transport: false }, + { reservation: flight, transport: true }, + ]); + } + }); + + it('FE-FILES-LIST-008: an own column of 0 counts on the phone and not on desktop', () => { + const file = buildTripFile({ place_id: 0, linked_place_ids: [], reservation_id: null }); + expect(linkedFileTargets(file, places, reservations, TRANSPORT).places).toEqual([zeroPlace]); + expect(linkedFileTargets(file, places, reservations, TRANSPORT, DESKTOP_FILE_LINK_LIST_RULES).places).toEqual([]); + }); + + it('FE-FILES-LIST-009: null entries in the extra links are skipped', () => { + const file = buildTripFile({ + place_id: null, + linked_place_ids: [null as unknown as number, 1], + reservation_id: null, + linked_reservation_ids: [null as unknown as number], + }); + const linked = linkedFileTargets(file, places, reservations, TRANSPORT, DESKTOP_FILE_LINK_LIST_RULES); + expect(linked.places).toEqual([louvre]); + expect(linked.reservations).toEqual([]); + }); + + it('FE-FILES-LIST-010: missing place and booking lists give empty links', () => { + const file = buildTripFile({ place_id: 1, reservation_id: 10 }); + expect(linkedFileTargets(file, undefined, undefined, TRANSPORT)).toEqual({ places: [], reservations: [] }); + }); + + it('FE-FILES-LIST-011: a booking column of 0 counts on the phone and not on desktop', () => { + const zeroBooking = buildReservation({ id: 0, type: 'hotel', title: 'Zero' }); + const file = buildTripFile({ place_id: null, reservation_id: 0, linked_reservation_ids: [] }); + expect(linkedFileTargets(file, places, [zeroBooking], TRANSPORT).reservations).toEqual([ + { reservation: zeroBooking, transport: false }, + ]); + expect( + linkedFileTargets(file, places, [zeroBooking], TRANSPORT, DESKTOP_FILE_LINK_LIST_RULES).reservations + ).toEqual([]); + }); +}); + +describe('linkedFileIds', () => { + it('FE-FILES-LIST-012: own column first, extras once, nulls skipped, 0 kept only on the phone', () => { + const file = buildTripFile({ + place_id: 0, + linked_place_ids: [2, null as unknown as number, 0, 2], + reservation_id: 11, + linked_reservation_ids: [10, 11], + }); + expect(linkedFileIds(file, 'place_id')).toEqual([0, 2]); + expect(linkedFileIds(file, 'place_id', DESKTOP_FILE_LINK_LIST_RULES)).toEqual([2, 0]); + expect(linkedFileIds(file, 'reservation_id')).toEqual([11, 10]); + expect(linkedFileIds(file, 'reservation_id', DESKTOP_FILE_LINK_LIST_RULES)).toEqual([11, 10]); + const empty = buildTripFile({ place_id: null, linked_place_ids: undefined }); + expect(linkedFileIds(empty, 'place_id')).toEqual([]); + }); +}); diff --git a/client/src/components/Files/fileListRules.ts b/client/src/components/Files/fileListRules.ts new file mode 100644 index 000000000..f957bff72 --- /dev/null +++ b/client/src/components/Files/fileListRules.ts @@ -0,0 +1,122 @@ +import type { Place, Reservation, TripFile } from '../../types'; +import { isImage, isMedia, isWalletPass } from './FileManager.helpers'; + +export type FileTypeCategory = 'pdf' | 'image' | 'pass' | 'xls' | 'other'; + +function isSpreadsheetFile(mimeType?: string | null, name?: string | null): boolean { + const ext = (name || '').toLowerCase().split('.').pop(); + if (ext === 'xls' || ext === 'xlsx' || ext === 'csv' || ext === 'ods') return true; + const mime = mimeType || ''; + return mime.includes('excel') || mime.includes('spreadsheet') || mime === 'text/csv'; +} + +/** + * PDF / IMG / PASS / XLS / fallback, derived from the real mime_type and filename. IMG folds + * in video (#823 media) since both open in the same lightbox. + */ +export function getFileTypeCategory(file: Pick): FileTypeCategory { + if (isWalletPass(file.mime_type, file.original_name)) return 'pass'; + if (file.mime_type === 'application/pdf') return 'pdf'; + if (isMedia(file.mime_type)) return 'image'; + if (isSpreadsheetFile(file.mime_type, file.original_name)) return 'xls'; + return 'other'; +} + +/** + * How the filter tabs sort a file, which differs between the views. The phone's tiles + * partition the files by type category: 'image' takes videos too and 'doc' is every file + * that is no PDF and no media, and a note id of 0 still counts as collab. The desktop tabs + * read the mime type directly: 'image' is pictures only and 'doc' is Word, Excel and text, + * and only a set note id counts as collab. + */ +export interface FileFilterRules { + byTypeCategory?: boolean; + collabWhenNotNull?: boolean; +} + +export const PHONE_FILE_FILTER_RULES: FileFilterRules = { byTypeCategory: true, collabWhenNotNull: true }; + +function matchesTypeFilter(file: TripFile, filter: 'pdf' | 'image' | 'doc', byTypeCategory: boolean): boolean { + if (byTypeCategory) { + const category = getFileTypeCategory(file); + if (filter === 'doc') return category === 'xls' || category === 'pass' || category === 'other'; + return category === filter; + } + const mime = file.mime_type || ''; + if (filter === 'pdf') return file.mime_type === 'application/pdf'; + if (filter === 'image') return isImage(file.mime_type); + return mime.includes('word') || mime.includes('excel') || mime.includes('text'); +} + +/** Whether a file shows under a filter tab; an unknown filter shows everything. */ +export function matchesFileFilter(file: TripFile, filter: string, rules: FileFilterRules = {}): boolean { + switch (filter) { + case 'starred': + return !!file.starred; + case 'collab': + return rules.collabWhenNotNull ? file.note_id != null : !!file.note_id; + case 'pdf': + case 'image': + case 'doc': + return matchesTypeFilter(file, filter, !!rules.byTypeCategory); + default: + return true; + } +} + +/** + * How the link list reads a file's own place and booking column. The desktop row skips an + * empty column of any kind (0 included); the phone skips only a null one. + */ +export interface FileLinkListRules { + ownLinkWhenTruthy?: boolean; +} + +export const DESKTOP_FILE_LINK_LIST_RULES: FileLinkListRules = { ownLinkWhenTruthy: true }; + +export interface LinkedFileTargets { + places: Place[]; + /** Each linked booking, with whether it is a transport (the rest show as bookings). */ + reservations: { reservation: Reservation; transport: boolean }[]; +} + +/** + * The ids a file is linked to in one column: its own place or booking column first, then + * each extra link once, null entries skipped. + */ +export function linkedFileIds( + file: Pick, + field: 'place_id' | 'reservation_id', + rules: FileLinkListRules = {} +): number[] { + const own = field === 'place_id' ? file.place_id : file.reservation_id; + const extra = field === 'place_id' ? file.linked_place_ids : file.linked_reservation_ids; + const ids = new Set(); + if (rules.ownLinkWhenTruthy ? own : own != null) ids.add(own as number); + for (const id of extra || []) if (id != null) ids.add(id); + return [...ids]; +} + +/** + * The places and bookings a file is linked to: its own place and booking column plus the + * extra file links, each once and in that order, leaving out ids that match nothing. + */ +export function linkedFileTargets( + file: TripFile, + places: Place[] | undefined, + reservations: Reservation[] | undefined, + transportTypes: ReadonlySet, + rules: FileLinkListRules = {} +): LinkedFileTargets { + const linkedPlaces: Place[] = []; + for (const id of linkedFileIds(file, 'place_id', rules)) { + const place = (places || []).find((p) => p.id === id); + if (place) linkedPlaces.push(place); + } + const linkedReservations: LinkedFileTargets['reservations'] = []; + for (const id of linkedFileIds(file, 'reservation_id', rules)) { + const reservation = (reservations || []).find((r) => r.id === id); + if (reservation) linkedReservations.push({ reservation, transport: transportTypes.has(reservation.type) }); + } + return { places: linkedPlaces, reservations: linkedReservations }; +} diff --git a/client/src/components/Files/useFileManager.ts b/client/src/components/Files/useFileManager.ts index d0d7429c3..8f4c30e72 100644 --- a/client/src/components/Files/useFileManager.ts +++ b/client/src/components/Files/useFileManager.ts @@ -10,10 +10,11 @@ import { useAuthStore } from '../../store/authStore' import { canManageDocSync } from './docsync/useDocSync' import { useDocSyncOffered } from './docsync/useDocSyncOffered' import { getAuthUrl } from '../../api/authUrl' -import { isImage, isMedia, isWalletPass } from './FileManager.helpers' +import { isMedia, isWalletPass } from './FileManager.helpers' import { openFile as openFileInTab } from '../../utils/fileDownload' import { useFileTrash } from './useFileTrash' -import { filesFromClipboard } from './fileActions' +import { DESKTOP_FILE_TRASH_TOAST_RULES, filesFromClipboard, trashFileWithToast, updateFileFields } from './fileActions' +import { matchesFileFilter } from './fileListRules' export interface FileManagerProps { files?: TripFile[] @@ -132,23 +133,9 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place } }, [onDrop]) - const filteredFiles = files.filter(f => { - if (filterType === 'starred') return !!f.starred - if (filterType === 'pdf') return f.mime_type === 'application/pdf' - if (filterType === 'image') return isImage(f.mime_type) - if (filterType === 'doc') return (f.mime_type || '').includes('word') || (f.mime_type || '').includes('excel') || (f.mime_type || '').includes('text') - if (filterType === 'collab') return !!f.note_id - return true - }) + const filteredFiles = files.filter(f => matchesFileFilter(f, filterType)) - const handleDelete = async (id) => { - try { - await onDelete(id) - toast.success(t('files.toast.trashed') || 'Moved to trash') - } catch { - toast.error(t('files.toast.deleteError')) - } - } + const handleDelete = (id) => trashFileWithToast(() => onDelete(id), { t, toast }, DESKTOP_FILE_TRASH_TOAST_RULES) useEffect(() => { if (previewFile) { @@ -158,14 +145,8 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place } }, [previewFile?.url]) - const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => { - try { - await filesApi.update(tripId, fileId, data) - refreshFiles() - } catch { - toast.error(t('files.toast.assignError')) - } - } + const handleAssign = (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => + updateFileFields(tripId, fileId, data, { t, toast, refresh: refreshFiles }) // Image OR video — both open in the lightbox; videos play there (#823). const mediaFiles = filteredFiles.filter(f => isMedia(f.mime_type)) diff --git a/client/src/mobile/screens/trip/tabs/MFileLinkSheet.tsx b/client/src/mobile/screens/trip/tabs/MFileLinkSheet.tsx index 4d623e4e6..d23c56288 100644 --- a/client/src/mobile/screens/trip/tabs/MFileLinkSheet.tsx +++ b/client/src/mobile/screens/trip/tabs/MFileLinkSheet.tsx @@ -3,6 +3,7 @@ import { Check, Link2, Loader2, MapPin, Ticket, TrainFront } from 'lucide-react' import type { LucideIcon } from 'lucide-react' 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 type { TripPlanner } from '../MTripShell' import { Eyebrow, TileHeader } from '../sheets/MTripSheetUi' @@ -33,13 +34,8 @@ export default function MFileLinkSheet({ planner, file, onClose }: MFileLinkShee if (!shown) return - const placeIds = new Set() - if (shown.place_id != null) placeIds.add(shown.place_id) - for (const id of shown.linked_place_ids || []) if (id != null) placeIds.add(id) - - const resIds = new Set() - if (shown.reservation_id != null) resIds.add(shown.reservation_id) - for (const id of shown.linked_reservation_ids || []) if (id != null) resIds.add(id) + const placeIds = new Set(linkedFileIds(shown, 'place_id')) + const resIds = new Set(linkedFileIds(shown, 'reservation_id')) const refresh = () => tripActions.loadFiles(tripId) diff --git a/client/src/mobile/screens/trip/tabs/MFileMenuSheet.tsx b/client/src/mobile/screens/trip/tabs/MFileMenuSheet.tsx index aa97a9bed..b7230930d 100644 --- a/client/src/mobile/screens/trip/tabs/MFileMenuSheet.tsx +++ b/client/src/mobile/screens/trip/tabs/MFileMenuSheet.tsx @@ -4,7 +4,7 @@ import MSheet from '../../../components/MSheet' import MListRow from '../../../components/MListRow' import MConfirmSheet from '../../settings/MConfirmSheet' import { downloadFile } from '../../../../utils/fileDownload' -import { filesApi } from '../../../../api/client' +import { PHONE_FILE_UPDATE_RULES, trashFileWithToast, updateFileFields } from '../../../../components/Files/fileActions' import type { TripFile } from '../../../../types' import type { TripPlanner } from '../MTripShell' import { Eyebrow, TileHeader } from '../sheets/MTripSheetUi' @@ -59,9 +59,7 @@ export default function MFileMenuSheet({ planner, file, onClose, onOpenLinks }: const value = noteDraft.trim() if (value === (shown.description || '')) return setSaving(true) - filesApi.update(tripId, shown.id, { description: value }) - .then(() => tripActions.loadFiles(tripId)) - .catch(() => toast.error(t('files.toast.assignError'))) + void updateFileFields(tripId, shown.id, { description: value }, { t, toast, refresh: () => tripActions.loadFiles(tripId) }, PHONE_FILE_UPDATE_RULES) .finally(() => setSaving(false)) } @@ -70,9 +68,7 @@ export default function MFileMenuSheet({ planner, file, onClose, onOpenLinks }: const remove = () => { setConfirmDelete(false) setDeleting(true) - tripActions.deleteFile(tripId, shown.id) - .then(() => { toast.success(t('files.toast.trashed')); onClose() }) - .catch(() => toast.error(t('files.toast.deleteError'))) + void trashFileWithToast(() => tripActions.deleteFile(tripId, shown.id), { t, toast, onTrashed: onClose }) .finally(() => setDeleting(false)) } diff --git a/client/src/mobile/screens/trip/tabs/filesModel.ts b/client/src/mobile/screens/trip/tabs/filesModel.ts index e5c07e004..181c594bb 100644 --- a/client/src/mobile/screens/trip/tabs/filesModel.ts +++ b/client/src/mobile/screens/trip/tabs/filesModel.ts @@ -1,6 +1,9 @@ import { File, FileText, Files, FolderOpen, Image as ImageIcon, Star, StickyNote, Ticket, FileSpreadsheet } from 'lucide-react' import type { LucideIcon } from 'lucide-react' -import { isMedia, isWalletPass } from '../../../../components/Files/FileManager.helpers' +import { + PHONE_FILE_FILTER_RULES, getFileTypeCategory, linkedFileTargets, + matchesFileFilter as matchesSharedFileFilter, type FileTypeCategory, +} from '../../../../components/Files/fileListRules' import type { Place, Reservation, TranslationFn, TripFile } from '../../../../types' /** @@ -12,7 +15,6 @@ import type { Place, Reservation, TranslationFn, TripFile } from '../../../../ty */ export type FileFilterId = 'all' | 'pdf' | 'image' | 'doc' | 'starred' | 'collab' -export type FileTypeCategory = 'pdf' | 'image' | 'pass' | 'xls' | 'other' interface FileTypeMeta { category: FileTypeCategory @@ -31,27 +33,6 @@ const TYPE_META: Record> = { other: { icon: File, color: '#6b7280', label: 'FILE' }, } -function isSpreadsheetFile(mimeType?: string | null, name?: string | null): boolean { - const ext = (name || '').toLowerCase().split('.').pop() - if (ext === 'xls' || ext === 'xlsx' || ext === 'csv' || ext === 'ods') return true - const mime = mimeType || '' - return mime.includes('excel') || mime.includes('spreadsheet') || mime === 'text/csv' -} - -/** - * PDF / IMG / PASS / XLS / fallback — the demo's four explicit type rows - * (Z. 3655) plus the generic fallback, derived from the real mime_type / - * filename. IMG folds in video (#823 media) since both open in the same - * lightbox (useFileManager.ts's isMedia groups them the same way). - */ -export function getFileTypeCategory(file: Pick): FileTypeCategory { - if (isWalletPass(file.mime_type, file.original_name)) return 'pass' - if (file.mime_type === 'application/pdf') return 'pdf' - if (isMedia(file.mime_type)) return 'image' - if (isSpreadsheetFile(file.mime_type, file.original_name)) return 'xls' - return 'other' -} - export function getFileTypeMeta(file: Pick): FileTypeMeta { const category = getFileTypeCategory(file) return { category, ...TYPE_META[category] } @@ -75,18 +56,7 @@ export const FILE_FILTERS: { id: FileFilterId; icon: LucideIcon; labelKey: strin * invisible to every specific filter except All (open point, see report). */ export function matchesFileFilter(file: TripFile, filter: FileFilterId): boolean { - switch (filter) { - case 'all': return true - case 'starred': return !!file.starred - case 'collab': return file.note_id != null - case 'pdf': return getFileTypeCategory(file) === 'pdf' - case 'image': return getFileTypeCategory(file) === 'image' - case 'doc': { - const category = getFileTypeCategory(file) - return category === 'xls' || category === 'pass' || category === 'other' - } - default: return true - } + return matchesSharedFileFilter(file, filter, PHONE_FILE_FILTER_RULES) } /** Starred files first, stable otherwise (spec 03 §5.2: "Sortierung: markierte Dateien zuerst"). */ @@ -115,25 +85,11 @@ export function buildFileLinkLabels( transportTypes: Set, t: TranslationFn, ): string[] { - const labels: string[] = [] - - const placeIds = new Set() - if (file.place_id != null) placeIds.add(file.place_id) - for (const id of file.linked_place_ids || []) if (id != null) placeIds.add(id) - for (const id of placeIds) { - const place = places.find(p => p.id === id) - if (place) labels.push(`${t('files.sourcePlan')} · ${place.name}`) - } - - const resIds = new Set() - if (file.reservation_id != null) resIds.add(file.reservation_id) - for (const id of file.linked_reservation_ids || []) if (id != null) resIds.add(id) - for (const id of resIds) { - const res = reservations.find(r => r.id === id) - if (res) { - const key = transportTypes.has(res.type) ? 'files.sourceTransport' : 'files.sourceBooking' - labels.push(`${t(key)} · ${res.title || t(key)}`) - } + const linked = linkedFileTargets(file, places, reservations, transportTypes) + const labels = linked.places.map(place => `${t('files.sourcePlan')} · ${place.name}`) + for (const { reservation, transport } of linked.reservations) { + const key = transport ? 'files.sourceTransport' : 'files.sourceBooking' + labels.push(`${t(key)} · ${reservation.title || t(key)}`) } if (file.note_id != null) labels.push(t('files.sourceCollab')) diff --git a/client/tests/unit/mobile/trip/filesModel.test.ts b/client/tests/unit/mobile/trip/filesModel.test.ts index ab70aacf1..d949d083f 100644 --- a/client/tests/unit/mobile/trip/filesModel.test.ts +++ b/client/tests/unit/mobile/trip/filesModel.test.ts @@ -4,12 +4,12 @@ import { FILE_FILTERS, buildFileLinkLabels, formatFileDate, - getFileTypeCategory, getFileTypeMeta, matchesFileFilter, sortFilesStarredFirst, type FileFilterId, } from '../../../../src/mobile/screens/trip/tabs/filesModel'; +import { getFileTypeCategory } from '../../../../src/components/Files/fileListRules'; import { buildPlace, buildReservation, buildTripFile } from '../../../helpers/factories'; import type { TranslationFn, TripFile } from '../../../../src/types';