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.
This commit is contained in:
Maurice
2026-10-09 12:09:07 +02:00
parent 88a2479627
commit 973773e003
10 changed files with 472 additions and 110 deletions
+5 -12
View File
@@ -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<number>()
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<number>()
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 (
<div key={file.id} className="group flex items-start gap-2.5 rounded-[14px] border border-edge-faint bg-surface-secondary px-3 py-2.5 transition-colors hover:border-edge"
style={{ opacity: isTrash ? 0.7 : 1 }}
@@ -78,11 +71,11 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea
{!!file.file_size && <span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
{linkedPlaces.map(p => (
{linked.places.map(p => (
<SourceBadge key={p.id} icon={MapPin} kind={t('files.sourcePlan')} label={p.name} />
))}
{linkedReservations.map(r => (
TRANSPORT_TYPES.has(r.type)
{linked.reservations.map(({ reservation: r, transport }) => (
transport
? <SourceBadge key={r.id} icon={transportIcon(r.type)} kind={t('files.sourceTransport')} label={r.title || t('files.sourceTransport')} />
: <SourceBadge key={r.id} icon={Ticket} kind={t('files.sourceBooking')} label={r.title || t('files.sourceBooking')} />
))}
+108 -2
View File
@@ -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<void>((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');
});
});
+61 -1
View File
@@ -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<typeof useToast>;
}
/**
* 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<typeof filesApi.update>[2],
{ t, toast, refresh }: FileFeedback & { refresh: () => unknown },
rules: FileUpdateRules = {}
): Promise<void> {
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<unknown>,
{ t, toast, onTrashed }: FileFeedback & { onTrashed?: () => void },
rules: FileTrashToastRules = {}
): Promise<void> {
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'));
}
}
@@ -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([]);
});
});
@@ -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<TripFile, 'mime_type' | 'original_name'>): 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<TripFile, 'place_id' | 'linked_place_ids' | 'reservation_id' | 'linked_reservation_ids'>,
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<number>();
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<string>,
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 };
}
+7 -26
View File
@@ -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))
@@ -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 <MSheet open={false} onClose={onClose} variant="card" material="glass" />
const placeIds = new Set<number>()
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<number>()
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)
@@ -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))
}
@@ -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<FileTypeCategory, Omit<FileTypeMeta, 'category'>> = {
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<TripFile, 'mime_type' | 'original_name'>): 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<TripFile, 'mime_type' | 'original_name'>): 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<string>,
t: TranslationFn,
): string[] {
const labels: string[] = []
const placeIds = new Set<number>()
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<number>()
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'))
@@ -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';