Skip to content

Commit 04edb85

Browse files
baozhoutaoclaude
andcommitted
fix(i18n): localize FileField upload widget + approvals snapshot field labels
The approvals inbox drawer and its Approve/Reject/… dialogs leaked English: - FileField (the shared upload widget) hard-coded every visible string ("Drag & drop files here", "or click to browse", "Take photo", "Uploading…", validation errors, etc.). Route them through useObjectTranslation with new `fields.file.*` keys, translated across all 10 locale bundles. - The record-snapshot summary title-cased raw machine keys ("assessment_status" → "Assessment Status") instead of the target object's field labels. Consume the new server-sent `payload_labels` (framework side) in payloadSummary/decisionAmountEntry, falling back to the prettified key when absent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 059a052 commit 04edb85

14 files changed

Lines changed: 231 additions & 21 deletions

File tree

.changeset/approvals-inbox-i18n.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@object-ui/fields": patch
3+
"@object-ui/i18n": patch
4+
"@object-ui/console": patch
5+
---
6+
7+
fix(i18n): localize FileField upload widget + approvals snapshot field labels
8+
9+
- `FileField` (the shared upload widget) hard-coded every visible string
10+
("Drag & drop files here", "or click to browse", "Take photo", "Uploading…",
11+
size/upload validation messages, …). They now route through
12+
`useObjectTranslation` with new `fields.file.*` keys, translated across all
13+
10 locale bundles. This is why the approvals Approve/Reject dialog's
14+
attachment dropzone was English in a Chinese console.
15+
- The approvals inbox record-snapshot summary title-cased raw machine keys
16+
instead of the target object's field labels. It now consumes the
17+
server-sent `payload_labels` in `payloadSummary`/`decisionAmountEntry`,
18+
falling back to the prettified key when absent; `approvalsApi`'s row type
19+
gains `payload_labels`.

apps/console/src/pages/system/ApprovalsInboxPage.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ const OPAQUE_ID_RE = /^[A-Za-z0-9_-]{15,}$/;
279279
function payloadSummary(
280280
payload: unknown,
281281
display?: Record<string, string>,
282+
labels?: Record<string, string>,
282283
max = 6,
283284
excludeKey?: string,
284285
): Array<[string, string]> {
@@ -293,7 +294,9 @@ function payloadSummary(
293294
if (!resolved && typeof v === 'string' && OPAQUE_ID_RE.test(v.trim()) && !/^\d+$/.test(v.trim())) {
294295
continue;
295296
}
296-
out.push([prettifyKey(k), resolved ?? formatPayloadValue(k, v)]);
297+
// Prefer the server-resolved field label (the target object's own label,
298+
// already localized for a single-locale project) over a title-cased key.
299+
out.push([labels?.[k] ?? prettifyKey(k), resolved ?? formatPayloadValue(k, v)]);
297300
if (out.length >= max) break;
298301
}
299302
return out;
@@ -324,7 +327,12 @@ function decisionAmountEntry(
324327
? v
325328
: (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)) ? Number(v) : null);
326329
if (num == null || !Number.isFinite(num)) continue;
327-
return { key: k, label: prettifyKey(k), value: num, display: r.payload_display?.[k] ?? num.toLocaleString() };
330+
return {
331+
key: k,
332+
label: r.payload_labels?.[k] ?? prettifyKey(k),
333+
value: num,
334+
display: r.payload_display?.[k] ?? num.toLocaleString(),
335+
};
328336
}
329337
return null;
330338
}
@@ -1585,7 +1593,7 @@ export function ApprovalsInboxPage() {
15851593
// figure at the top instead of a value buried bottom-right in the
15861594
// generic field grid. Excluded from that grid below so it shows once.
15871595
const drawerAmount = decisionAmountEntry(selected);
1588-
const summary = payloadSummary(selected.payload, selected.payload_display, 6, drawerAmount?.key);
1596+
const summary = payloadSummary(selected.payload, selected.payload_display, selected.payload_labels, 6, drawerAmount?.key);
15891597
return (
15901598
<Card>
15911599
<CardContent className="p-4 space-y-3">

apps/console/src/services/approvalsApi.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ export interface ApprovalRequestRow {
6464
pending_approver_groups?: Record<string, string[]>;
6565
/** Display values for lookup fields in `payload` (field key → record title). */
6666
payload_display?: Record<string, string>;
67+
/** Display labels for `payload` fields (field key → target object's field label). */
68+
payload_labels?: Record<string, string>;
6769
/** SLA deadline (`created_at + escalation.timeoutHours`), display-only. */
6870
sla_due_at?: string;
6971
/** Owning flow's approval steps for progress display (single reads only). */

packages/fields/src/widgets/FileField.tsx

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useRef, useState, useCallback } from 'react';
22
import { Button, EmptyValue } from '@object-ui/components';
33
import { useUpload } from '@object-ui/providers';
4+
import { useObjectTranslation } from '@object-ui/i18n';
45
import { Upload, X, File as FileIcon, ImageIcon, Camera, Loader2 } from 'lucide-react';
56
import { FieldWidgetProps } from './types';
67
import { useUploadingSignal } from './useUploadingSignal';
@@ -20,6 +21,7 @@ function useFileUploads(opts: {
2021
}) {
2122
const { files, multiple, maxSize, onChange } = opts;
2223
const { upload } = useUpload();
24+
const { t } = useObjectTranslation();
2325
const [errors, setErrors] = useState<string[]>([]);
2426
const [uploadProgress, setUploadProgress] = useState<Record<string, number>>({});
2527
const [uploading, setUploading] = useState(false);
@@ -31,7 +33,10 @@ function useFileUploads(opts: {
3133
const validFiles = selectedFiles.filter(file => {
3234
if (maxSize && file.size > maxSize) {
3335
const maxMB = (maxSize / (1024 * 1024)).toFixed(1);
34-
newErrors.push(`"${file.name}" exceeds max size (${maxMB} MB)`);
36+
newErrors.push(t('fields.file.exceedsMaxSize', {
37+
defaultValue: '"{{name}}" exceeds max size ({{max}} MB)',
38+
name: file.name, max: maxMB,
39+
}));
3540
return false;
3641
}
3742
return true;
@@ -63,7 +68,10 @@ function useFileUploads(opts: {
6368
url: result.url,
6469
};
6570
} catch (err) {
66-
newErrors.push(`Failed to upload "${file.name}": ${(err as Error).message}`);
71+
newErrors.push(t('fields.file.uploadFailed', {
72+
defaultValue: 'Failed to upload "{{name}}": {{error}}',
73+
name: file.name, error: (err as Error).message,
74+
}));
6775
setErrors([...newErrors]);
6876
return null;
6977
}
@@ -81,7 +89,7 @@ function useFileUploads(opts: {
8189
setUploading(false);
8290
setUploadProgress({});
8391
}
84-
}, [files, multiple, onChange, maxSize, upload]);
92+
}, [files, multiple, onChange, maxSize, upload, t]);
8593

8694
return { processFiles, errors, uploading, uploadProgress };
8795
}
@@ -92,6 +100,7 @@ function useFileUploads(opts: {
92100
* L2: File size validation, per-file progress indicators, error messages.
93101
*/
94102
export function FileField({ value, onChange, field, readonly, onUploadingChange, ...props }: FieldWidgetProps<any>) {
103+
const { t } = useObjectTranslation();
95104
const inputRef = useRef<HTMLInputElement>(null);
96105
const cameraRef = useRef<HTMLInputElement>(null);
97106
const fileField = (field || (props as any).schema) as any;
@@ -161,7 +170,7 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
161170
<div className="flex flex-wrap gap-2">
162171
{readonlyFiles.map((file: any, idx: number) => (
163172
<span key={idx} className="text-sm truncate max-w-xs">
164-
{file.name || file.original_name || 'File'}
173+
{file.name || file.original_name || t('fields.file.fileFallback', { defaultValue: 'File' })}
165174
</span>
166175
))}
167176
</div>
@@ -204,7 +213,7 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
204213
capture={cameraEnabled}
205214
onChange={handleFileChange}
206215
className="hidden"
207-
aria-label="Camera capture"
216+
aria-label={t('fields.file.cameraCapture', { defaultValue: 'Camera capture' })}
208217
data-testid="file-field-camera-input"
209218
/>
210219
)}
@@ -236,10 +245,14 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
236245
<Upload className={`size-8 ${isDragOver ? 'text-primary' : 'text-muted-foreground'}`} />
237246
<div className="text-center">
238247
<p className="text-sm font-medium">
239-
{isDragOver ? 'Drop files here' : 'Drag & drop files here'}
248+
{isDragOver
249+
? t('fields.file.dropFilesHere', { defaultValue: 'Drop files here' })
250+
: t('fields.file.dragDropHere', { defaultValue: 'Drag & drop files here' })}
240251
</p>
241252
<p className="text-xs text-muted-foreground mt-1">
242-
or click to browse{cameraEnabled ? ' • use the camera button below' : ''}
253+
{cameraEnabled
254+
? t('fields.file.browseHintCamera', { defaultValue: 'or click to browse • use the camera button below' })
255+
: t('fields.file.browseHint', { defaultValue: 'or click to browse' })}
243256
</p>
244257
</div>
245258
</div>
@@ -257,7 +270,9 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
257270
data-testid="file-field-camera-button"
258271
>
259272
<Camera className="size-4 mr-2" />
260-
{cameraEnabled === 'user' ? 'Take selfie' : 'Take photo'}
273+
{cameraEnabled === 'user'
274+
? t('fields.file.takeSelfie', { defaultValue: 'Take selfie' })
275+
: t('fields.file.takePhoto', { defaultValue: 'Take photo' })}
261276
</Button>
262277
)}
263278

@@ -266,12 +281,15 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
266281
<div className="flex items-center gap-2 text-xs text-muted-foreground" data-testid="file-field-uploading">
267282
<Loader2 className="size-3 animate-spin" />
268283
<span>
269-
Uploading…
270-
{Object.keys(uploadProgress).length > 0 &&
271-
` (${Math.round(
272-
(Object.values(uploadProgress).reduce((s, v) => s + v, 0) /
273-
Object.keys(uploadProgress).length) * 100,
274-
)}%)`}
284+
{Object.keys(uploadProgress).length > 0
285+
? t('fields.file.uploadingPct', {
286+
defaultValue: 'Uploading… ({{pct}}%)',
287+
pct: Math.round(
288+
(Object.values(uploadProgress).reduce((s, v) => s + v, 0) /
289+
Object.keys(uploadProgress).length) * 100,
290+
),
291+
})
292+
: t('fields.file.uploading', { defaultValue: 'Uploading…' })}
275293
</span>
276294
</div>
277295
)}
@@ -302,7 +320,7 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
302320
<FileIcon className="size-4 text-muted-foreground shrink-0" />
303321
)}
304322
<span className="text-sm truncate">
305-
{file.name || file.original_name || 'File'}
323+
{file.name || file.original_name || t('fields.file.fileFallback', { defaultValue: 'File' })}
306324
</span>
307325
{file.size && (
308326
<span className="text-xs text-muted-foreground">
@@ -362,6 +380,7 @@ export function FileCell({
362380
/** Focus-grid coordinate (see GridField keyboard navigation). */
363381
'data-cell'?: string;
364382
}) {
383+
const { t } = useObjectTranslation();
365384
const inputRef = useRef<HTMLInputElement>(null);
366385
const files = value ? (Array.isArray(value) ? value : [value]) : [];
367386
const { processFiles, errors, uploading } = useFileUploads({
@@ -379,7 +398,9 @@ export function FileCell({
379398

380399
const isImage = (file: any) => String(file?.mime_type || '').startsWith('image/');
381400
const nameOf = (file: any) =>
382-
typeof file === 'string' ? file : file?.name || file?.original_name || 'File';
401+
typeof file === 'string'
402+
? file
403+
: file?.name || file?.original_name || t('fields.file.fileFallback', { defaultValue: 'File' });
383404
const showUpload = !disabled && !uploading && (multiple || files.length === 0);
384405

385406
return (
@@ -412,7 +433,7 @@ export function FileCell({
412433
<button
413434
type="button"
414435
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
415-
aria-label={`Remove ${nameOf(file)}`}
436+
aria-label={t('fields.file.remove', { defaultValue: 'Remove {{name}}', name: nameOf(file) })}
416437
onClick={() => removeAt(idx)}
417438
>
418439
<X className="size-3" />
@@ -440,7 +461,7 @@ export function FileCell({
440461
disabled={disabled}
441462
>
442463
<Upload className="size-3.5" />
443-
{files.length === 0 && 'Upload'}
464+
{files.length === 0 && t('fields.file.upload', { defaultValue: 'Upload' })}
444465
</Button>
445466
)}
446467
{errors.length > 0 && (

packages/i18n/src/locales/ar.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,22 @@ const ar = {
117117
relativeDate: {
118118
overdue: "متأخر {{count}} يوم",
119119
},
120+
file: {
121+
dragDropHere: 'اسحب الملفات وأفلتها هنا',
122+
dropFilesHere: 'أفلت الملفات هنا',
123+
browseHint: 'أو انقر للتصفح',
124+
browseHintCamera: 'أو انقر للتصفح • استخدم زر الكاميرا أدناه',
125+
takePhoto: 'التقاط صورة',
126+
takeSelfie: 'التقاط صورة ذاتية',
127+
cameraCapture: 'التقاط بالكاميرا',
128+
uploading: 'جارٍ الرفع…',
129+
uploadingPct: 'جارٍ الرفع… ({{pct}}%)',
130+
fileFallback: 'ملف',
131+
upload: 'رفع',
132+
remove: 'إزالة {{name}}',
133+
exceedsMaxSize: '"{{name}}" يتجاوز الحجم الأقصى ({{max}} ميغابايت)',
134+
uploadFailed: 'فشل رفع "{{name}}": {{error}}',
135+
},
120136
richText: {
121137
format: "التنسيق: {{format}}",
122138
basicEditorHint: "محرر النص الغني (أساسي)",

packages/i18n/src/locales/de.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,22 @@ const de = {
117117
relativeDate: {
118118
overdue: "{{count}} T. überfällig",
119119
},
120+
file: {
121+
dragDropHere: 'Dateien hierher ziehen und ablegen',
122+
dropFilesHere: 'Dateien hier ablegen',
123+
browseHint: 'oder zum Durchsuchen klicken',
124+
browseHintCamera: 'oder zum Durchsuchen klicken • Kamera-Schaltfläche unten verwenden',
125+
takePhoto: 'Foto aufnehmen',
126+
takeSelfie: 'Selfie aufnehmen',
127+
cameraCapture: 'Kameraaufnahme',
128+
uploading: 'Wird hochgeladen…',
129+
uploadingPct: 'Wird hochgeladen… ({{pct}} %)',
130+
fileFallback: 'Datei',
131+
upload: 'Hochladen',
132+
remove: '{{name}} entfernen',
133+
exceedsMaxSize: '„{{name}}“ überschreitet die maximale Größe ({{max}} MB)',
134+
uploadFailed: 'Hochladen von „{{name}}“ fehlgeschlagen: {{error}}',
135+
},
120136
richText: {
121137
format: "Format: {{format}}",
122138
basicEditorHint: "Rich-Text-Editor (einfach)",

packages/i18n/src/locales/en.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,22 @@ const en = {
136136
relativeDate: {
137137
overdue: 'Overdue {{count}}d',
138138
},
139+
file: {
140+
dragDropHere: 'Drag & drop files here',
141+
dropFilesHere: 'Drop files here',
142+
browseHint: 'or click to browse',
143+
browseHintCamera: 'or click to browse • use the camera button below',
144+
takePhoto: 'Take photo',
145+
takeSelfie: 'Take selfie',
146+
cameraCapture: 'Camera capture',
147+
uploading: 'Uploading…',
148+
uploadingPct: 'Uploading… ({{pct}}%)',
149+
fileFallback: 'File',
150+
upload: 'Upload',
151+
remove: 'Remove {{name}}',
152+
exceedsMaxSize: '"{{name}}" exceeds max size ({{max}} MB)',
153+
uploadFailed: 'Failed to upload "{{name}}": {{error}}',
154+
},
139155
richText: {
140156
format: 'Format: {{format}}',
141157
basicEditorHint: 'Rich text editor (basic)',

packages/i18n/src/locales/es.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,22 @@ const es = {
117117
relativeDate: {
118118
overdue: "Atrasado {{count}} d",
119119
},
120+
file: {
121+
dragDropHere: 'Arrastra y suelta archivos aquí',
122+
dropFilesHere: 'Suelta los archivos aquí',
123+
browseHint: 'o haz clic para explorar',
124+
browseHintCamera: 'o haz clic para explorar • usa el botón de cámara de abajo',
125+
takePhoto: 'Tomar foto',
126+
takeSelfie: 'Tomar selfie',
127+
cameraCapture: 'Captura de cámara',
128+
uploading: 'Subiendo…',
129+
uploadingPct: 'Subiendo… ({{pct}} %)',
130+
fileFallback: 'Archivo',
131+
upload: 'Subir',
132+
remove: 'Quitar {{name}}',
133+
exceedsMaxSize: '«{{name}}» supera el tamaño máximo ({{max}} MB)',
134+
uploadFailed: 'Error al subir «{{name}}»: {{error}}',
135+
},
120136
richText: {
121137
format: "Formato: {{format}}",
122138
basicEditorHint: "Editor de texto enriquecido (básico)",

packages/i18n/src/locales/fr.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,22 @@ const fr = {
117117
relativeDate: {
118118
overdue: "En retard de {{count}} j",
119119
},
120+
file: {
121+
dragDropHere: 'Glissez-déposez des fichiers ici',
122+
dropFilesHere: 'Déposez les fichiers ici',
123+
browseHint: 'ou cliquez pour parcourir',
124+
browseHintCamera: 'ou cliquez pour parcourir • utilisez le bouton appareil photo ci-dessous',
125+
takePhoto: 'Prendre une photo',
126+
takeSelfie: 'Prendre un selfie',
127+
cameraCapture: 'Capture photo',
128+
uploading: 'Téléversement…',
129+
uploadingPct: 'Téléversement… ({{pct}} %)',
130+
fileFallback: 'Fichier',
131+
upload: 'Téléverser',
132+
remove: 'Supprimer {{name}}',
133+
exceedsMaxSize: '« {{name}} » dépasse la taille maximale ({{max}} Mo)',
134+
uploadFailed: 'Échec du téléversement de « {{name}} » : {{error}}',
135+
},
120136
richText: {
121137
format: "Format : {{format}}",
122138
basicEditorHint: "Éditeur de texte enrichi (basique)",

packages/i18n/src/locales/ja.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,22 @@ const ja = {
117117
relativeDate: {
118118
overdue: "期限超過 {{count}}日",
119119
},
120+
file: {
121+
dragDropHere: 'ファイルをここにドラッグ&ドロップ',
122+
dropFilesHere: 'ここにドロップ',
123+
browseHint: 'またはクリックして選択',
124+
browseHintCamera: 'またはクリックして選択 • 下のカメラボタンを使用',
125+
takePhoto: '写真を撮る',
126+
takeSelfie: '自撮り',
127+
cameraCapture: 'カメラ撮影',
128+
uploading: 'アップロード中…',
129+
uploadingPct: 'アップロード中…({{pct}}%)',
130+
fileFallback: 'ファイル',
131+
upload: 'アップロード',
132+
remove: '{{name}} を削除',
133+
exceedsMaxSize: '「{{name}}」は最大サイズ({{max}} MB)を超えています',
134+
uploadFailed: '「{{name}}」のアップロードに失敗しました:{{error}}',
135+
},
120136
richText: {
121137
format: "フォーマット: {{format}}",
122138
basicEditorHint: "リッチテキストエディター(基本)",

0 commit comments

Comments
 (0)