@object-ui/fields 14.0.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 +162 -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 +20 -8
- package/dist/index.js +1222 -1033
- package/dist/widgets/FileField.d.ts +22 -0
- package/dist/widgets/GridField.d.ts +6 -2
- package/dist/widgets/SelectField.d.ts +1 -1
- package/package.json +7 -7
- 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 +219 -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/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/PeoplePicker.test.tsx +6 -0
- package/src/widgets/PeoplePicker.tsx +11 -2
- 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
|
+
}
|
|
@@ -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
|
});
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { Plus, Trash2, SlidersHorizontal, Maximize2, Copy, GripVertical } from 'lucide-react';
|
|
19
19
|
import { resolveFieldRuleState } from '@object-ui/core';
|
|
20
20
|
import { LookupField } from './LookupField';
|
|
21
|
+
import { FileCell } from './FileField';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* GridField / LineItemsField — editable child-grid ("line items") widget.
|
|
@@ -30,7 +31,7 @@ import { LookupField } from './LookupField';
|
|
|
30
31
|
*
|
|
31
32
|
* Column config (a subset of `GridColumnDefinition`):
|
|
32
33
|
* { field, label?, type?, options?, width?, required?, prefix?, step? }
|
|
33
|
-
* type ∈ 'text' | 'number' | 'currency' | 'date' | 'select'
|
|
34
|
+
* type ∈ 'text' | 'number' | 'currency' | 'date' | 'select' | 'lookup' | 'file'
|
|
34
35
|
*
|
|
35
36
|
* Field-level config (from `GridFieldMetadata`):
|
|
36
37
|
* columns, min_rows, max_rows, allow_add, allow_delete, total_field
|
|
@@ -39,7 +40,7 @@ import { LookupField } from './LookupField';
|
|
|
39
40
|
export interface GridColumn {
|
|
40
41
|
field: string;
|
|
41
42
|
label?: string;
|
|
42
|
-
type?: 'text' | 'number' | 'currency' | 'date' | 'select' | 'lookup';
|
|
43
|
+
type?: 'text' | 'number' | 'currency' | 'date' | 'select' | 'lookup' | 'file';
|
|
43
44
|
options?: Array<{ label: string; value: string }>;
|
|
44
45
|
width?: number;
|
|
45
46
|
required?: boolean;
|
|
@@ -49,7 +50,11 @@ export interface GridColumn {
|
|
|
49
50
|
reference?: string;
|
|
50
51
|
displayField?: string;
|
|
51
52
|
idField?: string;
|
|
53
|
+
/** Multi-value column: multi-record lookup, or multi-file upload cell. */
|
|
52
54
|
multiple?: boolean;
|
|
55
|
+
/** For `type: 'file'` — accepted MIME types / extensions for the picker
|
|
56
|
+
* (e.g. `['image/*', '.pdf']`). Omit to accept anything. */
|
|
57
|
+
accept?: string[];
|
|
53
58
|
/**
|
|
54
59
|
* Hidden from the grid by default but revealable via the column chooser.
|
|
55
60
|
* Set by `deriveColumns` for fields beyond the default-visible budget — the
|
|
@@ -105,6 +110,7 @@ const MIN_WIDTH_BY_TYPE: Record<string, number> = {
|
|
|
105
110
|
number: 104,
|
|
106
111
|
currency: 116,
|
|
107
112
|
date: 150,
|
|
113
|
+
file: 168,
|
|
108
114
|
};
|
|
109
115
|
const minWidthFor = (c: GridColumn): number => c.width ?? MIN_WIDTH_BY_TYPE[c.type ?? 'text'] ?? 132;
|
|
110
116
|
|
|
@@ -252,6 +258,13 @@ export function computeRow(columns: GridColumn[], row: Row): Row {
|
|
|
252
258
|
* currency/number → formatted, empty → em dash). Lookups render separately. */
|
|
253
259
|
function displayText(c: GridColumn, value: any): string {
|
|
254
260
|
if (value === null || value === undefined || value === '') return '—';
|
|
261
|
+
if (c.type === 'file') {
|
|
262
|
+
const files = Array.isArray(value) ? value : [value];
|
|
263
|
+
if (files.length === 0) return '—';
|
|
264
|
+
if (files.length > 1) return `${files.length} files`;
|
|
265
|
+
const f = files[0];
|
|
266
|
+
return typeof f === 'string' ? f : f?.name || f?.original_name || 'File';
|
|
267
|
+
}
|
|
255
268
|
if (c.type === 'select' && Array.isArray(c.options)) {
|
|
256
269
|
const opt = c.options.find((o) => String(o.value) === String(value));
|
|
257
270
|
return opt ? opt.label : String(value);
|
|
@@ -613,6 +626,8 @@ export function GridField({
|
|
|
613
626
|
readonly
|
|
614
627
|
field={{ reference: c.reference, display_field: c.displayField, id_field: c.idField } as any}
|
|
615
628
|
/>
|
|
629
|
+
) : c.type === 'file' ? (
|
|
630
|
+
displayText(c, row[c.field])
|
|
616
631
|
) : row[c.field] != null && row[c.field] !== '' ? (
|
|
617
632
|
String(row[c.field])
|
|
618
633
|
) : (
|
|
@@ -700,6 +715,21 @@ export function GridField({
|
|
|
700
715
|
/>
|
|
701
716
|
);
|
|
702
717
|
}
|
|
718
|
+
// File / image column → a real upload control in the cell (objectui#2360),
|
|
719
|
+
// not a text input: chips for uploaded files + a compact picker button.
|
|
720
|
+
if (c.type === 'file') {
|
|
721
|
+
return (
|
|
722
|
+
<FileCell
|
|
723
|
+
value={val}
|
|
724
|
+
onChange={(v: any) => setCellValue(rowIdx, c.field, v)}
|
|
725
|
+
multiple={c.multiple}
|
|
726
|
+
accept={Array.isArray(c.accept) && c.accept.length > 0 ? c.accept.join(',') : undefined}
|
|
727
|
+
disabled={locked}
|
|
728
|
+
aria-label={c.label || c.field}
|
|
729
|
+
data-cell={`${rowIdx}-${colIdx}`}
|
|
730
|
+
/>
|
|
731
|
+
);
|
|
732
|
+
}
|
|
703
733
|
if (c.type === 'select') {
|
|
704
734
|
return (
|
|
705
735
|
<Select value={val != null ? String(val) : ''} onValueChange={(v) => setCell(rowIdx, c, v)} disabled={locked}>
|
|
@@ -544,6 +544,16 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
544
544
|
[idField],
|
|
545
545
|
);
|
|
546
546
|
|
|
547
|
+
// Level-2 pickers (PeoplePicker / RecordPickerDialog) operate on bare ids —
|
|
548
|
+
// their seed queries and selected-row matching compare against `idField`
|
|
549
|
+
// values. Collapse any `$expand`-ed record objects (passed through
|
|
550
|
+
// uncollapsed by the inline editor, objectui#2572) before handing the value
|
|
551
|
+
// down, or the seed fetch would query for a bogus object-shaped id.
|
|
552
|
+
const pickerValue = useMemo(
|
|
553
|
+
() => (multiple ? (Array.isArray(value) ? value.map(normalizeId) : []) : normalizeId(value)),
|
|
554
|
+
[multiple, value, normalizeId],
|
|
555
|
+
);
|
|
556
|
+
|
|
547
557
|
// Resolve a raw field value into its display option. An expanded-reference
|
|
548
558
|
// object is mapped directly (mirroring the read cell's display-name path) so
|
|
549
559
|
// the inline editor shows the record's name instead of the placeholder; a bare
|
|
@@ -914,7 +924,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
914
924
|
subtitleFields={subtitleFields}
|
|
915
925
|
avatarField={avatarField}
|
|
916
926
|
pageSize={lookupPageSize}
|
|
917
|
-
value={
|
|
927
|
+
value={pickerValue}
|
|
918
928
|
onSelect={onChange}
|
|
919
929
|
onSelectRecords={handlePickerSelectRecords}
|
|
920
930
|
lookupFilters={lookupFilters}
|
|
@@ -1135,7 +1145,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
1135
1145
|
titleFormat={refTitleFormat}
|
|
1136
1146
|
idField={idField}
|
|
1137
1147
|
pageSize={lookupPageSize}
|
|
1138
|
-
value={
|
|
1148
|
+
value={pickerValue}
|
|
1139
1149
|
onSelect={onChange}
|
|
1140
1150
|
onSelectRecords={handlePickerSelectRecords}
|
|
1141
1151
|
lookupFilters={lookupFilters}
|
|
@@ -14,8 +14,16 @@ export function NumberField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
14
14
|
|
|
15
15
|
const numberField = (field || (props as any).schema) as NumberFieldMetadata;
|
|
16
16
|
// Step follows `scale` (decimal places), not `precision` (total digit count):
|
|
17
|
-
// a decimal(10, 0) column has 0 decimal places, so the input should step by 1
|
|
17
|
+
// a decimal(10, 0) column has 0 decimal places, so the input should step by 1
|
|
18
|
+
// (`scale: 0` is a valid declaration — hence the typeof check, not truthiness).
|
|
19
|
+
// An explicit `step` in the metadata wins over the derived one.
|
|
18
20
|
const scale = numberField?.scale;
|
|
21
|
+
const step =
|
|
22
|
+
typeof numberField?.step === 'number'
|
|
23
|
+
? numberField.step
|
|
24
|
+
: typeof scale === 'number'
|
|
25
|
+
? Math.pow(10, -scale)
|
|
26
|
+
: 'any';
|
|
19
27
|
|
|
20
28
|
// Filter out non-DOM props
|
|
21
29
|
const { inputType, ...domProps } = props as any;
|
|
@@ -31,7 +39,11 @@ export function NumberField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
31
39
|
}}
|
|
32
40
|
placeholder={numberField?.placeholder}
|
|
33
41
|
disabled={readonly || domProps.disabled}
|
|
34
|
-
|
|
42
|
+
// Surface the field's declared range so the browser's spinner/keyboard
|
|
43
|
+
// affordances respect it (server-side validation still owns enforcement).
|
|
44
|
+
min={typeof numberField?.min === 'number' ? numberField.min : undefined}
|
|
45
|
+
max={typeof numberField?.max === 'number' ? numberField.max : undefined}
|
|
46
|
+
step={step}
|
|
35
47
|
/>
|
|
36
48
|
);
|
|
37
49
|
}
|
|
@@ -179,6 +179,12 @@ describe('PeoplePicker', () => {
|
|
|
179
179
|
await waitFor(() => expect(screen.getByText('Amy Lin')).toBeTruthy());
|
|
180
180
|
const search = screen.getByTestId('people-picker-search');
|
|
181
181
|
fireEvent.keyDown(search, { key: 'ArrowDown' });
|
|
182
|
+
// Wait for the cursor to actually land on the first row before committing —
|
|
183
|
+
// guards against a late background re-render swallowing the keypress (the
|
|
184
|
+
// old identity-keyed cursor reset made this flaky on slow CI).
|
|
185
|
+
await waitFor(() =>
|
|
186
|
+
expect(screen.getAllByTestId('person-row')[0].getAttribute('data-active')).toBe('true'),
|
|
187
|
+
);
|
|
182
188
|
fireEvent.keyDown(search, { key: 'Enter' });
|
|
183
189
|
expect(onSelect).toHaveBeenCalledWith('u1');
|
|
184
190
|
});
|
|
@@ -311,10 +311,19 @@ export function PeoplePicker({
|
|
|
311
311
|
|
|
312
312
|
// --- keyboard cursor ---
|
|
313
313
|
const [activeIndex, setActiveIndex] = useState(-1);
|
|
314
|
-
// Reset the cursor when the query
|
|
314
|
+
// Reset the cursor when the query results are actually *replaced* — keyed on
|
|
315
|
+
// the record-id signature, not the array identity. A background refetch that
|
|
316
|
+
// returns the same records (StrictMode double-effect, refetch-on-focus)
|
|
317
|
+
// re-emits a new array; resetting on identity alone yanked the cursor to -1
|
|
318
|
+
// mid-navigation (flaky ArrowDown→Enter, and a real UX nit).
|
|
319
|
+
const recordsSignature = useMemo(
|
|
320
|
+
() => query.records.map(r => String(getPersonId(r, idField))).join(''),
|
|
321
|
+
[query.records, idField],
|
|
322
|
+
);
|
|
315
323
|
useEffect(() => {
|
|
316
324
|
setActiveIndex(-1);
|
|
317
|
-
|
|
325
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
326
|
+
}, [query.search, recordsSignature]);
|
|
318
327
|
// Keep it in range if the list shrinks.
|
|
319
328
|
useEffect(() => {
|
|
320
329
|
setActiveIndex(i => (i >= navList.length ? navList.length - 1 : i));
|
|
@@ -102,14 +102,14 @@ describe('SelectField — role / context gating (#2284)', () => {
|
|
|
102
102
|
type: 'select',
|
|
103
103
|
options: [
|
|
104
104
|
{ label: 'Standard', value: 'standard' },
|
|
105
|
-
{ label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.
|
|
105
|
+
{ label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" },
|
|
106
106
|
],
|
|
107
107
|
} as any;
|
|
108
108
|
|
|
109
109
|
it('clears an admin-only value for a non-admin (offered set excludes it)', () => {
|
|
110
110
|
const onChange = vi.fn();
|
|
111
111
|
render(
|
|
112
|
-
<PredicateScopeProvider scope={{ current_user: {
|
|
112
|
+
<PredicateScopeProvider scope={{ current_user: { positions: ['sales'] } }}>
|
|
113
113
|
<SelectField
|
|
114
114
|
value={'admin_only'}
|
|
115
115
|
onChange={onChange}
|
|
@@ -124,7 +124,7 @@ describe('SelectField — role / context gating (#2284)', () => {
|
|
|
124
124
|
it('keeps an admin-only value for an admin', () => {
|
|
125
125
|
const onChange = vi.fn();
|
|
126
126
|
render(
|
|
127
|
-
<PredicateScopeProvider scope={{ current_user: {
|
|
127
|
+
<PredicateScopeProvider scope={{ current_user: { positions: ['admin'] } }}>
|
|
128
128
|
<SelectField
|
|
129
129
|
value={'admin_only'}
|
|
130
130
|
onChange={onChange}
|
|
@@ -24,7 +24,7 @@ import { FieldWidgetProps } from './types';
|
|
|
24
24
|
* Supports cascading / role-gated options (#2284): each option may carry a
|
|
25
25
|
* `visibleWhen` CEL predicate, evaluated against the live form record +
|
|
26
26
|
* `current_user`, so the offered set narrows as a controlling field changes
|
|
27
|
-
* (`record.country == 'cn'`) or by role (`'admin' in current_user.
|
|
27
|
+
* (`record.country == 'cn'`) or by role (`'admin' in current_user.positions`). A
|
|
28
28
|
* field declares which sibling fields drive its list via `dependsOn`; while any
|
|
29
29
|
* of those is empty the control is gated with a "select the parent first" hint,
|
|
30
30
|
* mirroring the dependent-lookup UX.
|