@@ -5,13 +5,28 @@ import { useObjectTranslation } from '@object-ui/i18n';
55import { Upload , X , File as FileIcon , ImageIcon , Camera , Loader2 } from 'lucide-react' ;
66import { FieldWidgetProps } from './types' ;
77import { useUploadingSignal } from './useUploadingSignal' ;
8+ import {
9+ fileValueForSubmit ,
10+ readFileValues ,
11+ uploadResultView ,
12+ withRecentUploads ,
13+ isImageValue ,
14+ type FileValueView ,
15+ } from './file-value' ;
816
917/**
1018 * Shared upload pipeline for the file widgets: validates size, uploads through
1119 * the configured UploadProvider adapter (with progress), and merges results
1220 * into the field value — append for `multiple`, replace otherwise. Extracted so
1321 * the full-size FileField and the compact grid-cell {@link FileCell} stay
1422 * 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.
1530 */
1631function useFileUploads ( opts : {
1732 files : any [ ] ;
@@ -25,6 +40,7 @@ function useFileUploads(opts: {
2540 const [ errors , setErrors ] = useState < string [ ] > ( [ ] ) ;
2641 const [ uploadProgress , setUploadProgress ] = useState < Record < string , number > > ( { } ) ;
2742 const [ uploading , setUploading ] = useState ( false ) ;
43+ const [ recent , setRecent ] = useState < Record < string , FileValueView > > ( { } ) ;
2844
2945 const processFiles = useCallback ( async ( selectedFiles : File [ ] ) => {
3046 if ( selectedFiles . length === 0 ) return ;
@@ -47,26 +63,14 @@ function useFileUploads(opts: {
4763
4864 setUploading ( true ) ;
4965 try {
50- const fileObjects = await Promise . all (
66+ const uploaded = await Promise . all (
5167 validFiles . map ( async ( file ) => {
5268 try {
5369 const result = await upload ( file , {
5470 onProgress : ( ratio ) =>
5571 setUploadProgress ( ( prev ) => ( { ...prev , [ file . name ] : ratio } ) ) ,
5672 } ) ;
57- return {
58- // `file_id` is the storage id the backend keys attachments by
59- // (the adapter returns it as `meta.fileId`); surfacing it here
60- // lets callers that need the id — e.g. an action param POSTing
61- // `attachments: string[]` — recover it without a second lookup.
62- // Extra key; the record file-field value shape is unchanged.
63- file_id : ( result . meta as { fileId ?: string } | undefined ) ?. fileId ,
64- name : result . name ,
65- original_name : file . name ,
66- size : result . size ,
67- mime_type : result . mimeType ,
68- url : result . url ,
69- } ;
73+ return { result, originalName : file . name } ;
7074 } catch ( err ) {
7175 newErrors . push ( t ( 'fields.file.uploadFailed' , {
7276 defaultValue : `Failed to upload "${ file . name } ": ${ ( err as Error ) . message } ` ,
@@ -77,21 +81,33 @@ function useFileUploads(opts: {
7781 }
7882 } ) ,
7983 ) ;
80- const successful = fileObjects . filter ( Boolean ) as any [ ] ;
84+ const successful = uploaded . filter ( Boolean ) as Array < { result : any ; originalName : string } > ;
8185 if ( successful . length === 0 ) return ;
8286
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+ ) ;
8399 if ( multiple ) {
84- onChange ( [ ...files , ...successful ] ) ;
100+ onChange ( [ ...files , ...nextValues ] ) ;
85101 } else {
86- onChange ( successful [ 0 ] ) ;
102+ onChange ( nextValues [ 0 ] ) ;
87103 }
88104 } finally {
89105 setUploading ( false ) ;
90106 setUploadProgress ( { } ) ;
91107 }
92108 } , [ files , multiple , onChange , maxSize , upload , t ] ) ;
93109
94- return { processFiles, errors, uploading, uploadProgress } ;
110+ return { processFiles, errors, uploading, uploadProgress, recent } ;
95111}
96112
97113/**
@@ -124,9 +140,13 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
124140 const [ isDragOver , setIsDragOver ] = useState ( false ) ;
125141
126142 const files = value ? ( Array . isArray ( value ) ? value : [ value ] ) : [ ] ;
127- const { processFiles, errors, uploading, uploadProgress } = useFileUploads ( {
143+ const { processFiles, errors, uploading, uploadProgress, recent } = useFileUploads ( {
128144 files, multiple, maxSize, onChange,
129145 } ) ;
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 ) ;
130150 useUploadingSignal ( uploading , onUploadingChange ) ;
131151
132152 const handleDragOver = useCallback ( ( e : React . DragEvent ) => {
@@ -164,13 +184,12 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
164184
165185 if ( readonly ) {
166186 if ( ! value ) return < EmptyValue /> ;
167-
168- const readonlyFiles = Array . isArray ( value ) ? value : [ value ] ;
187+
169188 return (
170189 < div className = "flex flex-wrap gap-2" >
171- { readonlyFiles . map ( ( file : any , idx : number ) => (
190+ { views . map ( ( file , idx ) => (
172191 < span key = { idx } className = "text-sm truncate max-w-xs" >
173- { file . name || file . original_name || t ( 'fields.file.fileFallback' , { defaultValue : 'File' } ) }
192+ { file . name }
174193 </ span >
175194 ) ) }
176195 </ div >
@@ -190,11 +209,6 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
190209 }
191210 } ;
192211
193- const isImage = ( file : any ) => {
194- const mime = file . mime_type || '' ;
195- return mime . startsWith ( 'image/' ) ;
196- } ;
197-
198212 return (
199213 < div className = { props . className } >
200214 < input
@@ -303,23 +317,23 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
303317 ) }
304318
305319 { /* File list */ }
306- { files . length > 0 && (
320+ { views . length > 0 && (
307321 < div className = "space-y-1" >
308- { files . map ( ( file : any , idx : number ) => (
322+ { views . map ( ( file , idx ) => (
309323 < div
310324 key = { idx }
311325 className = "flex items-center justify-between gap-2 p-2 bg-muted/50 rounded-md border"
312326 >
313327 < div className = "flex items-center gap-2 flex-1 min-w-0" >
314- { isImage ( file ) && file . url ? (
328+ { isImageValue ( file ) && file . url ? (
315329 < img src = { file . url } alt = { file . name } className = "size-8 object-cover rounded shrink-0" />
316- ) : isImage ( file ) ? (
330+ ) : isImageValue ( file ) ? (
317331 < ImageIcon className = "size-4 text-muted-foreground shrink-0" />
318332 ) : (
319333 < FileIcon className = "size-4 text-muted-foreground shrink-0" />
320334 ) }
321335 < span className = "text-sm truncate" >
322- { file . name || file . original_name || t ( 'fields.file.fileFallback' , { defaultValue : 'File' } ) }
336+ { file . name }
323337 </ span >
324338 { file . size && (
325339 < span className = "text-xs text-muted-foreground" >
@@ -382,9 +396,11 @@ export function FileCell({
382396 const { t } = useObjectTranslation ( ) ;
383397 const inputRef = useRef < HTMLInputElement > ( null ) ;
384398 const files = value ? ( Array . isArray ( value ) ? value : [ value ] ) : [ ] ;
385- const { processFiles, errors, uploading } = useFileUploads ( {
399+ const { processFiles, errors, uploading, recent } = useFileUploads ( {
386400 files, multiple : ! ! multiple , maxSize, onChange,
387401 } ) ;
402+ const fallbackName = t ( 'fields.file.fileFallback' , { defaultValue : 'File' } ) ;
403+ const views = withRecentUploads ( readFileValues ( value , fallbackName ) , recent ) ;
388404
389405 const removeAt = ( index : number ) => {
390406 if ( multiple ) {
@@ -395,11 +411,6 @@ export function FileCell({
395411 }
396412 } ;
397413
398- const isImage = ( file : any ) => String ( file ?. mime_type || '' ) . startsWith ( 'image/' ) ;
399- const nameOf = ( file : any ) =>
400- typeof file === 'string'
401- ? file
402- : file ?. name || file ?. original_name || t ( 'fields.file.fileFallback' , { defaultValue : 'File' } ) ;
403414 const showUpload = ! disabled && ! uploading && ( multiple || files . length === 0 ) ;
404415
405416 return (
@@ -415,24 +426,24 @@ export function FileCell({
415426 } }
416427 className = "hidden"
417428 />
418- { files . map ( ( file : any , idx : number ) => (
429+ { views . map ( ( file , idx ) => (
419430 < span
420431 key = { idx }
421432 className = "inline-flex max-w-40 items-center gap-1 rounded border bg-muted/50 px-1 py-0.5 text-xs"
422- title = { nameOf ( file ) }
433+ title = { file . name }
423434 data-testid = "file-cell-chip"
424435 >
425- { isImage ( file ) && file . url ? (
426- < 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" />
427438 ) : (
428439 < FileIcon className = "size-3 shrink-0 text-muted-foreground" />
429440 ) }
430- < span className = "truncate" > { nameOf ( file ) } </ span >
441+ < span className = "truncate" > { file . name } </ span >
431442 { ! disabled && (
432443 < button
433444 type = "button"
434445 className = "shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
435- aria-label = { t ( 'fields.file.remove' , { defaultValue : `Remove ${ nameOf ( file ) } ` , name : nameOf ( file ) } ) }
446+ aria-label = { t ( 'fields.file.remove' , { defaultValue : `Remove ${ file . name } ` , name : file . name } ) }
436447 onClick = { ( ) => removeAt ( idx ) }
437448 >
438449 < X className = "size-3" />
0 commit comments