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';