@object-ui/fields 16.1.0 → 17.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/CHANGELOG.md +626 -0
  2. package/dist/FieldEditWidget.d.ts +9 -2
  3. package/dist/__tests__/spec-symbol-batch7.test.d.ts +8 -0
  4. package/dist/__tests__/widget-props-contract.test.d.ts +8 -0
  5. package/dist/field-type-alias.d.ts +16 -0
  6. package/dist/index.cjs +5 -5
  7. package/dist/index.d.ts +14 -11
  8. package/dist/index.js +2426 -2100
  9. package/dist/widgets/AddressField.d.ts +2 -2
  10. package/dist/widgets/AutoNumberField.d.ts +2 -2
  11. package/dist/widgets/AvatarField.d.ts +2 -2
  12. package/dist/widgets/BooleanField.d.ts +2 -2
  13. package/dist/widgets/CapabilityMultiSelectField.d.ts +2 -2
  14. package/dist/widgets/CheckboxesField.d.ts +2 -2
  15. package/dist/widgets/CodeField.d.ts +2 -2
  16. package/dist/widgets/ColorField.d.ts +2 -2
  17. package/dist/widgets/CurrencyField.d.ts +2 -2
  18. package/dist/widgets/DateField.d.ts +2 -2
  19. package/dist/widgets/DateTimeField.d.ts +2 -2
  20. package/dist/widgets/EmailField.d.ts +2 -2
  21. package/dist/widgets/FileField.d.ts +2 -2
  22. package/dist/widgets/FilterConditionField.d.ts +34 -2
  23. package/dist/widgets/FormulaField.d.ts +2 -2
  24. package/dist/widgets/GeolocationField.d.ts +2 -2
  25. package/dist/widgets/GridField.d.ts +2 -2
  26. package/dist/widgets/ImageField.d.ts +2 -2
  27. package/dist/widgets/ImageLightbox.d.ts +30 -0
  28. package/dist/widgets/ImageLightbox.test.d.ts +1 -0
  29. package/dist/widgets/LocationField.d.ts +2 -2
  30. package/dist/widgets/LookupField.d.ts +2 -2
  31. package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
  32. package/dist/widgets/LookupField.idField.test.d.ts +1 -0
  33. package/dist/widgets/MasterDetailField.d.ts +2 -2
  34. package/dist/widgets/MultiSelectField.d.ts +2 -2
  35. package/dist/widgets/NumberField.d.ts +2 -2
  36. package/dist/widgets/ObjectField.d.ts +2 -2
  37. package/dist/widgets/ObjectRefField.d.ts +2 -2
  38. package/dist/widgets/PasswordField.d.ts +2 -2
  39. package/dist/widgets/PercentField.d.ts +2 -2
  40. package/dist/widgets/PhoneField.d.ts +2 -2
  41. package/dist/widgets/QRCodeField.d.ts +2 -2
  42. package/dist/widgets/RadioField.d.ts +2 -2
  43. package/dist/widgets/RatingField.d.ts +2 -2
  44. package/dist/widgets/RecipientPickerField.d.ts +2 -2
  45. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  46. package/dist/widgets/RichTextField.d.ts +2 -2
  47. package/dist/widgets/SelectField.d.ts +2 -2
  48. package/dist/widgets/SignatureField.d.ts +2 -2
  49. package/dist/widgets/SliderField.d.ts +2 -2
  50. package/dist/widgets/SummaryField.d.ts +2 -2
  51. package/dist/widgets/TagsField.d.ts +2 -2
  52. package/dist/widgets/TextAreaField.d.ts +2 -2
  53. package/dist/widgets/TextField.d.ts +2 -2
  54. package/dist/widgets/TimeField.d.ts +2 -2
  55. package/dist/widgets/UrlField.d.ts +2 -2
  56. package/dist/widgets/UserField.d.ts +2 -2
  57. package/dist/widgets/VectorField.d.ts +2 -2
  58. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +8 -0
  59. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +8 -0
  60. package/dist/widgets/file-value.d.ts +99 -0
  61. package/dist/widgets/file-value.test.d.ts +1 -0
  62. package/dist/widgets/types.d.ts +119 -3
  63. package/dist/widgets/useFieldTranslation.d.ts +2 -0
  64. package/package.json +9 -8
  65. package/src/FieldEditWidget.test.ts +74 -2
  66. package/src/FieldEditWidget.tsx +50 -8
  67. package/src/__tests__/spec-symbol-batch7.test.ts +112 -0
  68. package/src/__tests__/widget-props-contract.test.tsx +129 -0
  69. package/src/complex-widgets.test.tsx +3 -3
  70. package/src/datetime-widgets.test.tsx +2 -2
  71. package/src/field-type-alias.ts +100 -0
  72. package/src/index.tsx +97 -134
  73. package/src/widgets/AddressField.tsx +2 -2
  74. package/src/widgets/AutoNumberField.tsx +2 -2
  75. package/src/widgets/AvatarField.tsx +2 -2
  76. package/src/widgets/BooleanField.tsx +2 -2
  77. package/src/widgets/CapabilityMultiSelectField.tsx +4 -4
  78. package/src/widgets/CheckboxesField.tsx +12 -9
  79. package/src/widgets/CodeField.tsx +2 -2
  80. package/src/widgets/ColorField.tsx +2 -2
  81. package/src/widgets/CurrencyField.tsx +2 -2
  82. package/src/widgets/DateField.tsx +2 -2
  83. package/src/widgets/DateTimeField.tsx +2 -2
  84. package/src/widgets/EmailField.tsx +2 -2
  85. package/src/widgets/FileField.tsx +90 -59
  86. package/src/widgets/FilterConditionField.tsx +112 -12
  87. package/src/widgets/FormulaField.tsx +2 -2
  88. package/src/widgets/GeolocationField.tsx +2 -2
  89. package/src/widgets/GridField.tsx +3 -3
  90. package/src/widgets/ImageField.tsx +73 -35
  91. package/src/widgets/ImageLightbox.test.tsx +52 -0
  92. package/src/widgets/ImageLightbox.tsx +108 -0
  93. package/src/widgets/LocationField.tsx +2 -2
  94. package/src/widgets/LookupField.hydration.test.tsx +136 -0
  95. package/src/widgets/LookupField.idField.test.tsx +61 -0
  96. package/src/widgets/LookupField.tsx +116 -19
  97. package/src/widgets/MasterDetailField.tsx +2 -2
  98. package/src/widgets/MultiSelectField.tsx +10 -7
  99. package/src/widgets/NumberField.tsx +2 -2
  100. package/src/widgets/ObjectField.tsx +2 -2
  101. package/src/widgets/ObjectRefField.tsx +13 -8
  102. package/src/widgets/PasswordField.tsx +2 -2
  103. package/src/widgets/PeoplePicker.test.tsx +20 -0
  104. package/src/widgets/PeoplePicker.tsx +15 -3
  105. package/src/widgets/PercentField.tsx +2 -2
  106. package/src/widgets/PhoneField.tsx +2 -2
  107. package/src/widgets/QRCodeField.tsx +2 -2
  108. package/src/widgets/RadioField.tsx +10 -7
  109. package/src/widgets/RatingField.tsx +2 -2
  110. package/src/widgets/RecipientPickerField.test.tsx +157 -0
  111. package/src/widgets/RecipientPickerField.tsx +40 -20
  112. package/src/widgets/RichTextField.tsx +2 -2
  113. package/src/widgets/SelectField.tsx +5 -5
  114. package/src/widgets/SignatureField.tsx +2 -2
  115. package/src/widgets/SliderField.tsx +2 -2
  116. package/src/widgets/SummaryField.tsx +2 -2
  117. package/src/widgets/TagsField.tsx +3 -3
  118. package/src/widgets/TextAreaField.tsx +6 -3
  119. package/src/widgets/TextField.tsx +2 -2
  120. package/src/widgets/TimeField.tsx +2 -2
  121. package/src/widgets/UrlField.tsx +2 -2
  122. package/src/widgets/UserField.tsx +2 -2
  123. package/src/widgets/VectorField.tsx +2 -2
  124. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
  125. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
  126. package/src/widgets/file-value.test.ts +230 -0
  127. package/src/widgets/file-value.ts +246 -0
  128. package/src/widgets/types.ts +129 -6
  129. package/src/widgets/useFieldTranslation.ts +30 -0
@@ -1,7 +1,7 @@
1
1
  import React, { useId, useEffect } from 'react';
2
2
  import { Checkbox, Label, EmptyValue, Badge } from '@object-ui/components';
3
3
  import type { OptionLike } from '@object-ui/core';
4
- import { FieldWidgetProps } from './types';
4
+ import { FieldWidgetComponentProps } from './types';
5
5
  import { useCascadingOptions } from './useCascadingOptions';
6
6
 
7
7
  type Option = OptionLike;
@@ -30,12 +30,12 @@ export function CheckboxesField({
30
30
  emptyHint: _emptyHint,
31
31
  dataSource: _dataSource,
32
32
  ...props
33
- }: FieldWidgetProps<string[]>) {
33
+ }: FieldWidgetComponentProps<string[]>) {
34
34
  const config = (field || schema) as any;
35
35
  const rawOptions: Option[] = config?.options || [];
36
36
  const selected: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
37
37
  const groupId = useId();
38
- const fieldName = (props as any).name || config?.name || (props as any).id || '';
38
+ const fieldName = props.name || config?.name || props.id || '';
39
39
 
40
40
  const dependsOn = config?.dependsOn ?? dependsOnProp;
41
41
  const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
@@ -97,15 +97,18 @@ export function CheckboxesField({
97
97
  data-testid={fieldName ? `checkboxes-${fieldName}` : undefined}
98
98
  >
99
99
  {options.map((opt) => {
100
- const id = `${groupId}-${opt.value}`;
100
+ // Multi-value fields store string arrays — stringify the (possibly
101
+ // numeric, #3090-widened) authored value at this boundary.
102
+ const value = String(opt.value);
103
+ const id = `${groupId}-${value}`;
101
104
  return (
102
- <div key={opt.value} className="flex items-center space-x-2 py-0.5">
105
+ <div key={value} className="flex items-center space-x-2 py-0.5">
103
106
  <Checkbox
104
107
  id={id}
105
- checked={selected.includes(opt.value)}
106
- onCheckedChange={(checked) => toggle(opt.value, !!checked)}
107
- disabled={(props as any).disabled}
108
- data-testid={`checkboxes-option-${opt.value}`}
108
+ checked={selected.includes(value)}
109
+ onCheckedChange={(checked) => toggle(value, !!checked)}
110
+ disabled={props.disabled}
111
+ data-testid={`checkboxes-option-${value}`}
109
112
  />
110
113
  <Label htmlFor={id} className="font-normal">{opt.label}</Label>
111
114
  </div>
@@ -1,13 +1,13 @@
1
1
  import React from 'react';
2
2
  import { Textarea, cn, EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
4
 
5
5
  /**
6
6
  * Code field widget - provides a code editor with syntax highlighting
7
7
  * Uses a simple textarea with monospace font
8
8
  * For advanced code editing, use the @object-ui/plugin-editor component
9
9
  */
10
- export function CodeField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
10
+ export function CodeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
11
11
  const config = field || (props as any).schema;
12
12
  // Get code-specific configuration from field metadata
13
13
  const language = (config as any)?.language ?? 'javascript';
@@ -1,12 +1,12 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
4
 
5
5
  /**
6
6
  * Color field widget - provides a color picker input
7
7
  * Supports hex color values (e.g., #ff0000)
8
8
  */
9
- export function ColorField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
9
+ export function ColorField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
10
10
  const colorField = (field || (props as any).schema) as any;
11
11
 
12
12
  if (readonly) {
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
4
  import { useLocalization } from '@object-ui/i18n';
5
5
  import { resolveFieldCurrency } from '../currency';
6
6
 
@@ -36,7 +36,7 @@ const CURRENCY_SYMBOLS: Record<string, string> = {
36
36
  USD: '$',
37
37
  };
38
38
 
39
- export function CurrencyField({ value, onChange, field, readonly, errorMessage, className, ...props }: FieldWidgetProps<number>) {
39
+ export function CurrencyField({ value, onChange, field, readonly, errorMessage, className, ...props }: FieldWidgetComponentProps<number>) {
40
40
  const currencyField = (field || (props as any).schema) as any;
41
41
  // Shared precedence: field currency → currencyConfig → tenant default (ADR-0053).
42
42
  const { currency: tenantCurrency } = useLocalization();
@@ -1,13 +1,13 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
4
  import { openNativePicker } from './openNativePicker';
5
5
 
6
6
  /**
7
7
  * DateField - Date picker input widget
8
8
  * Uses native date input and displays locale-formatted date in readonly mode
9
9
  */
10
- export function DateField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
10
+ export function DateField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
11
11
  if (readonly) {
12
12
  return value ? <span className="text-sm">{new Date(value).toLocaleDateString()}</span> : <EmptyValue />;
13
13
  }
@@ -1,13 +1,13 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
4
  import { openNativePicker } from './openNativePicker';
5
5
 
6
6
  /**
7
7
  * DateTimeField - Combined date and time picker widget
8
8
  * Displays both date and time in locale format when readonly
9
9
  */
10
- export function DateTimeField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
10
+ export function DateTimeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
11
11
  if (readonly) {
12
12
  if (!value) return <EmptyValue />;
13
13
  const date = new Date(value);
@@ -1,12 +1,12 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
4
 
5
5
  /**
6
6
  * EmailField - Email address input with mailto link in readonly mode
7
7
  * Renders as a clickable mailto link when readonly, standard email input otherwise
8
8
  */
9
- export function EmailField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<string>) {
9
+ export function EmailField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetComponentProps<string>) {
10
10
  const config = field || (props as any).schema;
11
11
  if (readonly) {
12
12
  if (!value) return <EmptyValue />;
@@ -1,9 +1,18 @@
1
1
  import React, { useRef, useState, useCallback } from 'react';
2
2
  import { Button, EmptyValue } from '@object-ui/components';
3
3
  import { useUpload } from '@object-ui/providers';
4
+ import { useObjectTranslation } from '@object-ui/i18n';
4
5
  import { Upload, X, File as FileIcon, ImageIcon, Camera, Loader2 } from 'lucide-react';
5
- import { FieldWidgetProps } from './types';
6
+ import { FieldWidgetComponentProps } from './types';
6
7
  import { useUploadingSignal } from './useUploadingSignal';
8
+ import {
9
+ fileValueForSubmit,
10
+ readFileValues,
11
+ uploadResultView,
12
+ withRecentUploads,
13
+ isImageValue,
14
+ type FileValueView,
15
+ } from './file-value';
7
16
 
8
17
  /**
9
18
  * Shared upload pipeline for the file widgets: validates size, uploads through
@@ -11,6 +20,13 @@ import { useUploadingSignal } from './useUploadingSignal';
11
20
  * into the field value — append for `multiple`, replace otherwise. Extracted so
12
21
  * the full-size FileField and the compact grid-cell {@link FileCell} stay
13
22
  * behaviourally identical (same value shape, same error handling).
23
+ *
24
+ * Stores the **reference form** — a bare `sys_file` id — when the upload
25
+ * adapter surfaced one, and the legacy inline blob when it did not, so the same
26
+ * build works against a backend that has adopted file-as-reference and one that
27
+ * has not (see `file-value`). Because a bare id carries no name or URL, each
28
+ * completed upload's display details are kept in `recent`, keyed by id, so the
29
+ * file renders immediately instead of as a bare token until a read enriches it.
14
30
  */
15
31
  function useFileUploads(opts: {
16
32
  files: any[];
@@ -20,9 +36,11 @@ function useFileUploads(opts: {
20
36
  }) {
21
37
  const { files, multiple, maxSize, onChange } = opts;
22
38
  const { upload } = useUpload();
39
+ const { t } = useObjectTranslation();
23
40
  const [errors, setErrors] = useState<string[]>([]);
24
41
  const [uploadProgress, setUploadProgress] = useState<Record<string, number>>({});
25
42
  const [uploading, setUploading] = useState(false);
43
+ const [recent, setRecent] = useState<Record<string, FileValueView>>({});
26
44
 
27
45
  const processFiles = useCallback(async (selectedFiles: File[]) => {
28
46
  if (selectedFiles.length === 0) return;
@@ -31,7 +49,10 @@ function useFileUploads(opts: {
31
49
  const validFiles = selectedFiles.filter(file => {
32
50
  if (maxSize && file.size > maxSize) {
33
51
  const maxMB = (maxSize / (1024 * 1024)).toFixed(1);
34
- newErrors.push(`"${file.name}" exceeds max size (${maxMB} MB)`);
52
+ newErrors.push(t('fields.file.exceedsMaxSize', {
53
+ defaultValue: `"${file.name}" exceeds max size (${maxMB} MB)`,
54
+ name: file.name, max: maxMB,
55
+ }));
35
56
  return false;
36
57
  }
37
58
  return true;
@@ -42,48 +63,51 @@ function useFileUploads(opts: {
42
63
 
43
64
  setUploading(true);
44
65
  try {
45
- const fileObjects = await Promise.all(
66
+ const uploaded = await Promise.all(
46
67
  validFiles.map(async (file) => {
47
68
  try {
48
69
  const result = await upload(file, {
49
70
  onProgress: (ratio) =>
50
71
  setUploadProgress((prev) => ({ ...prev, [file.name]: ratio })),
51
72
  });
52
- return {
53
- // `file_id` is the storage id the backend keys attachments by
54
- // (the adapter returns it as `meta.fileId`); surfacing it here
55
- // lets callers that need the id — e.g. an action param POSTing
56
- // `attachments: string[]` — recover it without a second lookup.
57
- // Extra key; the record file-field value shape is unchanged.
58
- file_id: (result.meta as { fileId?: string } | undefined)?.fileId,
59
- name: result.name,
60
- original_name: file.name,
61
- size: result.size,
62
- mime_type: result.mimeType,
63
- url: result.url,
64
- };
73
+ return { result, originalName: file.name };
65
74
  } catch (err) {
66
- newErrors.push(`Failed to upload "${file.name}": ${(err as Error).message}`);
75
+ newErrors.push(t('fields.file.uploadFailed', {
76
+ defaultValue: `Failed to upload "${file.name}": ${(err as Error).message}`,
77
+ name: file.name, error: (err as Error).message,
78
+ }));
67
79
  setErrors([...newErrors]);
68
80
  return null;
69
81
  }
70
82
  }),
71
83
  );
72
- const successful = fileObjects.filter(Boolean) as any[];
84
+ const successful = uploaded.filter(Boolean) as Array<{ result: any; originalName: string }>;
73
85
  if (successful.length === 0) return;
74
86
 
87
+ // Remember what each new id looks like so it can render before the next
88
+ // read expands it back into `{ id, name, size, mimeType, url }`.
89
+ const views: Record<string, FileValueView> = {};
90
+ for (const { result, originalName } of successful) {
91
+ const view = uploadResultView(result, originalName);
92
+ if (view.id) views[view.id] = view;
93
+ }
94
+ if (Object.keys(views).length > 0) setRecent((prev) => ({ ...prev, ...views }));
95
+
96
+ const nextValues = successful.map(({ result, originalName }) =>
97
+ fileValueForSubmit(result, originalName),
98
+ );
75
99
  if (multiple) {
76
- onChange([...files, ...successful]);
100
+ onChange([...files, ...nextValues]);
77
101
  } else {
78
- onChange(successful[0]);
102
+ onChange(nextValues[0]);
79
103
  }
80
104
  } finally {
81
105
  setUploading(false);
82
106
  setUploadProgress({});
83
107
  }
84
- }, [files, multiple, onChange, maxSize, upload]);
108
+ }, [files, multiple, onChange, maxSize, upload, t]);
85
109
 
86
- return { processFiles, errors, uploading, uploadProgress };
110
+ return { processFiles, errors, uploading, uploadProgress, recent };
87
111
  }
88
112
 
89
113
  /**
@@ -91,7 +115,8 @@ function useFileUploads(opts: {
91
115
  * Supports single and multiple file uploads with configurable accepted file types.
92
116
  * L2: File size validation, per-file progress indicators, error messages.
93
117
  */
94
- export function FileField({ value, onChange, field, readonly, onUploadingChange, ...props }: FieldWidgetProps<any>) {
118
+ export function FileField({ value, onChange, field, readonly, onUploadingChange, ...props }: FieldWidgetComponentProps<any>) {
119
+ const { t } = useObjectTranslation();
95
120
  const inputRef = useRef<HTMLInputElement>(null);
96
121
  const cameraRef = useRef<HTMLInputElement>(null);
97
122
  const fileField = (field || (props as any).schema) as any;
@@ -115,9 +140,13 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
115
140
  const [isDragOver, setIsDragOver] = useState(false);
116
141
 
117
142
  const files = value ? (Array.isArray(value) ? value : [value]) : [];
118
- const { processFiles, errors, uploading, uploadProgress } = useFileUploads({
143
+ const { processFiles, errors, uploading, uploadProgress, recent } = useFileUploads({
119
144
  files, multiple, maxSize, onChange,
120
145
  });
146
+ const fallbackName = t('fields.file.fileFallback', { defaultValue: 'File' });
147
+ // Normalised for display: accepts a bare reference id, the expanded
148
+ // `{ id, name, size, mimeType, url }`, or a legacy inline blob.
149
+ const views = withRecentUploads(readFileValues(value, fallbackName), recent);
121
150
  useUploadingSignal(uploading, onUploadingChange);
122
151
 
123
152
  const handleDragOver = useCallback((e: React.DragEvent) => {
@@ -155,13 +184,12 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
155
184
 
156
185
  if (readonly) {
157
186
  if (!value) return <EmptyValue />;
158
-
159
- const readonlyFiles = Array.isArray(value) ? value : [value];
187
+
160
188
  return (
161
189
  <div className="flex flex-wrap gap-2">
162
- {readonlyFiles.map((file: any, idx: number) => (
190
+ {views.map((file, idx) => (
163
191
  <span key={idx} className="text-sm truncate max-w-xs">
164
- {file.name || file.original_name || 'File'}
192
+ {file.name}
165
193
  </span>
166
194
  ))}
167
195
  </div>
@@ -181,11 +209,6 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
181
209
  }
182
210
  };
183
211
 
184
- const isImage = (file: any) => {
185
- const mime = file.mime_type || '';
186
- return mime.startsWith('image/');
187
- };
188
-
189
212
  return (
190
213
  <div className={props.className}>
191
214
  <input
@@ -204,7 +227,7 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
204
227
  capture={cameraEnabled}
205
228
  onChange={handleFileChange}
206
229
  className="hidden"
207
- aria-label="Camera capture"
230
+ aria-label={t('fields.file.cameraCapture', { defaultValue: 'Camera capture' })}
208
231
  data-testid="file-field-camera-input"
209
232
  />
210
233
  )}
@@ -236,10 +259,14 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
236
259
  <Upload className={`size-8 ${isDragOver ? 'text-primary' : 'text-muted-foreground'}`} />
237
260
  <div className="text-center">
238
261
  <p className="text-sm font-medium">
239
- {isDragOver ? 'Drop files here' : 'Drag & drop files here'}
262
+ {isDragOver
263
+ ? t('fields.file.dropFilesHere', { defaultValue: 'Drop files here' })
264
+ : t('fields.file.dragDropHere', { defaultValue: 'Drag & drop files here' })}
240
265
  </p>
241
266
  <p className="text-xs text-muted-foreground mt-1">
242
- or click to browse{cameraEnabled ? ' • use the camera button below' : ''}
267
+ {cameraEnabled
268
+ ? t('fields.file.browseHintCamera', { defaultValue: 'or click to browse • use the camera button below' })
269
+ : t('fields.file.browseHint', { defaultValue: 'or click to browse' })}
243
270
  </p>
244
271
  </div>
245
272
  </div>
@@ -257,7 +284,9 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
257
284
  data-testid="file-field-camera-button"
258
285
  >
259
286
  <Camera className="size-4 mr-2" />
260
- {cameraEnabled === 'user' ? 'Take selfie' : 'Take photo'}
287
+ {cameraEnabled === 'user'
288
+ ? t('fields.file.takeSelfie', { defaultValue: 'Take selfie' })
289
+ : t('fields.file.takePhoto', { defaultValue: 'Take photo' })}
261
290
  </Button>
262
291
  )}
263
292
 
@@ -266,12 +295,14 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
266
295
  <div className="flex items-center gap-2 text-xs text-muted-foreground" data-testid="file-field-uploading">
267
296
  <Loader2 className="size-3 animate-spin" />
268
297
  <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
- )}%)`}
298
+ {(() => {
299
+ const keys = Object.keys(uploadProgress);
300
+ if (keys.length === 0) return t('fields.file.uploading', { defaultValue: 'Uploading…' });
301
+ const pct = Math.round(
302
+ (Object.values(uploadProgress).reduce((s, v) => s + v, 0) / keys.length) * 100,
303
+ );
304
+ return t('fields.file.uploadingPct', { defaultValue: `Uploading… (${pct}%)`, pct });
305
+ })()}
275
306
  </span>
276
307
  </div>
277
308
  )}
@@ -286,23 +317,23 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
286
317
  )}
287
318
 
288
319
  {/* File list */}
289
- {files.length > 0 && (
320
+ {views.length > 0 && (
290
321
  <div className="space-y-1">
291
- {files.map((file: any, idx: number) => (
322
+ {views.map((file, idx) => (
292
323
  <div
293
324
  key={idx}
294
325
  className="flex items-center justify-between gap-2 p-2 bg-muted/50 rounded-md border"
295
326
  >
296
327
  <div className="flex items-center gap-2 flex-1 min-w-0">
297
- {isImage(file) && file.url ? (
328
+ {isImageValue(file) && file.url ? (
298
329
  <img src={file.url} alt={file.name} className="size-8 object-cover rounded shrink-0" />
299
- ) : isImage(file) ? (
330
+ ) : isImageValue(file) ? (
300
331
  <ImageIcon className="size-4 text-muted-foreground shrink-0" />
301
332
  ) : (
302
333
  <FileIcon className="size-4 text-muted-foreground shrink-0" />
303
334
  )}
304
335
  <span className="text-sm truncate">
305
- {file.name || file.original_name || 'File'}
336
+ {file.name}
306
337
  </span>
307
338
  {file.size && (
308
339
  <span className="text-xs text-muted-foreground">
@@ -362,11 +393,14 @@ export function FileCell({
362
393
  /** Focus-grid coordinate (see GridField keyboard navigation). */
363
394
  'data-cell'?: string;
364
395
  }) {
396
+ const { t } = useObjectTranslation();
365
397
  const inputRef = useRef<HTMLInputElement>(null);
366
398
  const files = value ? (Array.isArray(value) ? value : [value]) : [];
367
- const { processFiles, errors, uploading } = useFileUploads({
399
+ const { processFiles, errors, uploading, recent } = useFileUploads({
368
400
  files, multiple: !!multiple, maxSize, onChange,
369
401
  });
402
+ const fallbackName = t('fields.file.fileFallback', { defaultValue: 'File' });
403
+ const views = withRecentUploads(readFileValues(value, fallbackName), recent);
370
404
 
371
405
  const removeAt = (index: number) => {
372
406
  if (multiple) {
@@ -377,9 +411,6 @@ export function FileCell({
377
411
  }
378
412
  };
379
413
 
380
- const isImage = (file: any) => String(file?.mime_type || '').startsWith('image/');
381
- const nameOf = (file: any) =>
382
- typeof file === 'string' ? file : file?.name || file?.original_name || 'File';
383
414
  const showUpload = !disabled && !uploading && (multiple || files.length === 0);
384
415
 
385
416
  return (
@@ -395,24 +426,24 @@ export function FileCell({
395
426
  }}
396
427
  className="hidden"
397
428
  />
398
- {files.map((file: any, idx: number) => (
429
+ {views.map((file, idx) => (
399
430
  <span
400
431
  key={idx}
401
432
  className="inline-flex max-w-40 items-center gap-1 rounded border bg-muted/50 px-1 py-0.5 text-xs"
402
- title={nameOf(file)}
433
+ title={file.name}
403
434
  data-testid="file-cell-chip"
404
435
  >
405
- {isImage(file) && file.url ? (
406
- <img src={file.url} alt={nameOf(file)} className="size-5 shrink-0 rounded object-cover" />
436
+ {isImageValue(file) && file.url ? (
437
+ <img src={file.url} alt={file.name} className="size-5 shrink-0 rounded object-cover" />
407
438
  ) : (
408
439
  <FileIcon className="size-3 shrink-0 text-muted-foreground" />
409
440
  )}
410
- <span className="truncate">{nameOf(file)}</span>
441
+ <span className="truncate">{file.name}</span>
411
442
  {!disabled && (
412
443
  <button
413
444
  type="button"
414
445
  className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
415
- aria-label={`Remove ${nameOf(file)}`}
446
+ aria-label={t('fields.file.remove', { defaultValue: `Remove ${file.name}`, name: file.name })}
416
447
  onClick={() => removeAt(idx)}
417
448
  >
418
449
  <X className="size-3" />
@@ -440,7 +471,7 @@ export function FileCell({
440
471
  disabled={disabled}
441
472
  >
442
473
  <Upload className="size-3.5" />
443
- {files.length === 0 && 'Upload'}
474
+ {files.length === 0 && t('fields.file.upload', { defaultValue: 'Upload' })}
444
475
  </Button>
445
476
  )}
446
477
  {errors.length > 0 && (