@object-ui/fields 13.2.0 → 14.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +11 -0
  3. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  4. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  5. package/dist/index.cjs +5 -5
  6. package/dist/index.d.ts +23 -8
  7. package/dist/index.js +2027 -1364
  8. package/dist/widgets/FileField.d.ts +22 -0
  9. package/dist/widgets/FilterConditionField.d.ts +4 -0
  10. package/dist/widgets/GridField.d.ts +6 -2
  11. package/dist/widgets/ObjectRefField.d.ts +4 -0
  12. package/dist/widgets/RecipientPickerField.d.ts +4 -0
  13. package/dist/widgets/SelectField.d.ts +1 -1
  14. package/package.json +9 -9
  15. package/src/FieldEditWidget.tsx +3 -0
  16. package/src/__tests__/DateCellRenderer.test.tsx +27 -4
  17. package/src/__tests__/lookupCellDisplayField.test.tsx +107 -0
  18. package/src/__tests__/lookupCellDisplayName.test.tsx +119 -0
  19. package/src/index.tsx +237 -39
  20. package/src/standard-widgets.test.tsx +35 -0
  21. package/src/widgets/CurrencyField.tsx +5 -0
  22. package/src/widgets/FileField.tsx +170 -28
  23. package/src/widgets/FilterConditionField.tsx +343 -0
  24. package/src/widgets/GridField.test.tsx +84 -1
  25. package/src/widgets/GridField.tsx +32 -2
  26. package/src/widgets/LookupField.tsx +12 -2
  27. package/src/widgets/NumberField.tsx +14 -2
  28. package/src/widgets/ObjectRefField.tsx +106 -0
  29. package/src/widgets/PeoplePicker.test.tsx +6 -0
  30. package/src/widgets/PeoplePicker.tsx +11 -2
  31. package/src/widgets/RecipientPickerField.tsx +150 -0
  32. package/src/widgets/SelectField.cascade.test.tsx +3 -3
  33. package/src/widgets/SelectField.tsx +1 -1
@@ -5,39 +5,24 @@ import { Upload, X, File as FileIcon, ImageIcon, Camera, Loader2 } from 'lucide-
5
5
  import { FieldWidgetProps } from './types';
6
6
 
7
7
  /**
8
- * FileField - File upload widget with drag-and-drop support
9
- * Supports single and multiple file uploads with configurable accepted file types.
10
- * L2: File size validation, per-file progress indicators, error messages.
8
+ * Shared upload pipeline for the file widgets: validates size, uploads through
9
+ * the configured UploadProvider adapter (with progress), and merges results
10
+ * into the field value — append for `multiple`, replace otherwise. Extracted so
11
+ * the full-size FileField and the compact grid-cell {@link FileCell} stay
12
+ * behaviourally identical (same value shape, same error handling).
11
13
  */
12
- export function FileField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
13
- const inputRef = useRef<HTMLInputElement>(null);
14
- const cameraRef = useRef<HTMLInputElement>(null);
15
- const fileField = (field || (props as any).schema) as any;
16
- const multiple = fileField?.multiple || false;
17
- const accept = fileField?.accept ? fileField.accept.join(',') : undefined;
18
- const maxSize = fileField?.maxSize as number | undefined; // bytes
19
- /**
20
- * Camera capture mode for mobile devices.
21
- * - `'environment'` (back camera): photos of receipts, documents, products
22
- * - `'user'` (front camera): selfies, profile pictures
23
- * - `false`: disable the camera button entirely
24
- * @default 'environment' when accept includes image/* on a touch device
25
- */
26
- const captureMode = (fileField?.capture ?? null) as 'environment' | 'user' | false | null;
27
- const acceptsImages = !accept || accept.split(',').some((t: string) =>
28
- t.trim().startsWith('image/') || t.trim() === 'image/*' || t.trim().startsWith('.jp') || t.trim().startsWith('.png') || t.trim().startsWith('.gif') || t.trim().startsWith('.webp'),
29
- );
30
- // Auto-enable camera button on touch devices when image upload is permitted, unless explicitly disabled.
31
- const isTouchDevice = typeof navigator !== 'undefined' && (navigator.maxTouchPoints > 0 || /Mobi|Android/i.test(navigator.userAgent));
32
- const cameraEnabled = captureMode === false ? false : (captureMode ?? (acceptsImages && isTouchDevice ? 'environment' : null));
33
- const [isDragOver, setIsDragOver] = useState(false);
34
- const [errors, setErrors] = useState<string[]>([]);
14
+ function useFileUploads(opts: {
15
+ files: any[];
16
+ multiple: boolean;
17
+ maxSize?: number;
18
+ onChange: (value: any) => void;
19
+ }) {
20
+ const { files, multiple, maxSize, onChange } = opts;
35
21
  const { upload } = useUpload();
22
+ const [errors, setErrors] = useState<string[]>([]);
36
23
  const [uploadProgress, setUploadProgress] = useState<Record<string, number>>({});
37
24
  const [uploading, setUploading] = useState(false);
38
25
 
39
- const files = value ? (Array.isArray(value) ? value : [value]) : [];
40
-
41
26
  const processFiles = useCallback(async (selectedFiles: File[]) => {
42
27
  if (selectedFiles.length === 0) return;
43
28
  const newErrors: string[] = [];
@@ -91,6 +76,42 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
91
76
  }
92
77
  }, [files, multiple, onChange, maxSize, upload]);
93
78
 
79
+ return { processFiles, errors, uploading, uploadProgress };
80
+ }
81
+
82
+ /**
83
+ * FileField - File upload widget with drag-and-drop support
84
+ * Supports single and multiple file uploads with configurable accepted file types.
85
+ * L2: File size validation, per-file progress indicators, error messages.
86
+ */
87
+ export function FileField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
88
+ const inputRef = useRef<HTMLInputElement>(null);
89
+ const cameraRef = useRef<HTMLInputElement>(null);
90
+ const fileField = (field || (props as any).schema) as any;
91
+ const multiple = fileField?.multiple || false;
92
+ const accept = fileField?.accept ? fileField.accept.join(',') : undefined;
93
+ const maxSize = fileField?.maxSize as number | undefined; // bytes
94
+ /**
95
+ * Camera capture mode for mobile devices.
96
+ * - `'environment'` (back camera): photos of receipts, documents, products
97
+ * - `'user'` (front camera): selfies, profile pictures
98
+ * - `false`: disable the camera button entirely
99
+ * @default 'environment' when accept includes image/* on a touch device
100
+ */
101
+ const captureMode = (fileField?.capture ?? null) as 'environment' | 'user' | false | null;
102
+ const acceptsImages = !accept || accept.split(',').some((t: string) =>
103
+ t.trim().startsWith('image/') || t.trim() === 'image/*' || t.trim().startsWith('.jp') || t.trim().startsWith('.png') || t.trim().startsWith('.gif') || t.trim().startsWith('.webp'),
104
+ );
105
+ // Auto-enable camera button on touch devices when image upload is permitted, unless explicitly disabled.
106
+ const isTouchDevice = typeof navigator !== 'undefined' && (navigator.maxTouchPoints > 0 || /Mobi|Android/i.test(navigator.userAgent));
107
+ const cameraEnabled = captureMode === false ? false : (captureMode ?? (acceptsImages && isTouchDevice ? 'environment' : null));
108
+ const [isDragOver, setIsDragOver] = useState(false);
109
+
110
+ const files = value ? (Array.isArray(value) ? value : [value]) : [];
111
+ const { processFiles, errors, uploading, uploadProgress } = useFileUploads({
112
+ files, multiple, maxSize, onChange,
113
+ });
114
+
94
115
  const handleDragOver = useCallback((e: React.DragEvent) => {
95
116
  e.preventDefault();
96
117
  e.stopPropagation();
@@ -301,3 +322,124 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
301
322
  </div>
302
323
  );
303
324
  }
325
+
326
+ /**
327
+ * FileCell — compact upload control for a line-item grid cell (objectui#2360).
328
+ *
329
+ * Same value shape and upload pipeline as {@link FileField}, sized for a 32px
330
+ * grid row: existing files render as removable chips (image thumbnail / file
331
+ * icon + name) and a small button opens the native file picker. No
332
+ * drag-and-drop zone — a grid cell has no room for one; the per-row expand
333
+ * form still offers the full-size FileField.
334
+ */
335
+ export function FileCell({
336
+ value,
337
+ onChange,
338
+ disabled,
339
+ multiple,
340
+ accept,
341
+ maxSize,
342
+ 'aria-label': ariaLabel,
343
+ 'data-cell': dataCell,
344
+ }: {
345
+ value: any;
346
+ onChange: (value: any) => void;
347
+ disabled?: boolean;
348
+ multiple?: boolean;
349
+ /** Comma-joined accept list for the native picker (e.g. `"image/*,.pdf"`). */
350
+ accept?: string;
351
+ /** Max file size in bytes (oversize picks are rejected with an inline error). */
352
+ maxSize?: number;
353
+ 'aria-label'?: string;
354
+ /** Focus-grid coordinate (see GridField keyboard navigation). */
355
+ 'data-cell'?: string;
356
+ }) {
357
+ const inputRef = useRef<HTMLInputElement>(null);
358
+ const files = value ? (Array.isArray(value) ? value : [value]) : [];
359
+ const { processFiles, errors, uploading } = useFileUploads({
360
+ files, multiple: !!multiple, maxSize, onChange,
361
+ });
362
+
363
+ const removeAt = (index: number) => {
364
+ if (multiple) {
365
+ const next = files.filter((_: any, i: number) => i !== index);
366
+ onChange(next.length > 0 ? next : null);
367
+ } else {
368
+ onChange(null);
369
+ }
370
+ };
371
+
372
+ const isImage = (file: any) => String(file?.mime_type || '').startsWith('image/');
373
+ const nameOf = (file: any) =>
374
+ typeof file === 'string' ? file : file?.name || file?.original_name || 'File';
375
+ const showUpload = !disabled && !uploading && (multiple || files.length === 0);
376
+
377
+ return (
378
+ <div className="flex min-h-8 flex-wrap items-center gap-1 px-1 py-0.5">
379
+ <input
380
+ ref={inputRef}
381
+ type="file"
382
+ multiple={multiple}
383
+ accept={accept}
384
+ onChange={(e) => {
385
+ processFiles(Array.from(e.target.files || []));
386
+ e.target.value = ''; // allow re-picking the same file
387
+ }}
388
+ className="hidden"
389
+ />
390
+ {files.map((file: any, idx: number) => (
391
+ <span
392
+ key={idx}
393
+ className="inline-flex max-w-40 items-center gap-1 rounded border bg-muted/50 px-1 py-0.5 text-xs"
394
+ title={nameOf(file)}
395
+ data-testid="file-cell-chip"
396
+ >
397
+ {isImage(file) && file.url ? (
398
+ <img src={file.url} alt={nameOf(file)} className="size-5 shrink-0 rounded object-cover" />
399
+ ) : (
400
+ <FileIcon className="size-3 shrink-0 text-muted-foreground" />
401
+ )}
402
+ <span className="truncate">{nameOf(file)}</span>
403
+ {!disabled && (
404
+ <button
405
+ type="button"
406
+ className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
407
+ aria-label={`Remove ${nameOf(file)}`}
408
+ onClick={() => removeAt(idx)}
409
+ >
410
+ <X className="size-3" />
411
+ </button>
412
+ )}
413
+ </span>
414
+ ))}
415
+ {uploading && (
416
+ <span
417
+ className="inline-flex items-center gap-1 text-xs text-muted-foreground"
418
+ data-testid="file-cell-uploading"
419
+ >
420
+ <Loader2 className="size-3.5 animate-spin" />
421
+ </span>
422
+ )}
423
+ {showUpload && (
424
+ <Button
425
+ type="button"
426
+ variant="ghost"
427
+ size="sm"
428
+ className="h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
429
+ onClick={() => inputRef.current?.click()}
430
+ aria-label={ariaLabel}
431
+ data-cell={dataCell}
432
+ disabled={disabled}
433
+ >
434
+ <Upload className="size-3.5" />
435
+ {files.length === 0 && 'Upload'}
436
+ </Button>
437
+ )}
438
+ {errors.length > 0 && (
439
+ <span className="w-full truncate text-[11px] text-destructive" title={errors.join('; ')}>
440
+ {errors[0]}
441
+ </span>
442
+ )}
443
+ </div>
444
+ );
445
+ }
@@ -0,0 +1,343 @@
1
+ import React from 'react';
2
+ import { FilterBuilder, cn } from '@object-ui/components';
3
+ import { SchemaRendererContext } from '@object-ui/react';
4
+ import type { FieldWidgetProps } from './types';
5
+
6
+ /**
7
+ * FilterConditionField — visual criteria builder for a stored FilterCondition
8
+ * (e.g. `sys_sharing_rule.criteria_json`), scoped to the object chosen in a
9
+ * sibling `object_name` field.
10
+ *
11
+ * Reached via the field `widget: 'filter-condition'` hint (resolves as
12
+ * `field:filter-condition`). Reads the live `object_name` from
13
+ * `dependentValues`, loads that object's fields via
14
+ * `dataSource.getObjectSchema(...)`, and renders `<FilterBuilder>` over them —
15
+ * so an admin builds `type == "customer" AND is_active == true` by picking
16
+ * fields/operators instead of hand-writing JSON.
17
+ *
18
+ * Storage contract: the value round-trips as a **MongoDB-style object filter**
19
+ * (`{ field: value }`, `{ field: { $gt: n } }`, `{ $or: [...] }`), JSON-encoded
20
+ * — the exact shape the sharing evaluator spreads into `engine.find(object,
21
+ * { filter })`. Criteria that can't be represented in the builder (nested
22
+ * mixes, unknown operators) fall back to a raw-JSON editor so nothing is hidden
23
+ * or lost; an "Edit as JSON" toggle is always available.
24
+ */
25
+
26
+ interface FilterFieldDef {
27
+ value: string;
28
+ label: string;
29
+ type?: string;
30
+ options?: Array<{ value: string; label: string }>;
31
+ referenceTo?: string;
32
+ }
33
+
34
+ interface BuilderCondition {
35
+ id: string;
36
+ field: string;
37
+ operator: string;
38
+ value: any;
39
+ }
40
+ interface BuilderGroup {
41
+ id: string;
42
+ logic: 'and' | 'or';
43
+ conditions: BuilderCondition[];
44
+ }
45
+
46
+ const EMPTY_GROUP: BuilderGroup = { id: 'root', logic: 'and', conditions: [] };
47
+
48
+ /** Field types that are not meaningfully filterable in a simple builder. */
49
+ const NON_FILTERABLE = new Set([
50
+ 'object', 'vector', 'file', 'image', 'avatar', 'signature',
51
+ 'richtext', 'html', 'markdown', 'location', 'grid', 'json', 'code',
52
+ ]);
53
+
54
+ function deriveFilterFields(schema: any): FilterFieldDef[] {
55
+ const raw = schema?.fields;
56
+ const entries: Array<[string, any]> = Array.isArray(raw)
57
+ ? raw.map((f: any) => [f?.name, f])
58
+ : raw && typeof raw === 'object'
59
+ ? Object.entries(raw)
60
+ : [];
61
+ const out: FilterFieldDef[] = [];
62
+ for (const [name, f] of entries) {
63
+ if (!name || !f || f.hidden) continue;
64
+ const type = f.type as string | undefined;
65
+ if (type && NON_FILTERABLE.has(type)) continue;
66
+ out.push({
67
+ value: name,
68
+ label: f.label || name,
69
+ type,
70
+ options: Array.isArray(f.options)
71
+ ? f.options.map((o: any) =>
72
+ typeof o === 'string'
73
+ ? { value: o, label: o }
74
+ : { value: String(o?.value), label: String(o?.label ?? o?.value) },
75
+ )
76
+ : undefined,
77
+ referenceTo: f.reference_to || f.reference,
78
+ });
79
+ }
80
+ return out;
81
+ }
82
+
83
+ function coerceByType(value: any, type?: string): any {
84
+ if (value == null) return value;
85
+ if (type === 'boolean' || type === 'toggle') {
86
+ if (typeof value === 'boolean') return value;
87
+ if (value === 'true') return true;
88
+ if (value === 'false') return false;
89
+ return value;
90
+ }
91
+ if (type === 'number' || type === 'currency' || type === 'percent' || type === 'rating' || type === 'slider') {
92
+ const n = Number(value);
93
+ return Number.isFinite(n) && value !== '' ? n : value;
94
+ }
95
+ return value;
96
+ }
97
+
98
+ function toArray(value: any): any[] {
99
+ if (Array.isArray(value)) return value;
100
+ if (typeof value === 'string') return value.split(',').map((s) => s.trim()).filter(Boolean);
101
+ return value == null ? [] : [value];
102
+ }
103
+
104
+ function condToMongo(c: BuilderCondition, typeOf: (f: string) => string | undefined): Record<string, any> | null {
105
+ const { field, operator, value } = c || ({} as BuilderCondition);
106
+ if (!field) return null;
107
+ const t = typeOf(field);
108
+ const cv = coerceByType(value, t);
109
+ switch (operator) {
110
+ case 'equals': return { [field]: cv };
111
+ case 'notEquals': return { [field]: { $ne: cv } };
112
+ case 'contains': return { [field]: { $contains: value } };
113
+ case 'notContains': return { [field]: { $ncontains: value } };
114
+ case 'isEmpty': return { [field]: { $in: [null, ''] } };
115
+ case 'isNotEmpty': return { [field]: { $nin: [null, ''] } };
116
+ case 'greaterThan':
117
+ case 'after': return { [field]: { $gt: cv } };
118
+ case 'lessThan':
119
+ case 'before': return { [field]: { $lt: cv } };
120
+ case 'greaterOrEqual': return { [field]: { $gte: cv } };
121
+ case 'lessOrEqual': return { [field]: { $lte: cv } };
122
+ case 'between': {
123
+ const [a, b] = Array.isArray(value) ? value : [undefined, undefined];
124
+ return { [field]: { $gte: coerceByType(a, t), $lte: coerceByType(b, t) } };
125
+ }
126
+ case 'in': return { [field]: { $in: toArray(value).map((v) => coerceByType(v, t)) } };
127
+ case 'notIn': return { [field]: { $nin: toArray(value).map((v) => coerceByType(v, t)) } };
128
+ default: return { [field]: cv };
129
+ }
130
+ }
131
+
132
+ function filterGroupToMongo(group: BuilderGroup, typeOf: (f: string) => string | undefined): Record<string, any> | null {
133
+ const frags = (group?.conditions ?? [])
134
+ .map((c) => condToMongo(c, typeOf))
135
+ .filter((x): x is Record<string, any> => !!x);
136
+ if (frags.length === 0) return null; // empty = match all
137
+ if (group.logic === 'or') return { $or: frags };
138
+ if (frags.length === 1) return frags[0];
139
+ const keys = frags.flatMap((f) => Object.keys(f));
140
+ const noCollision = new Set(keys).size === keys.length;
141
+ return noCollision ? Object.assign({}, ...frags) : { $and: frags };
142
+ }
143
+
144
+ function arraysEqual(a: any, b: any[]): boolean {
145
+ return Array.isArray(a) && a.length === b.length && a.every((v, i) => v === b[i]);
146
+ }
147
+
148
+ function kvToCondition(field: string, v: any, idx: number): BuilderCondition | null {
149
+ const id = `c_${idx}_${field}`;
150
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) {
151
+ return { id, field, operator: 'equals', value: v };
152
+ }
153
+ const opKeys = Object.keys(v);
154
+ if (opKeys.length === 1) {
155
+ const op = opKeys[0];
156
+ const val = v[op];
157
+ switch (op) {
158
+ case '$ne': return { id, field, operator: 'notEquals', value: val };
159
+ case '$contains': return { id, field, operator: 'contains', value: val };
160
+ case '$ncontains': return { id, field, operator: 'notContains', value: val };
161
+ case '$gt': return { id, field, operator: 'greaterThan', value: val };
162
+ case '$lt': return { id, field, operator: 'lessThan', value: val };
163
+ case '$gte': return { id, field, operator: 'greaterOrEqual', value: val };
164
+ case '$lte': return { id, field, operator: 'lessOrEqual', value: val };
165
+ case '$in':
166
+ return arraysEqual(val, [null, ''])
167
+ ? { id, field, operator: 'isEmpty', value: '' }
168
+ : { id, field, operator: 'in', value: val };
169
+ case '$nin':
170
+ return arraysEqual(val, [null, ''])
171
+ ? { id, field, operator: 'isNotEmpty', value: '' }
172
+ : { id, field, operator: 'notIn', value: val };
173
+ default: return null;
174
+ }
175
+ }
176
+ if (opKeys.length === 2 && '$gte' in v && '$lte' in v) {
177
+ return { id, field, operator: 'between', value: [v.$gte, v.$lte] };
178
+ }
179
+ return null;
180
+ }
181
+
182
+ /** Returns a BuilderGroup, or `null` when the criteria can't be represented. */
183
+ function mongoToFilterGroup(mongo: any): BuilderGroup | null {
184
+ if (mongo == null) return { ...EMPTY_GROUP, conditions: [] };
185
+ if (typeof mongo !== 'object' || Array.isArray(mongo)) return null;
186
+ const entries = Object.entries(mongo);
187
+ if (entries.length === 0) return { ...EMPTY_GROUP, conditions: [] };
188
+ if (entries.length === 1 && (mongo.$or || mongo.$and)) {
189
+ const logic: 'and' | 'or' = mongo.$or ? 'or' : 'and';
190
+ const arr = mongo.$or || mongo.$and;
191
+ if (!Array.isArray(arr)) return null;
192
+ const conditions: BuilderCondition[] = [];
193
+ for (let i = 0; i < arr.length; i++) {
194
+ const frag = arr[i];
195
+ if (!frag || typeof frag !== 'object' || Object.keys(frag).length !== 1) return null;
196
+ const field = Object.keys(frag)[0];
197
+ if (field.startsWith('$')) return null;
198
+ const c = kvToCondition(field, frag[field], i);
199
+ if (!c) return null;
200
+ conditions.push(c);
201
+ }
202
+ return { id: 'root', logic, conditions };
203
+ }
204
+ const conditions: BuilderCondition[] = [];
205
+ let i = 0;
206
+ for (const [field, v] of entries) {
207
+ if (field.startsWith('$')) return null; // mixed logical + field → raw
208
+ const c = kvToCondition(field, v, i++);
209
+ if (!c) return null;
210
+ conditions.push(c);
211
+ }
212
+ return { id: 'root', logic: 'and', conditions };
213
+ }
214
+
215
+ function stringifyValue(value: string | object | undefined | null): string {
216
+ if (value == null || value === '') return '';
217
+ if (typeof value === 'string') return value;
218
+ try {
219
+ return JSON.stringify(value);
220
+ } catch {
221
+ return '';
222
+ }
223
+ }
224
+
225
+ export function FilterConditionField({
226
+ value,
227
+ onChange,
228
+ readonly,
229
+ className,
230
+ ...props
231
+ }: FieldWidgetProps<string | object>) {
232
+ const ctx = React.useContext(SchemaRendererContext);
233
+ const dataSource: any = (props as any).dataSource ?? (ctx as any)?.dataSource ?? null;
234
+ const dependentValues: Record<string, any> = (props as any).dependentValues ?? {};
235
+ const objectName = String(dependentValues.object_name ?? '');
236
+
237
+ const [fields, setFields] = React.useState<FilterFieldDef[] | null>(null);
238
+
239
+ React.useEffect(() => {
240
+ setFields(null);
241
+ if (!dataSource || !objectName || typeof dataSource.getObjectSchema !== 'function') return;
242
+ let cancelled = false;
243
+ (async () => {
244
+ try {
245
+ const schema = await dataSource.getObjectSchema(objectName);
246
+ if (!cancelled) setFields(deriveFilterFields(schema));
247
+ } catch {
248
+ if (!cancelled) setFields([]);
249
+ }
250
+ })();
251
+ return () => {
252
+ cancelled = true;
253
+ };
254
+ }, [dataSource, objectName]);
255
+
256
+ const rawValue = React.useMemo(() => stringifyValue(value), [value]);
257
+
258
+ const parsed = React.useMemo(() => {
259
+ if (!rawValue.trim()) return { mongo: {}, ok: true };
260
+ try {
261
+ return { mongo: JSON.parse(rawValue), ok: true };
262
+ } catch {
263
+ return { mongo: null, ok: false };
264
+ }
265
+ }, [rawValue]);
266
+
267
+ const group = React.useMemo(
268
+ () => (parsed.ok ? mongoToFilterGroup(parsed.mongo) : null),
269
+ [parsed],
270
+ );
271
+
272
+ // Raw JSON mode: forced when the stored value can't be represented in the
273
+ // builder; otherwise opt-in via the toggle.
274
+ const representable = parsed.ok && group !== null;
275
+ const [rawMode, setRawMode] = React.useState<boolean>(!representable);
276
+ React.useEffect(() => {
277
+ if (!representable) setRawMode(true);
278
+ }, [representable]);
279
+
280
+ const typeOf = React.useMemo(() => {
281
+ const map = new Map((fields ?? []).map((f) => [f.value, f.type]));
282
+ return (f: string) => map.get(f);
283
+ }, [fields]);
284
+
285
+ const handleBuilderChange = (g: BuilderGroup) => {
286
+ const mongo = filterGroupToMongo(g, typeOf);
287
+ onChange((mongo == null ? '' : JSON.stringify(mongo)) as any);
288
+ };
289
+
290
+ if (!objectName) {
291
+ return (
292
+ <p className={cn('text-sm text-muted-foreground', className)}>
293
+ Select an object first.
294
+ </p>
295
+ );
296
+ }
297
+
298
+ if (readonly) {
299
+ if (!rawValue.trim()) {
300
+ return <span className={cn('text-sm text-muted-foreground', className)}>All records</span>;
301
+ }
302
+ return (
303
+ <pre className={cn('overflow-x-auto rounded bg-muted/40 p-2 text-xs', className)}>
304
+ {rawValue}
305
+ </pre>
306
+ );
307
+ }
308
+
309
+ return (
310
+ <div className={cn('flex flex-col gap-2', className)}>
311
+ {rawMode ? (
312
+ <>
313
+ <textarea
314
+ className="min-h-[96px] w-full rounded border bg-background px-2 py-1 font-mono text-xs"
315
+ value={rawValue}
316
+ placeholder='{ "type": "customer", "is_active": true }'
317
+ onChange={(e) => onChange(e.target.value as any)}
318
+ />
319
+ {!parsed.ok && (
320
+ <span className="text-xs text-destructive">Invalid JSON — the rule will match no records until fixed.</span>
321
+ )}
322
+ </>
323
+ ) : (
324
+ <FilterBuilder
325
+ fields={fields ?? []}
326
+ value={(group ?? EMPTY_GROUP) as any}
327
+ onChange={handleBuilderChange as any}
328
+ />
329
+ )}
330
+ <button
331
+ type="button"
332
+ className="self-start text-xs text-muted-foreground underline-offset-2 hover:underline"
333
+ onClick={() => setRawMode((m) => !m)}
334
+ disabled={!representable && !rawMode}
335
+ title={!representable ? 'This criteria can only be edited as JSON' : undefined}
336
+ >
337
+ {rawMode ? 'Use visual builder' : 'Edit as JSON'}
338
+ </button>
339
+ </div>
340
+ );
341
+ }
342
+
343
+ export default FilterConditionField;
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
- import { render, screen, fireEvent } from '@testing-library/react';
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
3
  import React from 'react';
4
+ import { UploadProvider } from '@object-ui/providers';
4
5
  import { GridField, LineItemsField, sumColumn, lookupAutofillPatch } from './GridField';
5
6
 
6
7
  const columns = [
@@ -258,6 +259,88 @@ describe('GridField / LineItemsField — editable line items', () => {
258
259
  });
259
260
  });
260
261
 
262
+ describe('file columns (upload in a grid cell — #2360)', () => {
263
+ const fileField = {
264
+ columns: [
265
+ { field: 'description', label: 'Description', type: 'text' as const },
266
+ { field: 'receipt', label: 'Receipt', type: 'file' as const },
267
+ ],
268
+ } as any;
269
+
270
+ it('renders a real upload control in the cell, not a text input', () => {
271
+ render(<GridField value={[{ description: 'Taxi', receipt: null }]} onChange={() => {}} field={fileField} />);
272
+ // One upload button per row (data row + ghost), backed by a native file input.
273
+ expect(screen.getAllByRole('button', { name: 'Receipt' }).length).toBeGreaterThan(0);
274
+ expect(document.querySelector('input[type="file"]')).toBeTruthy();
275
+ expect(screen.queryByRole('textbox', { name: 'Receipt' })).toBeNull();
276
+ });
277
+
278
+ it('uploads a picked file and writes the file object into the row', async () => {
279
+ const onChange = vi.fn();
280
+ const adapter = {
281
+ name: 'test',
282
+ upload: async (f: File) => ({ url: 'https://cdn/receipt.png', name: f.name, size: f.size, mimeType: f.type }),
283
+ };
284
+ render(
285
+ <UploadProvider adapter={adapter as any}>
286
+ <GridField value={[{ description: 'Taxi', receipt: null }]} onChange={onChange} field={fileField} />
287
+ </UploadProvider>,
288
+ );
289
+ const file = new File(['x'], 'receipt.png', { type: 'image/png' });
290
+ const input = document.querySelectorAll('input[type="file"]')[0] as HTMLInputElement;
291
+ fireEvent.change(input, { target: { files: [file] } });
292
+ await waitFor(() => expect(onChange).toHaveBeenCalled());
293
+ expect(onChange).toHaveBeenCalledWith([
294
+ {
295
+ description: 'Taxi',
296
+ receipt: {
297
+ name: 'receipt.png',
298
+ original_name: 'receipt.png',
299
+ size: file.size,
300
+ mime_type: 'image/png',
301
+ url: 'https://cdn/receipt.png',
302
+ },
303
+ },
304
+ ]);
305
+ });
306
+
307
+ it('shows an uploaded file as a removable chip', () => {
308
+ const onChange = vi.fn();
309
+ render(
310
+ <GridField
311
+ value={[{ description: 'Taxi', receipt: { name: 'receipt.png', mime_type: 'image/png' } }]}
312
+ onChange={onChange}
313
+ field={fileField}
314
+ />,
315
+ );
316
+ expect(screen.getByText('receipt.png')).toBeTruthy();
317
+ fireEvent.click(screen.getByLabelText('Remove receipt.png'));
318
+ expect(onChange).toHaveBeenCalledWith([{ description: 'Taxi', receipt: null }]);
319
+ });
320
+
321
+ it('passes the column accept list to the native picker', () => {
322
+ const withAccept = {
323
+ columns: [{ field: 'receipt', label: 'Receipt', type: 'file' as const, accept: ['image/*', '.pdf'] }],
324
+ } as any;
325
+ render(<GridField value={[]} onChange={() => {}} field={withAccept} />);
326
+ const input = document.querySelector('input[type="file"]') as HTMLInputElement;
327
+ expect(input.getAttribute('accept')).toBe('image/*,.pdf');
328
+ });
329
+
330
+ it('renders the file name read-only in readonly mode', () => {
331
+ render(
332
+ <GridField
333
+ value={[{ description: 'Taxi', receipt: { name: 'receipt.png' } }]}
334
+ onChange={() => {}}
335
+ field={fileField}
336
+ readonly
337
+ />,
338
+ );
339
+ expect(screen.getByText('receipt.png')).toBeTruthy();
340
+ expect(document.querySelector('input[type="file"]')).toBeNull();
341
+ });
342
+ });
343
+
261
344
  it('sumColumn ignores blanks and NaN', () => {
262
345
  expect(sumColumn([{ amount: 1 }, { amount: 2 }, { amount: null }], 'amount')).toBe(3);
263
346
  });