@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.
@@ -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
+ }
@@ -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={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={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
- step={scale ? Math.pow(10, -scale) : 'any'}
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 changes (results replaced).
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
- }, [query.search, query.records]);
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.roles" },
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: { roles: ['sales'] } }}>
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: { roles: ['admin'] } }}>
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.roles`). A
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.