Skip to content

Commit 697cda4

Browse files
os-zhuangclaude
andauthored
feat(fields): adopt the file-as-reference value shape — ObjectStack ADR-0104 D3 wave 2 (PR-7) (#2828)
A file/image field value now reaches the UI in one of three forms, and the rules for reading them live in one place — the new `file-value` module — instead of being re-derived in each widget: 1. Reference a bare sys_file id string, what the backend stores once file-as-reference is adopted 2. Expanded { id, name, size, mimeType, url }, what the read path returns after resolving a reference 3. Legacy blob { file_id?, name, original_name, size, mime_type, url }, the pre-reference shape this package used to build itself The casing split is the bug this fixes. The expanded form carries `mimeType`; the legacy blob carries `mime_type`. FileField, FileCell and ImageField all read only `mime_type`, so the moment a backend starts returning the expanded form they stop recognising images — thumbnails silently degrade to a generic file icon, and nothing about the symptom points at a value shape as the cause. readFileValue() accepts both, and every widget goes through it. Uploads now submit the reference form — the bare id — when the upload adapter surfaced one, and the legacy blob when it did not (the object-URL fallback adapter, or a backend predating file-as-reference), so the same build works against both. Action params already POSTed a bare fileId; record field values now use the same contract, and serializeParamValues shares the fileIdOf() extractor so the two surfaces cannot drift on what counts as an id. Because a bare id carries no name or URL of its own, each widget remembers the display details of files it just uploaded, keyed by id, so an upload renders immediately instead of showing a bare token until the next read enriches it. Claude-Session: https://claude.ai/code/session_01SHpGw3GBA9aFpfwVArRWfd Co-authored-by: Claude <noreply@anthropic.com>
1 parent 89eb682 commit 697cda4

7 files changed

Lines changed: 555 additions & 74 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@object-ui/fields": minor
3+
"@object-ui/app-shell": patch
4+
---
5+
6+
feat(fields): adopt the file-as-reference value shape (ObjectStack ADR-0104 D3 wave 2)
7+
8+
A `file`/`image` field value now reaches the UI in one of three forms, and the
9+
rules for reading them live in one place — `@object-ui/fields`' new
10+
`file-value` module — instead of being re-derived in each widget:
11+
12+
1. **Reference** — a bare `sys_file` id string, what the backend stores once
13+
file-as-reference is adopted.
14+
2. **Expanded**`{ id, name, size, mimeType, url }`, what the read path
15+
returns after resolving a reference.
16+
3. **Legacy inline blob** — `{ file_id?, name, original_name, size, mime_type,
17+
url }`, the pre-reference shape this package used to build itself.
18+
19+
**The casing split is the bug this fixes.** The expanded form carries
20+
`mimeType`; the legacy blob carries `mime_type`. `FileField`, `FileCell` and
21+
`ImageField` all read only `mime_type`, so the moment a backend starts returning
22+
the expanded form they stop recognising images — thumbnails silently degrade to
23+
a generic file icon, with nothing pointing at a value shape as the cause.
24+
`readFileValue()` accepts both.
25+
26+
**Uploads now submit the reference form** — the bare `sys_file` id — when the
27+
upload adapter surfaced one, falling back to the legacy blob when it did not
28+
(the object-URL fallback adapter, or a backend predating file-as-reference). The
29+
same build therefore works against both. Action params already POSTed a bare
30+
fileId; record field values now use the same contract, and
31+
`serializeParamValues` shares the `fileIdOf()` extractor so the two surfaces
32+
cannot drift on what counts as an id.
33+
34+
Because a bare id carries no name or URL, each widget remembers the display
35+
details of files it just uploaded, keyed by id, so an upload renders immediately
36+
rather than showing a bare token until the next read enriches it.

packages/app-shell/src/views/ActionParamDialog.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { useObjectTranslation, pickLocalized } from '@object-ui/i18n';
3232
import type { ActionParamDef } from '@object-ui/core';
3333
import { ExpressionEvaluator } from '@object-ui/core';
3434
import { usePredicateScope } from '@object-ui/react';
35-
import { getLazyFieldWidget } from '@object-ui/fields';
35+
import { getLazyFieldWidget, fileIdOf } from '@object-ui/fields';
3636
import { paramToField } from '../utils/paramToField';
3737

3838
export interface ParamDialogState {
@@ -75,12 +75,15 @@ export function filterVisibleParams(
7575

7676
/**
7777
* Serialize collected values for the request body. Upload widgets (`file` /
78-
* `image`) hold a rich `{ file_id, name, url, … }` object — or an array of them
79-
* when `multiple` — but the portable API contract is the storage id(s). Map each
80-
* upload param to its `file_id` (a bare string passes through unchanged, and an
81-
* object missing `file_id` is left intact so the failure is visible rather than
82-
* silently POSTing `undefined`). Every non-upload value is returned as-is. Pure
83-
* + exported so the mapping is unit-testable without the dialog render tree.
78+
* `image`) may hold a bare `sys_file` id — the reference form they now submit
79+
* when the adapter surfaces one — or a rich `{ file_id, name, url, … }` object,
80+
* or an array of either when `multiple`. The portable API contract is the
81+
* storage id(s), so each upload param is reduced to its id via `fileIdOf`, the
82+
* same extractor the field widgets use, so the two surfaces cannot drift on
83+
* what counts as an id. An object carrying no id is left intact, so the failure
84+
* is visible rather than silently POSTing `undefined`. Every non-upload value is
85+
* returned as-is. Pure + exported so the mapping is unit-testable without the
86+
* dialog render tree.
8487
*/
8588
export function serializeParamValues(
8689
params: ActionParamDef[],
@@ -95,8 +98,7 @@ export function serializeParamValues(
9598
.map((p) => p.name),
9699
);
97100
if (uploadNames.size === 0) return values;
98-
const toId = (item: any) =>
99-
item && typeof item === 'object' ? (item.file_id ?? item.id ?? item) : item;
101+
const toId = (item: any) => fileIdOf(item) ?? item;
100102
const out: Record<string, any> = { ...values };
101103
for (const name of uploadNames) {
102104
const v = out[name];

packages/fields/src/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2443,6 +2443,10 @@ export function registerFields() {
24432443
}
24442444

24452445
export * from './widgets/types';
2446+
// File field value shapes (ObjectStack ADR-0104 D3 wave 2) — the single
2447+
// arbiter of reference vs expanded vs legacy-blob form, shared by the upload
2448+
// widgets and by action-param serialization.
2449+
export * from './widgets/file-value';
24462450
export * from './FieldEditWidget';
24472451
export * from './widgets/TextField';
24482452
export * from './widgets/NumberField';

packages/fields/src/widgets/FileField.tsx

Lines changed: 56 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,28 @@ import { useObjectTranslation } from '@object-ui/i18n';
55
import { Upload, X, File as FileIcon, ImageIcon, Camera, Loader2 } from 'lucide-react';
66
import { FieldWidgetProps } from './types';
77
import { 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
*/
1631
function 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

Comments
 (0)