Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/approvals-inbox-i18n.md
Original file line number Diff line number Diff line change
@@ -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`.
14 changes: 11 additions & 3 deletions apps/console/src/pages/system/ApprovalsInboxPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ const OPAQUE_ID_RE = /^[A-Za-z0-9_-]{15,}$/;
function payloadSummary(
payload: unknown,
display?: Record<string, string>,
labels?: Record<string, string>,
max = 6,
excludeKey?: string,
): Array<[string, string]> {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 (
<Card>
<CardContent className="p-4 space-y-3">
Expand Down
2 changes: 2 additions & 0 deletions apps/console/src/services/approvalsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export interface ApprovalRequestRow {
pending_approver_groups?: Record<string, string[]>;
/** Display values for lookup fields in `payload` (field key → record title). */
payload_display?: Record<string, string>;
/** Display labels for `payload` fields (field key → target object's field label). */
payload_labels?: Record<string, string>;
/** SLA deadline (`created_at + escalation.timeoutHours`), display-only. */
sla_due_at?: string;
/** Owning flow's approval steps for progress display (single reads only). */
Expand Down
56 changes: 38 additions & 18 deletions packages/fields/src/widgets/FileField.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -20,6 +21,7 @@ function useFileUploads(opts: {
}) {
const { files, multiple, maxSize, onChange } = opts;
const { upload } = useUpload();
const { t } = useObjectTranslation();
const [errors, setErrors] = useState<string[]>([]);
const [uploadProgress, setUploadProgress] = useState<Record<string, number>>({});
const [uploading, setUploading] = useState(false);
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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 };
}
Expand All @@ -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<any>) {
const { t } = useObjectTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const cameraRef = useRef<HTMLInputElement>(null);
const fileField = (field || (props as any).schema) as any;
Expand Down Expand Up @@ -161,7 +170,7 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
<div className="flex flex-wrap gap-2">
{readonlyFiles.map((file: any, idx: number) => (
<span key={idx} className="text-sm truncate max-w-xs">
{file.name || file.original_name || 'File'}
{file.name || file.original_name || t('fields.file.fileFallback', { defaultValue: 'File' })}
</span>
))}
</div>
Expand Down Expand Up @@ -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"
/>
)}
Expand Down Expand Up @@ -236,10 +245,14 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
<Upload className={`size-8 ${isDragOver ? 'text-primary' : 'text-muted-foreground'}`} />
<div className="text-center">
<p className="text-sm font-medium">
{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' })}
</p>
<p className="text-xs text-muted-foreground mt-1">
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' })}
</p>
</div>
</div>
Expand All @@ -257,7 +270,9 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
data-testid="file-field-camera-button"
>
<Camera className="size-4 mr-2" />
{cameraEnabled === 'user' ? 'Take selfie' : 'Take photo'}
{cameraEnabled === 'user'
? t('fields.file.takeSelfie', { defaultValue: 'Take selfie' })
: t('fields.file.takePhoto', { defaultValue: 'Take photo' })}
</Button>
)}

Expand All @@ -266,12 +281,14 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
<div className="flex items-center gap-2 text-xs text-muted-foreground" data-testid="file-field-uploading">
<Loader2 className="size-3 animate-spin" />
<span>
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 });
})()}
</span>
</div>
)}
Expand Down Expand Up @@ -302,7 +319,7 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
<FileIcon className="size-4 text-muted-foreground shrink-0" />
)}
<span className="text-sm truncate">
{file.name || file.original_name || 'File'}
{file.name || file.original_name || t('fields.file.fileFallback', { defaultValue: 'File' })}
</span>
{file.size && (
<span className="text-xs text-muted-foreground">
Expand Down Expand Up @@ -362,6 +379,7 @@ export function FileCell({
/** Focus-grid coordinate (see GridField keyboard navigation). */
'data-cell'?: string;
}) {
const { t } = useObjectTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const files = value ? (Array.isArray(value) ? value : [value]) : [];
const { processFiles, errors, uploading } = useFileUploads({
Expand All @@ -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 (
Expand Down Expand Up @@ -412,7 +432,7 @@ export function FileCell({
<button
type="button"
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
aria-label={`Remove ${nameOf(file)}`}
aria-label={t('fields.file.remove', { defaultValue: `Remove ${nameOf(file)}`, name: nameOf(file) })}
onClick={() => removeAt(idx)}
>
<X className="size-3" />
Expand Down Expand Up @@ -440,7 +460,7 @@ export function FileCell({
disabled={disabled}
>
<Upload className="size-3.5" />
{files.length === 0 && 'Upload'}
{files.length === 0 && t('fields.file.upload', { defaultValue: 'Upload' })}
</Button>
)}
{errors.length > 0 && (
Expand Down
16 changes: 16 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "محرر النص الغني (أساسي)",
Expand Down
16 changes: 16 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
16 changes: 16 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down
16 changes: 16 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
16 changes: 16 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
16 changes: 16 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "リッチテキストエディター(基本)",
Expand Down
Loading
Loading