diff --git a/.changeset/approvals-inbox-i18n.md b/.changeset/approvals-inbox-i18n.md new file mode 100644 index 000000000..d93a0c4aa --- /dev/null +++ b/.changeset/approvals-inbox-i18n.md @@ -0,0 +1,19 @@ +--- +"@object-ui/fields": patch +"@object-ui/i18n": patch +"@object-ui/console": patch +--- + +fix(i18n): localize FileField upload widget + approvals snapshot field labels + +- `FileField` (the shared upload widget) hard-coded every visible string + ("Drag & drop files here", "or click to browse", "Take photo", "Uploading…", + size/upload validation messages, …). They now route through + `useObjectTranslation` with new `fields.file.*` keys, translated across all + 10 locale bundles. This is why the approvals Approve/Reject dialog's + attachment dropzone was English in a Chinese console. +- The approvals inbox record-snapshot summary title-cased raw machine keys + instead of the target object's field labels. It now consumes the + server-sent `payload_labels` in `payloadSummary`/`decisionAmountEntry`, + falling back to the prettified key when absent; `approvalsApi`'s row type + gains `payload_labels`. diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.tsx index 1d27d65f7..0732c9c40 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.tsx @@ -279,6 +279,7 @@ const OPAQUE_ID_RE = /^[A-Za-z0-9_-]{15,}$/; function payloadSummary( payload: unknown, display?: Record, + labels?: Record, max = 6, excludeKey?: string, ): Array<[string, string]> { @@ -293,7 +294,9 @@ function payloadSummary( if (!resolved && typeof v === 'string' && OPAQUE_ID_RE.test(v.trim()) && !/^\d+$/.test(v.trim())) { continue; } - out.push([prettifyKey(k), resolved ?? formatPayloadValue(k, v)]); + // Prefer the server-resolved field label (the target object's own label, + // already localized for a single-locale project) over a title-cased key. + out.push([labels?.[k] ?? prettifyKey(k), resolved ?? formatPayloadValue(k, v)]); if (out.length >= max) break; } return out; @@ -324,7 +327,12 @@ function decisionAmountEntry( ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)) ? Number(v) : null); if (num == null || !Number.isFinite(num)) continue; - return { key: k, label: prettifyKey(k), value: num, display: r.payload_display?.[k] ?? num.toLocaleString() }; + return { + key: k, + label: r.payload_labels?.[k] ?? prettifyKey(k), + value: num, + display: r.payload_display?.[k] ?? num.toLocaleString(), + }; } return null; } @@ -1585,7 +1593,7 @@ export function ApprovalsInboxPage() { // figure at the top instead of a value buried bottom-right in the // generic field grid. Excluded from that grid below so it shows once. const drawerAmount = decisionAmountEntry(selected); - const summary = payloadSummary(selected.payload, selected.payload_display, 6, drawerAmount?.key); + const summary = payloadSummary(selected.payload, selected.payload_display, selected.payload_labels, 6, drawerAmount?.key); return ( diff --git a/apps/console/src/services/approvalsApi.ts b/apps/console/src/services/approvalsApi.ts index 2227e6c60..c53d274c2 100644 --- a/apps/console/src/services/approvalsApi.ts +++ b/apps/console/src/services/approvalsApi.ts @@ -64,6 +64,8 @@ export interface ApprovalRequestRow { pending_approver_groups?: Record; /** Display values for lookup fields in `payload` (field key → record title). */ payload_display?: Record; + /** Display labels for `payload` fields (field key → target object's field label). */ + payload_labels?: Record; /** SLA deadline (`created_at + escalation.timeoutHours`), display-only. */ sla_due_at?: string; /** Owning flow's approval steps for progress display (single reads only). */ diff --git a/packages/fields/src/widgets/FileField.tsx b/packages/fields/src/widgets/FileField.tsx index e824709c9..6d67a5a9f 100644 --- a/packages/fields/src/widgets/FileField.tsx +++ b/packages/fields/src/widgets/FileField.tsx @@ -1,6 +1,7 @@ import React, { useRef, useState, useCallback } from 'react'; import { Button, EmptyValue } from '@object-ui/components'; import { useUpload } from '@object-ui/providers'; +import { useObjectTranslation } from '@object-ui/i18n'; import { Upload, X, File as FileIcon, ImageIcon, Camera, Loader2 } from 'lucide-react'; import { FieldWidgetProps } from './types'; import { useUploadingSignal } from './useUploadingSignal'; @@ -20,6 +21,7 @@ function useFileUploads(opts: { }) { const { files, multiple, maxSize, onChange } = opts; const { upload } = useUpload(); + const { t } = useObjectTranslation(); const [errors, setErrors] = useState([]); const [uploadProgress, setUploadProgress] = useState>({}); const [uploading, setUploading] = useState(false); @@ -31,7 +33,10 @@ function useFileUploads(opts: { const validFiles = selectedFiles.filter(file => { if (maxSize && file.size > maxSize) { const maxMB = (maxSize / (1024 * 1024)).toFixed(1); - newErrors.push(`"${file.name}" exceeds max size (${maxMB} MB)`); + newErrors.push(t('fields.file.exceedsMaxSize', { + defaultValue: `"${file.name}" exceeds max size (${maxMB} MB)`, + name: file.name, max: maxMB, + })); return false; } return true; @@ -63,7 +68,10 @@ function useFileUploads(opts: { url: result.url, }; } catch (err) { - newErrors.push(`Failed to upload "${file.name}": ${(err as Error).message}`); + newErrors.push(t('fields.file.uploadFailed', { + defaultValue: `Failed to upload "${file.name}": ${(err as Error).message}`, + name: file.name, error: (err as Error).message, + })); setErrors([...newErrors]); return null; } @@ -81,7 +89,7 @@ function useFileUploads(opts: { setUploading(false); setUploadProgress({}); } - }, [files, multiple, onChange, maxSize, upload]); + }, [files, multiple, onChange, maxSize, upload, t]); return { processFiles, errors, uploading, uploadProgress }; } @@ -92,6 +100,7 @@ function useFileUploads(opts: { * L2: File size validation, per-file progress indicators, error messages. */ export function FileField({ value, onChange, field, readonly, onUploadingChange, ...props }: FieldWidgetProps) { + const { t } = useObjectTranslation(); const inputRef = useRef(null); const cameraRef = useRef(null); const fileField = (field || (props as any).schema) as any; @@ -161,7 +170,7 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
{readonlyFiles.map((file: any, idx: number) => ( - {file.name || file.original_name || 'File'} + {file.name || file.original_name || t('fields.file.fileFallback', { defaultValue: 'File' })} ))}
@@ -204,7 +213,7 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange, capture={cameraEnabled} onChange={handleFileChange} className="hidden" - aria-label="Camera capture" + aria-label={t('fields.file.cameraCapture', { defaultValue: 'Camera capture' })} data-testid="file-field-camera-input" /> )} @@ -236,10 +245,14 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,

- {isDragOver ? 'Drop files here' : 'Drag & drop files here'} + {isDragOver + ? t('fields.file.dropFilesHere', { defaultValue: 'Drop files here' }) + : t('fields.file.dragDropHere', { defaultValue: 'Drag & drop files here' })}

- or click to browse{cameraEnabled ? ' • use the camera button below' : ''} + {cameraEnabled + ? t('fields.file.browseHintCamera', { defaultValue: 'or click to browse • use the camera button below' }) + : t('fields.file.browseHint', { defaultValue: 'or click to browse' })}

@@ -257,7 +270,9 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange, data-testid="file-field-camera-button" > - {cameraEnabled === 'user' ? 'Take selfie' : 'Take photo'} + {cameraEnabled === 'user' + ? t('fields.file.takeSelfie', { defaultValue: 'Take selfie' }) + : t('fields.file.takePhoto', { defaultValue: 'Take photo' })} )} @@ -266,12 +281,14 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
- Uploading… - {Object.keys(uploadProgress).length > 0 && - ` (${Math.round( - (Object.values(uploadProgress).reduce((s, v) => s + v, 0) / - Object.keys(uploadProgress).length) * 100, - )}%)`} + {(() => { + const keys = Object.keys(uploadProgress); + if (keys.length === 0) return t('fields.file.uploading', { defaultValue: 'Uploading…' }); + const pct = Math.round( + (Object.values(uploadProgress).reduce((s, v) => s + v, 0) / keys.length) * 100, + ); + return t('fields.file.uploadingPct', { defaultValue: `Uploading… (${pct}%)`, pct }); + })()}
)} @@ -302,7 +319,7 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange, )} - {file.name || file.original_name || 'File'} + {file.name || file.original_name || t('fields.file.fileFallback', { defaultValue: 'File' })} {file.size && ( @@ -362,6 +379,7 @@ export function FileCell({ /** Focus-grid coordinate (see GridField keyboard navigation). */ 'data-cell'?: string; }) { + const { t } = useObjectTranslation(); const inputRef = useRef(null); const files = value ? (Array.isArray(value) ? value : [value]) : []; const { processFiles, errors, uploading } = useFileUploads({ @@ -379,7 +397,9 @@ export function FileCell({ const isImage = (file: any) => String(file?.mime_type || '').startsWith('image/'); const nameOf = (file: any) => - typeof file === 'string' ? file : file?.name || file?.original_name || 'File'; + typeof file === 'string' + ? file + : file?.name || file?.original_name || t('fields.file.fileFallback', { defaultValue: 'File' }); const showUpload = !disabled && !uploading && (multiple || files.length === 0); return ( @@ -412,7 +432,7 @@ export function FileCell({ )} {errors.length > 0 && ( diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 80d63984a..b51038e58 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -117,6 +117,22 @@ const ar = { relativeDate: { overdue: "متأخر {{count}} يوم", }, + file: { + dragDropHere: 'اسحب الملفات وأفلتها هنا', + dropFilesHere: 'أفلت الملفات هنا', + browseHint: 'أو انقر للتصفح', + browseHintCamera: 'أو انقر للتصفح • استخدم زر الكاميرا أدناه', + takePhoto: 'التقاط صورة', + takeSelfie: 'التقاط صورة ذاتية', + cameraCapture: 'التقاط بالكاميرا', + uploading: 'جارٍ الرفع…', + uploadingPct: 'جارٍ الرفع… ({{pct}}%)', + fileFallback: 'ملف', + upload: 'رفع', + remove: 'إزالة {{name}}', + exceedsMaxSize: '"{{name}}" يتجاوز الحجم الأقصى ({{max}} ميغابايت)', + uploadFailed: 'فشل رفع "{{name}}": {{error}}', + }, richText: { format: "التنسيق: {{format}}", basicEditorHint: "محرر النص الغني (أساسي)", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index a1ca24367..ba015a83a 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -117,6 +117,22 @@ const de = { relativeDate: { overdue: "{{count}} T. überfällig", }, + file: { + dragDropHere: 'Dateien hierher ziehen und ablegen', + dropFilesHere: 'Dateien hier ablegen', + browseHint: 'oder zum Durchsuchen klicken', + browseHintCamera: 'oder zum Durchsuchen klicken • Kamera-Schaltfläche unten verwenden', + takePhoto: 'Foto aufnehmen', + takeSelfie: 'Selfie aufnehmen', + cameraCapture: 'Kameraaufnahme', + uploading: 'Wird hochgeladen…', + uploadingPct: 'Wird hochgeladen… ({{pct}} %)', + fileFallback: 'Datei', + upload: 'Hochladen', + remove: '{{name}} entfernen', + exceedsMaxSize: '„{{name}}“ überschreitet die maximale Größe ({{max}} MB)', + uploadFailed: 'Hochladen von „{{name}}“ fehlgeschlagen: {{error}}', + }, richText: { format: "Format: {{format}}", basicEditorHint: "Rich-Text-Editor (einfach)", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 8351063a9..c115bc05b 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -136,6 +136,22 @@ const en = { relativeDate: { overdue: 'Overdue {{count}}d', }, + file: { + dragDropHere: 'Drag & drop files here', + dropFilesHere: 'Drop files here', + browseHint: 'or click to browse', + browseHintCamera: 'or click to browse • use the camera button below', + takePhoto: 'Take photo', + takeSelfie: 'Take selfie', + cameraCapture: 'Camera capture', + uploading: 'Uploading…', + uploadingPct: 'Uploading… ({{pct}}%)', + fileFallback: 'File', + upload: 'Upload', + remove: 'Remove {{name}}', + exceedsMaxSize: '"{{name}}" exceeds max size ({{max}} MB)', + uploadFailed: 'Failed to upload "{{name}}": {{error}}', + }, richText: { format: 'Format: {{format}}', basicEditorHint: 'Rich text editor (basic)', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index f4578979d..fe8e82824 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -117,6 +117,22 @@ const es = { relativeDate: { overdue: "Atrasado {{count}} d", }, + file: { + dragDropHere: 'Arrastra y suelta archivos aquí', + dropFilesHere: 'Suelta los archivos aquí', + browseHint: 'o haz clic para explorar', + browseHintCamera: 'o haz clic para explorar • usa el botón de cámara de abajo', + takePhoto: 'Tomar foto', + takeSelfie: 'Tomar selfie', + cameraCapture: 'Captura de cámara', + uploading: 'Subiendo…', + uploadingPct: 'Subiendo… ({{pct}} %)', + fileFallback: 'Archivo', + upload: 'Subir', + remove: 'Quitar {{name}}', + exceedsMaxSize: '«{{name}}» supera el tamaño máximo ({{max}} MB)', + uploadFailed: 'Error al subir «{{name}}»: {{error}}', + }, richText: { format: "Formato: {{format}}", basicEditorHint: "Editor de texto enriquecido (básico)", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 6d3103bf1..a7eb82dbc 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -117,6 +117,22 @@ const fr = { relativeDate: { overdue: "En retard de {{count}} j", }, + file: { + dragDropHere: 'Glissez-déposez des fichiers ici', + dropFilesHere: 'Déposez les fichiers ici', + browseHint: 'ou cliquez pour parcourir', + browseHintCamera: 'ou cliquez pour parcourir • utilisez le bouton appareil photo ci-dessous', + takePhoto: 'Prendre une photo', + takeSelfie: 'Prendre un selfie', + cameraCapture: 'Capture photo', + uploading: 'Téléversement…', + uploadingPct: 'Téléversement… ({{pct}} %)', + fileFallback: 'Fichier', + upload: 'Téléverser', + remove: 'Supprimer {{name}}', + exceedsMaxSize: '« {{name}} » dépasse la taille maximale ({{max}} Mo)', + uploadFailed: 'Échec du téléversement de « {{name}} » : {{error}}', + }, richText: { format: "Format : {{format}}", basicEditorHint: "Éditeur de texte enrichi (basique)", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 3c48b9d27..1dc3c49dd 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -117,6 +117,22 @@ const ja = { relativeDate: { overdue: "期限超過 {{count}}日", }, + file: { + dragDropHere: 'ファイルをここにドラッグ&ドロップ', + dropFilesHere: 'ここにドロップ', + browseHint: 'またはクリックして選択', + browseHintCamera: 'またはクリックして選択 • 下のカメラボタンを使用', + takePhoto: '写真を撮る', + takeSelfie: '自撮り', + cameraCapture: 'カメラ撮影', + uploading: 'アップロード中…', + uploadingPct: 'アップロード中…({{pct}}%)', + fileFallback: 'ファイル', + upload: 'アップロード', + remove: '{{name}} を削除', + exceedsMaxSize: '「{{name}}」は最大サイズ({{max}} MB)を超えています', + uploadFailed: '「{{name}}」のアップロードに失敗しました:{{error}}', + }, richText: { format: "フォーマット: {{format}}", basicEditorHint: "リッチテキストエディター(基本)", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 3128e491f..69b561445 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -117,6 +117,22 @@ const ko = { relativeDate: { overdue: "기한 초과 {{count}}일", }, + file: { + dragDropHere: '파일을 여기로 끌어다 놓기', + dropFilesHere: '여기에 놓기', + browseHint: '또는 클릭하여 찾아보기', + browseHintCamera: '또는 클릭하여 찾아보기 • 아래 카메라 버튼 사용', + takePhoto: '사진 촬영', + takeSelfie: '셀피 촬영', + cameraCapture: '카메라 촬영', + uploading: '업로드 중…', + uploadingPct: '업로드 중…({{pct}}%)', + fileFallback: '파일', + upload: '업로드', + remove: '{{name}} 제거', + exceedsMaxSize: '"{{name}}"이(가) 최대 크기({{max}} MB)를 초과합니다', + uploadFailed: '"{{name}}" 업로드 실패: {{error}}', + }, richText: { format: "형식: {{format}}", basicEditorHint: "서식 있는 텍스트 편집기 (기본)", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index a810f9ad7..eee4f2306 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -117,6 +117,22 @@ const pt = { relativeDate: { overdue: "Atrasado {{count}} d", }, + file: { + dragDropHere: 'Arraste e solte arquivos aqui', + dropFilesHere: 'Solte os arquivos aqui', + browseHint: 'ou clique para procurar', + browseHintCamera: 'ou clique para procurar • use o botão da câmera abaixo', + takePhoto: 'Tirar foto', + takeSelfie: 'Tirar selfie', + cameraCapture: 'Captura da câmera', + uploading: 'Enviando…', + uploadingPct: 'Enviando… ({{pct}} %)', + fileFallback: 'Arquivo', + upload: 'Enviar', + remove: 'Remover {{name}}', + exceedsMaxSize: '"{{name}}" excede o tamanho máximo ({{max}} MB)', + uploadFailed: 'Falha ao enviar "{{name}}": {{error}}', + }, richText: { format: "Formato: {{format}}", basicEditorHint: "Editor de texto rico (básico)", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index b1dd1e503..10c144cb4 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -117,6 +117,22 @@ const ru = { relativeDate: { overdue: "Просрочено на {{count}} дн.", }, + file: { + dragDropHere: 'Перетащите файлы сюда', + dropFilesHere: 'Отпустите файлы здесь', + browseHint: 'или нажмите для выбора', + browseHintCamera: 'или нажмите для выбора • используйте кнопку камеры ниже', + takePhoto: 'Сделать фото', + takeSelfie: 'Сделать селфи', + cameraCapture: 'Съёмка камерой', + uploading: 'Загрузка…', + uploadingPct: 'Загрузка… ({{pct}} %)', + fileFallback: 'Файл', + upload: 'Загрузить', + remove: 'Удалить {{name}}', + exceedsMaxSize: '«{{name}}» превышает максимальный размер ({{max}} МБ)', + uploadFailed: 'Не удалось загрузить «{{name}}»: {{error}}', + }, richText: { format: "Формат: {{format}}", basicEditorHint: "Редактор форматированного текста (базовый)", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index da0a2cfb9..5e385f30e 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -135,6 +135,22 @@ const zh = { relativeDate: { overdue: '逾期 {{count}} 天', }, + file: { + dragDropHere: '拖拽文件到此处', + dropFilesHere: '松开以上传', + browseHint: '或点击浏览', + browseHintCamera: '或点击浏览 • 使用下方相机按钮', + takePhoto: '拍照', + takeSelfie: '自拍', + cameraCapture: '拍摄', + uploading: '上传中…', + uploadingPct: '上传中…({{pct}}%)', + fileFallback: '文件', + upload: '上传', + remove: '移除 {{name}}', + exceedsMaxSize: '“{{name}}” 超过大小上限({{max}} MB)', + uploadFailed: '上传 “{{name}}” 失败:{{error}}', + }, richText: { format: '格式: {{format}}', basicEditorHint: '富文本编辑器(基础)',