@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.
- package/CHANGELOG.md +208 -0
- package/README.md +11 -0
- package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +23 -8
- package/dist/index.js +2027 -1364
- package/dist/widgets/FileField.d.ts +22 -0
- package/dist/widgets/FilterConditionField.d.ts +4 -0
- package/dist/widgets/GridField.d.ts +6 -2
- package/dist/widgets/ObjectRefField.d.ts +4 -0
- package/dist/widgets/RecipientPickerField.d.ts +4 -0
- package/dist/widgets/SelectField.d.ts +1 -1
- package/package.json +9 -9
- package/src/FieldEditWidget.tsx +3 -0
- package/src/__tests__/DateCellRenderer.test.tsx +27 -4
- package/src/__tests__/lookupCellDisplayField.test.tsx +107 -0
- package/src/__tests__/lookupCellDisplayName.test.tsx +119 -0
- package/src/index.tsx +237 -39
- package/src/standard-widgets.test.tsx +35 -0
- package/src/widgets/CurrencyField.tsx +5 -0
- package/src/widgets/FileField.tsx +170 -28
- package/src/widgets/FilterConditionField.tsx +343 -0
- package/src/widgets/GridField.test.tsx +84 -1
- package/src/widgets/GridField.tsx +32 -2
- package/src/widgets/LookupField.tsx +12 -2
- package/src/widgets/NumberField.tsx +14 -2
- package/src/widgets/ObjectRefField.tsx +106 -0
- package/src/widgets/PeoplePicker.test.tsx +6 -0
- package/src/widgets/PeoplePicker.tsx +11 -2
- package/src/widgets/RecipientPickerField.tsx +150 -0
- package/src/widgets/SelectField.cascade.test.tsx +3 -3
- 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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
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
|
});
|