@object-ui/fields 16.1.0 → 17.1.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.
- package/CHANGELOG.md +432 -0
- package/dist/FieldEditWidget.d.ts +7 -0
- package/dist/field-type-alias.d.ts +16 -0
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +14 -11
- package/dist/index.js +2428 -2100
- package/dist/widgets/FilterConditionField.d.ts +32 -0
- package/dist/widgets/ImageLightbox.d.ts +30 -0
- package/dist/widgets/ImageLightbox.test.d.ts +1 -0
- package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
- package/dist/widgets/LookupField.idField.test.d.ts +1 -0
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +8 -0
- package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +8 -0
- package/dist/widgets/file-value.d.ts +112 -0
- package/dist/widgets/file-value.test.d.ts +1 -0
- package/dist/widgets/useFieldTranslation.d.ts +2 -0
- package/package.json +9 -8
- package/src/FieldEditWidget.test.ts +74 -2
- package/src/FieldEditWidget.tsx +44 -4
- package/src/field-type-alias.ts +100 -0
- package/src/index.tsx +94 -133
- package/src/widgets/CheckboxesField.tsx +8 -5
- package/src/widgets/FileField.tsx +88 -57
- package/src/widgets/FilterConditionField.tsx +109 -9
- package/src/widgets/ImageField.tsx +71 -33
- package/src/widgets/ImageLightbox.test.tsx +52 -0
- package/src/widgets/ImageLightbox.tsx +108 -0
- package/src/widgets/LookupField.hydration.test.tsx +136 -0
- package/src/widgets/LookupField.idField.test.tsx +61 -0
- package/src/widgets/LookupField.tsx +106 -9
- package/src/widgets/MultiSelectField.tsx +6 -3
- package/src/widgets/ObjectRefField.tsx +9 -4
- package/src/widgets/PeoplePicker.test.tsx +20 -0
- package/src/widgets/PeoplePicker.tsx +15 -3
- package/src/widgets/RadioField.tsx +6 -3
- package/src/widgets/RecipientPickerField.test.tsx +157 -0
- package/src/widgets/RecipientPickerField.tsx +36 -16
- package/src/widgets/TextAreaField.tsx +4 -1
- package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
- package/src/widgets/file-value.test.ts +230 -0
- package/src/widgets/file-value.ts +230 -0
- package/src/widgets/useFieldTranslation.ts +30 -0
package/src/index.tsx
CHANGED
|
@@ -303,6 +303,8 @@ import { PercentField } from './widgets/PercentField';
|
|
|
303
303
|
import { PasswordField } from './widgets/PasswordField';
|
|
304
304
|
import { FileField } from './widgets/FileField';
|
|
305
305
|
import { ImageField } from './widgets/ImageField';
|
|
306
|
+
import { ImageLightbox } from './widgets/ImageLightbox';
|
|
307
|
+
import { readFileValues } from './widgets/file-value';
|
|
306
308
|
import { LocationField } from './widgets/LocationField';
|
|
307
309
|
import { FormulaField } from './widgets/FormulaField';
|
|
308
310
|
import { SummaryField } from './widgets/SummaryField';
|
|
@@ -506,7 +508,7 @@ function formatRelativeDays(diffDays: number, locale?: string): string {
|
|
|
506
508
|
|
|
507
509
|
/**
|
|
508
510
|
* Format date as relative time (e.g., "3 days ago", "Today", "Overdue 3d"),
|
|
509
|
-
* localized via `Intl.RelativeTimeFormat` (objectstack-ai/
|
|
511
|
+
* localized via `Intl.RelativeTimeFormat` (objectstack-ai/objectstack#3040).
|
|
510
512
|
*
|
|
511
513
|
* `dueLike` gates the "Overdue" wording — a past `start_date`/`created_at`
|
|
512
514
|
* isn't overdue, only a past due/deadline-semantic field is. Non-due-like
|
|
@@ -1268,50 +1270,88 @@ export function FileCellRenderer({ value, field }: CellRendererProps): React.Rea
|
|
|
1268
1270
|
}
|
|
1269
1271
|
|
|
1270
1272
|
/**
|
|
1271
|
-
* Image field cell renderer (with thumbnails)
|
|
1273
|
+
* Image field cell renderer (with thumbnails + click-to-zoom).
|
|
1274
|
+
*
|
|
1275
|
+
* An image value may be a plain URL string, an object ({ url | src | href … }),
|
|
1276
|
+
* a bare `sys_file` id, or an array of any of those. Normalising through
|
|
1277
|
+
* `readFileValues` (which resolves a bare id to its stable download URL) means
|
|
1278
|
+
* a string-URL field, a CDN link, and an unexpanded reference all render a
|
|
1279
|
+
* thumbnail instead of a broken `<img src="">` placeholder.
|
|
1280
|
+
*
|
|
1281
|
+
* Clicking a thumbnail opens a full-screen lightbox (single or gallery). The
|
|
1282
|
+
* click is `stopPropagation`-guarded so, inside a grid row, enlarging an image
|
|
1283
|
+
* doesn't also trigger row navigation.
|
|
1272
1284
|
*/
|
|
1273
1285
|
export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
1274
|
-
|
|
1286
|
+
const { t } = useObjectTranslation();
|
|
1287
|
+
const [lightboxIndex, setLightboxIndex] = React.useState<number | null>(null);
|
|
1288
|
+
|
|
1289
|
+
const imgs = React.useMemo(
|
|
1290
|
+
() =>
|
|
1291
|
+
readFileValues(value, 'Image')
|
|
1292
|
+
.filter((v) => v.url)
|
|
1293
|
+
.map((v) => ({ url: v.url as string, name: v.name })),
|
|
1294
|
+
[value],
|
|
1295
|
+
);
|
|
1275
1296
|
|
|
1276
|
-
|
|
1277
|
-
// or an array of either. Normalize so a string-URL field (common — e.g. a
|
|
1278
|
-
// `cover` seeded with a CDN link) renders a thumbnail instead of a broken
|
|
1279
|
-
// <img src=""> placeholder.
|
|
1280
|
-
const urlOf = (v: any): string =>
|
|
1281
|
-
typeof v === 'string' ? v : (v?.url || v?.src || v?.href || v?.thumbnailUrl || '');
|
|
1282
|
-
const nameOf = (v: any, fallback: string): string =>
|
|
1283
|
-
(v && typeof v === 'object' && (v.name || v.original_name)) || fallback;
|
|
1297
|
+
if (!value || imgs.length === 0) return <EmptyValue />;
|
|
1284
1298
|
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1299
|
+
const imageAlt = (idx: number, name?: string) =>
|
|
1300
|
+
name || t('fields.image.imageAlt', { index: idx + 1 });
|
|
1301
|
+
const open = (idx: number) => (e: React.MouseEvent) => {
|
|
1302
|
+
e.stopPropagation();
|
|
1303
|
+
setLightboxIndex(idx);
|
|
1304
|
+
};
|
|
1305
|
+
const multiple = imgs.length > 1;
|
|
1306
|
+
|
|
1307
|
+
const lightbox = lightboxIndex !== null && (
|
|
1308
|
+
<ImageLightbox
|
|
1309
|
+
images={imgs}
|
|
1310
|
+
index={lightboxIndex}
|
|
1311
|
+
open
|
|
1312
|
+
onOpenChange={(o) => !o && setLightboxIndex(null)}
|
|
1313
|
+
onIndexChange={setLightboxIndex}
|
|
1314
|
+
/>
|
|
1315
|
+
);
|
|
1316
|
+
|
|
1317
|
+
if (multiple) {
|
|
1288
1318
|
return (
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1319
|
+
<>
|
|
1320
|
+
<div className="flex -space-x-2">
|
|
1321
|
+
{imgs.slice(0, 3).map((img, idx) => (
|
|
1322
|
+
<img
|
|
1323
|
+
key={idx}
|
|
1324
|
+
src={img.url}
|
|
1325
|
+
alt={imageAlt(idx, img.name)}
|
|
1326
|
+
onClick={open(idx)}
|
|
1327
|
+
className="size-8 cursor-zoom-in rounded-md border-2 border-background object-cover transition-transform hover:scale-110 hover:z-10"
|
|
1328
|
+
/>
|
|
1329
|
+
))}
|
|
1330
|
+
{imgs.length > 3 && (
|
|
1331
|
+
<button
|
|
1332
|
+
type="button"
|
|
1333
|
+
onClick={open(3)}
|
|
1334
|
+
className="size-8 rounded-md border-2 border-background bg-muted flex items-center justify-center text-xs font-medium text-muted-foreground hover:bg-muted/80"
|
|
1335
|
+
>
|
|
1336
|
+
+{imgs.length - 3}
|
|
1337
|
+
</button>
|
|
1338
|
+
)}
|
|
1339
|
+
</div>
|
|
1340
|
+
{lightbox}
|
|
1341
|
+
</>
|
|
1304
1342
|
);
|
|
1305
1343
|
}
|
|
1306
1344
|
|
|
1307
|
-
const url = urlOf(value);
|
|
1308
|
-
if (!url) return <EmptyValue />;
|
|
1309
1345
|
return (
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1346
|
+
<>
|
|
1347
|
+
<img
|
|
1348
|
+
src={imgs[0].url}
|
|
1349
|
+
alt={imageAlt(0, imgs[0].name)}
|
|
1350
|
+
onClick={open(0)}
|
|
1351
|
+
className="size-10 cursor-zoom-in rounded-md object-cover transition-transform hover:scale-105"
|
|
1352
|
+
/>
|
|
1353
|
+
{lightbox}
|
|
1354
|
+
</>
|
|
1315
1355
|
);
|
|
1316
1356
|
}
|
|
1317
1357
|
|
|
@@ -1823,98 +1863,10 @@ setCellRendererResolver(getCellRenderer);
|
|
|
1823
1863
|
|
|
1824
1864
|
|
|
1825
1865
|
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
* (for example: `"text"`, `"number"`, `"date"`, `"lookup"`).
|
|
1831
|
-
* @returns The normalized form field type string used in the form schema
|
|
1832
|
-
* (for example: `"input"`, `"textarea"`, `"date-picker"`, `"select"`).
|
|
1833
|
-
*/
|
|
1834
|
-
export function mapFieldTypeToFormType(fieldType: string): string {
|
|
1835
|
-
const typeMap: Record<string, string> = {
|
|
1836
|
-
// Text-based fields
|
|
1837
|
-
text: 'field:text',
|
|
1838
|
-
textarea: 'field:textarea',
|
|
1839
|
-
markdown: 'field:markdown', // Markdown editor (fallback to textarea)
|
|
1840
|
-
html: 'field:html', // Rich text editor (fallback to textarea)
|
|
1841
|
-
richtext: 'field:richtext', // WYSIWYG rich-text editor
|
|
1842
|
-
secret: 'field:password', // encrypted-at-rest value — mask input like a password
|
|
1843
|
-
|
|
1844
|
-
// Numeric fields
|
|
1845
|
-
number: 'field:number',
|
|
1846
|
-
currency: 'field:currency',
|
|
1847
|
-
percent: 'field:percent',
|
|
1848
|
-
slider: 'field:slider',
|
|
1849
|
-
progress: 'field:slider', // bounded 0..100 progress — edit via slider
|
|
1850
|
-
rating: 'field:rating',
|
|
1851
|
-
|
|
1852
|
-
// Date/Time fields
|
|
1853
|
-
date: 'field:date',
|
|
1854
|
-
datetime: 'field:datetime',
|
|
1855
|
-
time: 'field:time',
|
|
1856
|
-
|
|
1857
|
-
// Boolean
|
|
1858
|
-
boolean: 'field:boolean',
|
|
1859
|
-
toggle: 'field:boolean', // toggle is a boolean rendered as a switch
|
|
1860
|
-
|
|
1861
|
-
// Selection fields
|
|
1862
|
-
select: 'field:select',
|
|
1863
|
-
multiselect: 'field:multiselect',
|
|
1864
|
-
radio: 'field:radio',
|
|
1865
|
-
checkboxes: 'field:checkboxes',
|
|
1866
|
-
tags: 'field:tags',
|
|
1867
|
-
lookup: 'field:lookup',
|
|
1868
|
-
master_detail: 'field:master_detail',
|
|
1869
|
-
tree: 'field:lookup', // hierarchical reference — pick the parent via a lookup
|
|
1870
|
-
// `user` is a lookup specialized to sys_user; `owner` mirrors it (record
|
|
1871
|
-
// ownership). Both render via the UserField person-picker (delegates to the
|
|
1872
|
-
// lookup picker). Without these they would fall through to `field:text`.
|
|
1873
|
-
user: 'field:user',
|
|
1874
|
-
owner: 'field:owner',
|
|
1875
|
-
|
|
1876
|
-
// Contact fields
|
|
1877
|
-
email: 'field:email',
|
|
1878
|
-
phone: 'field:phone',
|
|
1879
|
-
url: 'field:url',
|
|
1880
|
-
|
|
1881
|
-
// File / media fields
|
|
1882
|
-
file: 'field:file',
|
|
1883
|
-
image: 'field:image',
|
|
1884
|
-
avatar: 'field:avatar',
|
|
1885
|
-
video: 'field:file', // uploads as a file
|
|
1886
|
-
audio: 'field:file', // uploads as a file
|
|
1887
|
-
signature: 'field:signature',
|
|
1888
|
-
|
|
1889
|
-
// Special / enhanced fields
|
|
1890
|
-
password: 'field:password',
|
|
1891
|
-
location: 'field:location', // Location/map field (fallback to input)
|
|
1892
|
-
geolocation: 'field:geolocation',
|
|
1893
|
-
address: 'field:address',
|
|
1894
|
-
color: 'field:color',
|
|
1895
|
-
code: 'field:code',
|
|
1896
|
-
json: 'field:code', // JSON edited in the code editor
|
|
1897
|
-
qrcode: 'field:qrcode',
|
|
1898
|
-
vector: 'field:vector',
|
|
1899
|
-
|
|
1900
|
-
// Embedded structured values (stored as JSON on the row)
|
|
1901
|
-
object: 'field:object',
|
|
1902
|
-
composite: 'field:object', // embedded object
|
|
1903
|
-
record: 'field:object', // name-keyed map
|
|
1904
|
-
repeater: 'field:grid', // embedded array of rows
|
|
1905
|
-
|
|
1906
|
-
// Auto-generated/computed fields (typically read-only)
|
|
1907
|
-
formula: 'field:formula',
|
|
1908
|
-
summary: 'field:summary',
|
|
1909
|
-
// `@objectstack/spec` spells this `autonumber`; the widget map key is
|
|
1910
|
-
// `auto_number`. Map both spellings so a spec-typed field/param doesn't
|
|
1911
|
-
// fall through to the plain text input.
|
|
1912
|
-
autonumber: 'field:auto_number',
|
|
1913
|
-
auto_number: 'field:auto_number',
|
|
1914
|
-
};
|
|
1915
|
-
|
|
1916
|
-
return typeMap[fieldType] || 'field:text';
|
|
1917
|
-
}
|
|
1866
|
+
// `mapFieldTypeToFormType` moved to './field-type-alias' (re-exported below) so
|
|
1867
|
+
// FieldEditWidget can resolve spec aliases without importing this barrel.
|
|
1868
|
+
export { mapFieldTypeToFormType } from './field-type-alias';
|
|
1869
|
+
import { mapFieldTypeToFormType } from './field-type-alias';
|
|
1918
1870
|
|
|
1919
1871
|
/**
|
|
1920
1872
|
* Formats file size in bytes to human-readable string
|
|
@@ -1966,18 +1918,23 @@ export function buildValidationRules(field: any): any {
|
|
|
1966
1918
|
// vars); a field-authored `*_message` is a string and passes through as-is,
|
|
1967
1919
|
// still winning over the localized default. See form.tsx `localizeRule`.
|
|
1968
1920
|
|
|
1969
|
-
// Length validation for text fields
|
|
1970
|
-
|
|
1921
|
+
// Length validation for text fields. The spec-canonical keys are camelCase
|
|
1922
|
+
// (`minLength`/`maxLength`, @objectstack/spec FieldSchema — what the server
|
|
1923
|
+
// record-validator enforces); the snake_case pair is the legacy objectui
|
|
1924
|
+
// spelling, kept as fallback (framework#1878/#1891 naming-drift closeout).
|
|
1925
|
+
const minLength = (field as any).minLength ?? field.min_length;
|
|
1926
|
+
if (minLength) {
|
|
1971
1927
|
rules.minLength = {
|
|
1972
|
-
value:
|
|
1928
|
+
value: minLength,
|
|
1973
1929
|
message: typeof field.min_length_message === 'string' ? field.min_length_message : undefined,
|
|
1974
1930
|
messageKey: 'validation.minLength',
|
|
1975
1931
|
};
|
|
1976
1932
|
}
|
|
1977
1933
|
|
|
1978
|
-
|
|
1934
|
+
const maxLength = (field as any).maxLength ?? field.max_length;
|
|
1935
|
+
if (maxLength) {
|
|
1979
1936
|
rules.maxLength = {
|
|
1980
|
-
value:
|
|
1937
|
+
value: maxLength,
|
|
1981
1938
|
message: typeof field.max_length_message === 'string' ? field.max_length_message : undefined,
|
|
1982
1939
|
messageKey: 'validation.maxLength',
|
|
1983
1940
|
};
|
|
@@ -2438,6 +2395,10 @@ export function registerFields() {
|
|
|
2438
2395
|
}
|
|
2439
2396
|
|
|
2440
2397
|
export * from './widgets/types';
|
|
2398
|
+
// File field value shapes (ObjectStack ADR-0104 D3 wave 2) — the single
|
|
2399
|
+
// arbiter of reference vs expanded vs legacy-blob form, shared by the upload
|
|
2400
|
+
// widgets and by action-param serialization.
|
|
2401
|
+
export * from './widgets/file-value';
|
|
2441
2402
|
export * from './FieldEditWidget';
|
|
2442
2403
|
export * from './widgets/TextField';
|
|
2443
2404
|
export * from './widgets/NumberField';
|
|
@@ -97,15 +97,18 @@ export function CheckboxesField({
|
|
|
97
97
|
data-testid={fieldName ? `checkboxes-${fieldName}` : undefined}
|
|
98
98
|
>
|
|
99
99
|
{options.map((opt) => {
|
|
100
|
-
|
|
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={
|
|
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(
|
|
106
|
-
onCheckedChange={(checked) => toggle(
|
|
108
|
+
checked={selected.includes(value)}
|
|
109
|
+
onCheckedChange={(checked) => toggle(value, !!checked)}
|
|
107
110
|
disabled={(props as any).disabled}
|
|
108
|
-
data-testid={`checkboxes-option-${
|
|
111
|
+
data-testid={`checkboxes-option-${value}`}
|
|
109
112
|
/>
|
|
110
113
|
<Label htmlFor={id} className="font-normal">{opt.label}</Label>
|
|
111
114
|
</div>
|
|
@@ -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
6
|
import { FieldWidgetProps } 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(
|
|
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
|
|
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(
|
|
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 =
|
|
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, ...
|
|
100
|
+
onChange([...files, ...nextValues]);
|
|
77
101
|
} else {
|
|
78
|
-
onChange(
|
|
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
|
/**
|
|
@@ -92,6 +116,7 @@ function useFileUploads(opts: {
|
|
|
92
116
|
* L2: File size validation, per-file progress indicators, error messages.
|
|
93
117
|
*/
|
|
94
118
|
export function FileField({ value, onChange, field, readonly, onUploadingChange, ...props }: FieldWidgetProps<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
|
-
{
|
|
190
|
+
{views.map((file, idx) => (
|
|
163
191
|
<span key={idx} className="text-sm truncate max-w-xs">
|
|
164
|
-
{file.name
|
|
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=
|
|
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
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
{
|
|
320
|
+
{views.length > 0 && (
|
|
290
321
|
<div className="space-y-1">
|
|
291
|
-
{
|
|
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
|
-
{
|
|
328
|
+
{isImageValue(file) && file.url ? (
|
|
298
329
|
<img src={file.url} alt={file.name} className="size-8 object-cover rounded shrink-0" />
|
|
299
|
-
) :
|
|
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
|
|
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
|
-
{
|
|
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={
|
|
433
|
+
title={file.name}
|
|
403
434
|
data-testid="file-cell-chip"
|
|
404
435
|
>
|
|
405
|
-
{
|
|
406
|
-
<img src={file.url} alt={
|
|
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">{
|
|
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 ${
|
|
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 && (
|