mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
fix(files): show descriptive error for unsupported upload type
Unsupported file uploads showed a generic 'Upload failed' toast even
though the server already returns a descriptive 400. The client catch
blocks discarded the error and always showed t('files.uploadError').
The server now emits the i18n key 'files.uploadErrorType' as its error
message; a new translateApiError() helper resolves a server message that
is a known translation key via t() and falls back to the generic key
otherwise. Wired into the three trip-file upload catch sites.
Closes #1363
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useTranslation, translateApiError } from '../../i18n'
|
||||
import { filesApi } from '../../api/client'
|
||||
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
@@ -119,8 +119,8 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
|
||||
if (lastId && (places.length > 0 || reservations.length > 0)) {
|
||||
setAssignFileId(lastId)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('files.uploadError'))
|
||||
} catch (err) {
|
||||
toast.error(translateApiError(t, err, 'files.uploadError'))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { mapsApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useTranslation, translateApiError } from '../../i18n'
|
||||
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
|
||||
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
|
||||
import { formatDistance, formatElevation } from '../../utils/units'
|
||||
@@ -189,7 +189,7 @@ export default function PlaceInspector({
|
||||
setFilesExpanded(true)
|
||||
} catch (err: unknown) {
|
||||
console.error('Upload failed', err)
|
||||
toast.error(t('files.uploadError'))
|
||||
toast.error(translateApiError(t, err, 'files.uploadError'))
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
|
||||
@@ -8,3 +8,4 @@ export {
|
||||
SUPPORTED_LANGUAGES,
|
||||
} from './TranslationContext'
|
||||
export { TransHtml } from './TransHtml'
|
||||
export { translateApiError } from './translateApiError'
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { translateApiError } from './translateApiError'
|
||||
|
||||
// Mimics the real t(): returns a translation for known keys, the key itself otherwise.
|
||||
const dict: Record<string, string> = {
|
||||
'files.uploadErrorType': "This file type isn't supported",
|
||||
'files.uploadError': 'Upload failed',
|
||||
}
|
||||
const t = (key: string) => dict[key] ?? key
|
||||
|
||||
describe('translateApiError', () => {
|
||||
it('resolves a server message that is a known i18n key', () => {
|
||||
const err = new Error('files.uploadErrorType')
|
||||
expect(translateApiError(t, err, 'files.uploadError')).toBe("This file type isn't supported")
|
||||
})
|
||||
|
||||
it('falls back to the generic key when the message is a plain string', () => {
|
||||
const err = new Error('Some raw server message')
|
||||
expect(translateApiError(t, err, 'files.uploadError')).toBe('Upload failed')
|
||||
})
|
||||
|
||||
it('falls back when the message is an empty string', () => {
|
||||
expect(translateApiError(t, new Error(''), 'files.uploadError')).toBe('Upload failed')
|
||||
})
|
||||
|
||||
it('falls back when the thrown value is not an Error', () => {
|
||||
expect(translateApiError(t, 'nope', 'files.uploadError')).toBe('Upload failed')
|
||||
expect(translateApiError(t, undefined, 'files.uploadError')).toBe('Upload failed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Resolve a server error whose message may be an i18n key.
|
||||
*
|
||||
* The server can return a translation key as its error message (e.g.
|
||||
* `files.uploadErrorType`). `t()` returns the key unchanged when it isn't a
|
||||
* known translation, so `translated === key` reliably means "not a key" — in
|
||||
* that case we fall back to a generic, always-localized message.
|
||||
*/
|
||||
export function translateApiError(
|
||||
t: (key: string) => string,
|
||||
err: unknown,
|
||||
fallbackKey: string,
|
||||
): string {
|
||||
const key = err instanceof Error ? err.message : ''
|
||||
const translated = t(key)
|
||||
return translated && translated !== key ? translated : t(fallbackKey)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { getCached, fetchPhoto } from '../../services/photoService'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useTranslation, translateApiError } from '../../i18n'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi } from '../../api/client'
|
||||
import { accommodationRepo } from '../../repo/accommodationRepo'
|
||||
import { offlineDb } from '../../db/offlineDb'
|
||||
@@ -400,7 +400,7 @@ export function useTripPlanner() {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('place_id', String(editingPlace.id))
|
||||
try { await tripActions.addFile(tripId, fd) } catch { toast.error(t('files.uploadError')) }
|
||||
try { await tripActions.addFile(tripId, fd) } catch (err) { toast.error(translateApiError(t, err, 'files.uploadError')) }
|
||||
}
|
||||
}
|
||||
toast.success(t('trip.toast.placeUpdated'))
|
||||
@@ -411,7 +411,7 @@ export function useTripPlanner() {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('place_id', String(place.id))
|
||||
try { await tripActions.addFile(tripId, fd) } catch { toast.error(t('files.uploadError')) }
|
||||
try { await tripActions.addFile(tripId, fd) } catch (err) { toast.error(translateApiError(t, err, 'files.uploadError')) }
|
||||
}
|
||||
}
|
||||
toast.success(t('trip.toast.placeAdded'))
|
||||
|
||||
@@ -37,7 +37,8 @@ const UPLOAD = {
|
||||
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const reject = () => {
|
||||
const err: Error & { statusCode?: number } = new Error('File type not allowed');
|
||||
// i18n key — the client resolves it via t() (see translateApiError).
|
||||
const err: Error & { statusCode?: number } = new Error('files.uploadErrorType');
|
||||
err.statusCode = 400;
|
||||
cb(err, false);
|
||||
};
|
||||
|
||||
@@ -126,6 +126,8 @@ describe('Upload file', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('file', svgPath);
|
||||
expect(res.status).toBe(400);
|
||||
// The error is an i18n key the client resolves via t() (issue #1363).
|
||||
expect(res.body.error).toBe('files.uploadErrorType');
|
||||
} finally {
|
||||
if (fs.existsSync(svgPath)) fs.unlinkSync(svgPath);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
|
||||
'files.countSingular': 'ملف واحد',
|
||||
'files.uploaded': 'تم رفع {count}',
|
||||
'files.uploadError': 'فشل الرفع',
|
||||
'files.uploadErrorType': 'نوع الملف هذا غير مدعوم',
|
||||
'files.dropzone': 'أسقط الملفات هنا',
|
||||
'files.dropzoneHint': 'أو انقر للتصفح',
|
||||
'files.allowedTypes': 'صور، PDF، DOC، DOCX، XLS، XLSX، TXT، CSV · حد أقصى 50 ميغابايت',
|
||||
|
||||
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
|
||||
'files.countSingular': '1 arquivo',
|
||||
'files.uploaded': '{count} enviado(s)',
|
||||
'files.uploadError': 'Falha no envio',
|
||||
'files.uploadErrorType': 'Esse tipo de arquivo não é suportado',
|
||||
'files.dropzone': 'Solte os arquivos aqui',
|
||||
'files.dropzoneHint': 'ou clique para escolher',
|
||||
'files.allowedTypes': 'Imagens, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Máx. 50 MB',
|
||||
|
||||
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
|
||||
'files.countSingular': '1 soubor',
|
||||
'files.uploaded': '{count} nahráno',
|
||||
'files.uploadError': 'Nahrávání se nezdařilo',
|
||||
'files.uploadErrorType': 'Tento typ souboru není podporován',
|
||||
'files.dropzone': 'Přetáhněte soubory sem',
|
||||
'files.dropzoneHint': 'nebo klikněte pro výběr',
|
||||
'files.allowedTypes': 'Obrázky, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
|
||||
|
||||
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
|
||||
'files.countSingular': '1 Datei',
|
||||
'files.uploaded': '{count} hochgeladen',
|
||||
'files.uploadError': 'Fehler beim Hochladen',
|
||||
'files.uploadErrorType': 'Dieser Dateityp wird nicht unterstützt',
|
||||
'files.dropzone': 'Dateien hier ablegen',
|
||||
'files.dropzoneHint': 'oder klicken zum Auswählen',
|
||||
'files.allowedTypes': 'Bilder, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
|
||||
|
||||
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
|
||||
'files.countSingular': '1 file',
|
||||
'files.uploaded': '{count} uploaded',
|
||||
'files.uploadError': 'Upload failed',
|
||||
'files.uploadErrorType': "This file type isn't supported",
|
||||
'files.dropzone': 'Drop files here',
|
||||
'files.dropzoneHint': 'or click to browse',
|
||||
'files.allowedTypes': 'Images, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
|
||||
|
||||
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
|
||||
'files.countSingular': '1 archivo',
|
||||
'files.uploaded': '{count} archivos subidos',
|
||||
'files.uploadError': 'La subida falló',
|
||||
'files.uploadErrorType': 'Este tipo de archivo no es compatible',
|
||||
'files.dropzone': 'Arrastra aquí los archivos',
|
||||
'files.dropzoneHint': 'o haz clic para explorar',
|
||||
'files.allowedTypes': 'Imágenes, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Máx. 50 MB',
|
||||
|
||||
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
|
||||
'files.countSingular': '1 fichier',
|
||||
'files.uploaded': '{count} importés',
|
||||
'files.uploadError': "Échec de l'import",
|
||||
'files.uploadErrorType': "Ce type de fichier n'est pas pris en charge",
|
||||
'files.dropzone': 'Déposez les fichiers ici',
|
||||
'files.dropzoneHint': 'ou cliquez pour parcourir',
|
||||
'files.allowedTypes': 'Images, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 Mo',
|
||||
|
||||
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
|
||||
'files.countSingular': '1 αρχείο',
|
||||
'files.uploaded': '{count} μεταφορτώθηκαν',
|
||||
'files.uploadError': 'Η μεταφόρτωση απέτυχε',
|
||||
'files.uploadErrorType': 'Αυτός ο τύπος αρχείου δεν υποστηρίζεται',
|
||||
'files.dropzone': 'Αποθέστε αρχεία εδώ',
|
||||
'files.dropzoneHint': 'ή κάντε κλικ για περιήγηση',
|
||||
'files.allowedTypes': 'Εικόνες, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Μέγ. 50 MB',
|
||||
|
||||
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
|
||||
'files.countSingular': '1 fájl',
|
||||
'files.uploaded': '{count} feltöltve',
|
||||
'files.uploadError': 'Feltöltés sikertelen',
|
||||
'files.uploadErrorType': 'Ez a fájltípus nem támogatott',
|
||||
'files.dropzone': 'Húzd ide a fájlokat',
|
||||
'files.dropzoneHint': 'vagy kattints a böngészéshez',
|
||||
'files.allowedTypes': 'Képek, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
|
||||
|
||||
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
|
||||
'files.countSingular': '1 berkas',
|
||||
'files.uploaded': '{count} diunggah',
|
||||
'files.uploadError': 'Gagal mengunggah',
|
||||
'files.uploadErrorType': 'Tipe file ini tidak didukung',
|
||||
'files.dropzone': 'Jatuhkan file di sini',
|
||||
'files.dropzoneHint': 'atau klik untuk memilih',
|
||||
'files.allowedTypes': 'Gambar, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Maks 50 MB',
|
||||
|
||||
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
|
||||
'files.countSingular': '1 documento',
|
||||
'files.uploaded': '{count} caricati',
|
||||
'files.uploadError': 'Caricamento non riuscito',
|
||||
'files.uploadErrorType': 'Questo tipo di file non è supportato',
|
||||
'files.dropzone': 'Trascina qui i file',
|
||||
'files.dropzoneHint': 'oppure clicca per sfogliare',
|
||||
'files.allowedTypes': 'Immagini, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
|
||||
|
||||
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
|
||||
'files.countSingular': '1件のファイル',
|
||||
'files.uploaded': '{count}件アップロード',
|
||||
'files.uploadError': 'アップロードに失敗しました',
|
||||
'files.uploadErrorType': 'このファイル形式はサポートされていません',
|
||||
'files.dropzone': 'ここにファイルをドロップ',
|
||||
'files.dropzoneHint': 'またはクリックして参照',
|
||||
'files.allowedTypes': '画像、PDF、DOC、DOCX、XLS、XLSX、TXT、CSV · 最大50MB',
|
||||
|
||||
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
|
||||
'files.countSingular': '파일 1개',
|
||||
'files.uploaded': '{count}개 업로드됨',
|
||||
'files.uploadError': '업로드 실패',
|
||||
'files.uploadErrorType': '지원되지 않는 파일 형식입니다',
|
||||
'files.dropzone': '여기에 파일을 놓으세요',
|
||||
'files.dropzoneHint': '또는 클릭하여 탐색',
|
||||
'files.allowedTypes': '이미지, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · 최대 50 MB',
|
||||
|
||||
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
|
||||
'files.countSingular': '1 bestand',
|
||||
'files.uploaded': '{count} geüpload',
|
||||
'files.uploadError': 'Uploaden mislukt',
|
||||
'files.uploadErrorType': 'Dit bestandstype wordt niet ondersteund',
|
||||
'files.dropzone': 'Sleep bestanden hierheen',
|
||||
'files.dropzoneHint': 'of klik om te bladeren',
|
||||
'files.allowedTypes': 'Afbeeldingen, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
|
||||
|
||||
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
|
||||
'files.countSingular': '1 plik',
|
||||
'files.uploaded': '{count} przesłanych',
|
||||
'files.uploadError': 'Przesyłanie nie powiodło się',
|
||||
'files.uploadErrorType': 'Ten typ pliku nie jest obsługiwany',
|
||||
'files.dropzone': 'Przeciągnij pliki tutaj',
|
||||
'files.dropzoneHint': 'lub kliknij, aby przeglądać',
|
||||
'files.allowedTypes': 'Obrazki, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Maks 50 MB',
|
||||
|
||||
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
|
||||
'files.countSingular': '1 файл',
|
||||
'files.uploaded': '{count} загружено',
|
||||
'files.uploadError': 'Ошибка загрузки',
|
||||
'files.uploadErrorType': 'Этот тип файла не поддерживается',
|
||||
'files.dropzone': 'Перетащите файлы сюда',
|
||||
'files.dropzoneHint': 'или нажмите для выбора',
|
||||
'files.allowedTypes': 'Изображения, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Макс. 50 МБ',
|
||||
|
||||
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
|
||||
'files.countSingular': '1 fil',
|
||||
'files.uploaded': '{count} uppladdade',
|
||||
'files.uploadError': 'Uppladdning misslyckades',
|
||||
'files.uploadErrorType': 'Den här filtypen stöds inte',
|
||||
'files.dropzone': 'Släpp filer här',
|
||||
'files.dropzoneHint': 'eller klicka för att bläddra',
|
||||
'files.allowedTypes': 'Foton, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Högst 50 MB',
|
||||
|
||||
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
|
||||
'files.countSingular': '1 dosya',
|
||||
'files.uploaded': '{count} yüklendi',
|
||||
'files.uploadError': 'Yükleme başarısız oldu',
|
||||
'files.uploadErrorType': 'Bu dosya türü desteklenmiyor',
|
||||
'files.dropzone': 'Dosyaları buraya bırakın',
|
||||
'files.dropzoneHint': 'veya göz atmak için tıklayın',
|
||||
'files.allowedTypes': 'Görsel, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Maks. 50 MB',
|
||||
|
||||
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
|
||||
'files.countSingular': '1 файл',
|
||||
'files.uploaded': '{count} завантажено',
|
||||
'files.uploadError': 'Помилка завантаження',
|
||||
'files.uploadErrorType': 'Цей тип файлу не підтримується',
|
||||
'files.dropzone': 'Перетягніть файли сюди',
|
||||
'files.dropzoneHint': 'або натисніть для вибору',
|
||||
'files.allowedTypes': 'Зображення, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Макс. 50 МБ',
|
||||
|
||||
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
|
||||
'files.countSingular': '1 個檔案',
|
||||
'files.uploaded': '已上傳 {count} 個',
|
||||
'files.uploadError': '上傳失敗',
|
||||
'files.uploadErrorType': '不支援此檔案類型',
|
||||
'files.dropzone': '將檔案拖放到此處',
|
||||
'files.dropzoneHint': '或點選瀏覽',
|
||||
'files.allowedTypes': '圖片、PDF、DOC、DOCX、XLS、XLSX、TXT、CSV · 最大 50 MB',
|
||||
|
||||
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
|
||||
'files.countSingular': '1 个文件',
|
||||
'files.uploaded': '已上传 {count} 个',
|
||||
'files.uploadError': '上传失败',
|
||||
'files.uploadErrorType': '不支持此文件类型',
|
||||
'files.dropzone': '将文件拖放到此处',
|
||||
'files.dropzoneHint': '或点击浏览',
|
||||
'files.allowedTypes': '图片、PDF、DOC、DOCX、XLS、XLSX、TXT、CSV · 最大 50 MB',
|
||||
|
||||
Reference in New Issue
Block a user