@object-ui/fields 16.0.0 → 17.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 (71) hide show
  1. package/CHANGELOG.md +630 -0
  2. package/README.md +45 -7
  3. package/dist/FieldEditWidget.d.ts +7 -0
  4. package/dist/field-type-alias.d.ts +16 -0
  5. package/dist/index.cjs +5 -5
  6. package/dist/index.d.ts +35 -11
  7. package/dist/index.js +2241 -1853
  8. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  9. package/dist/widgets/CheckboxesField.d.ts +8 -1
  10. package/dist/widgets/FileField.d.ts +1 -1
  11. package/dist/widgets/FilterConditionField.d.ts +32 -0
  12. package/dist/widgets/ImageField.d.ts +1 -1
  13. package/dist/widgets/ImageLightbox.d.ts +30 -0
  14. package/dist/widgets/ImageLightbox.test.d.ts +1 -0
  15. package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
  16. package/dist/widgets/LookupField.idField.test.d.ts +1 -0
  17. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  18. package/dist/widgets/MultiSelectField.d.ts +10 -2
  19. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  20. package/dist/widgets/RadioField.d.ts +8 -1
  21. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  22. package/dist/widgets/SelectField.d.ts +13 -9
  23. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +8 -0
  24. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +8 -0
  25. package/dist/widgets/deriveLookupColumns.d.ts +1 -0
  26. package/dist/widgets/file-value.d.ts +112 -0
  27. package/dist/widgets/file-value.test.d.ts +1 -0
  28. package/dist/widgets/types.d.ts +6 -0
  29. package/dist/widgets/useCascadingOptions.d.ts +18 -0
  30. package/dist/widgets/useFieldTranslation.d.ts +2 -0
  31. package/dist/widgets/useUploadingSignal.d.ts +9 -0
  32. package/dist/widgets/useUploadingSignal.test.d.ts +1 -0
  33. package/package.json +11 -10
  34. package/src/FieldEditWidget.test.ts +74 -2
  35. package/src/FieldEditWidget.tsx +44 -4
  36. package/src/field-type-alias.ts +100 -0
  37. package/src/field-type-coverage.test.ts +3 -1
  38. package/src/index.tsx +137 -135
  39. package/src/standard-widgets.test.tsx +44 -0
  40. package/src/widgets/CheckboxesField.cascade.test.tsx +143 -0
  41. package/src/widgets/CheckboxesField.tsx +77 -11
  42. package/src/widgets/FileField.tsx +91 -52
  43. package/src/widgets/FilterConditionField.tsx +109 -9
  44. package/src/widgets/ImageField.tsx +106 -65
  45. package/src/widgets/ImageLightbox.test.tsx +52 -0
  46. package/src/widgets/ImageLightbox.tsx +108 -0
  47. package/src/widgets/LookupField.hydration.test.tsx +136 -0
  48. package/src/widgets/LookupField.idField.test.tsx +61 -0
  49. package/src/widgets/LookupField.tsx +106 -9
  50. package/src/widgets/MultiSelectField.cascade.test.tsx +143 -0
  51. package/src/widgets/MultiSelectField.tsx +78 -11
  52. package/src/widgets/ObjectRefField.tsx +9 -4
  53. package/src/widgets/PeoplePicker.test.tsx +20 -0
  54. package/src/widgets/PeoplePicker.tsx +15 -3
  55. package/src/widgets/RadioField.cascade.test.tsx +141 -0
  56. package/src/widgets/RadioField.tsx +69 -9
  57. package/src/widgets/RecipientPickerField.test.tsx +157 -0
  58. package/src/widgets/RecipientPickerField.tsx +36 -16
  59. package/src/widgets/SelectField.tsx +33 -31
  60. package/src/widgets/TextAreaField.tsx +8 -3
  61. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
  62. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
  63. package/src/widgets/deriveLookupColumns.test.ts +13 -0
  64. package/src/widgets/deriveLookupColumns.ts +5 -15
  65. package/src/widgets/file-value.test.ts +230 -0
  66. package/src/widgets/file-value.ts +230 -0
  67. package/src/widgets/types.ts +6 -0
  68. package/src/widgets/useCascadingOptions.ts +46 -0
  69. package/src/widgets/useFieldTranslation.ts +30 -0
  70. package/src/widgets/useUploadingSignal.test.tsx +62 -0
  71. package/src/widgets/useUploadingSignal.ts +20 -0
@@ -1,8 +1,18 @@
1
1
  import React, { useRef, useState, useCallback, lazy, Suspense } from 'react';
2
2
  import { Button, EmptyValue } from '@object-ui/components';
3
3
  import { useUpload } from '@object-ui/providers';
4
- import { Upload, X, Image as ImageIcon, Crop as CropIcon, Loader2 } from 'lucide-react';
4
+ import { useObjectTranslation } from '@object-ui/i18n';
5
+ import { X, Image as ImageIcon, Crop as CropIcon, Loader2 } from 'lucide-react';
5
6
  import { FieldWidgetProps } from './types';
7
+ import { ImageLightbox } from './ImageLightbox';
8
+ import { useUploadingSignal } from './useUploadingSignal';
9
+ import {
10
+ fileValueForSubmit,
11
+ readFileValues,
12
+ uploadResultView,
13
+ withRecentUploads,
14
+ type FileValueView,
15
+ } from './file-value';
6
16
 
7
17
  // Lazy-load the cropper so the dialog (canvas + crop logic) is not in the initial
8
18
  // ImageField bundle. Consumers that never crop pay zero cost.
@@ -14,7 +24,7 @@ const ImageCropperDialog = lazy(() =>
14
24
  * ImageField - Image upload widget with preview thumbnails
15
25
  * Supports single and multiple image uploads with drag-and-drop and preview display
16
26
  */
17
- export function ImageField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
27
+ export function ImageField({ value, onChange, field, readonly, onUploadingChange, ...props }: FieldWidgetProps<any>) {
18
28
  const inputRef = useRef<HTMLInputElement>(null);
19
29
  const imageField = (field || (props as any).schema) as any;
20
30
  const multiple = imageField?.multiple || false;
@@ -24,29 +34,97 @@ export function ImageField({ value, onChange, field, readonly, ...props }: Field
24
34
  */
25
35
  const cropEnabled = imageField?.crop !== false;
26
36
  const [cropTarget, setCropTarget] = useState<{ index: number; src: string; name: string } | null>(null);
37
+ const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
27
38
  const { upload } = useUpload();
39
+ const { t } = useObjectTranslation();
28
40
  const [uploading, setUploading] = useState(false);
41
+ // Display details of just-uploaded images, keyed by their new `sys_file` id.
42
+ // Submitting the reference form means the field value no longer carries the
43
+ // URL a thumbnail needs; these keep the preview visible until the next read
44
+ // returns the expanded form. See `file-value`.
45
+ const [recent, setRecent] = useState<Record<string, FileValueView>>({});
46
+ useUploadingSignal(uploading, onUploadingChange);
47
+
48
+ // Derived value + memoized handlers must run before the readonly early return
49
+ // so hook order stays stable across renders.
50
+ const images = value ? (Array.isArray(value) ? value : [value]) : [];
51
+ const views = withRecentUploads(readFileValues(value, 'Image'), recent);
52
+
53
+ const remember = useCallback((result: any, originalName: string) => {
54
+ const view = uploadResultView(result, originalName);
55
+ if (view.id) setRecent((prev) => ({ ...prev, [view.id as string]: view }));
56
+ }, []);
57
+
58
+ const handleCropConfirm = useCallback(
59
+ async (blob: Blob, name: string) => {
60
+ if (!cropTarget) return;
61
+ setUploading(true);
62
+ try {
63
+ const result = await upload(blob);
64
+ remember(result, name);
65
+ const next = fileValueForSubmit(result, name);
66
+ if (multiple) {
67
+ const updated = [...images];
68
+ updated[cropTarget.index] = next;
69
+ onChange(updated);
70
+ } else {
71
+ onChange(next);
72
+ }
73
+ } finally {
74
+ setUploading(false);
75
+ setCropTarget(null);
76
+ }
77
+ },
78
+ [cropTarget, images, multiple, onChange, upload, remember],
79
+ );
80
+
81
+ const openCropper = useCallback(
82
+ (index: number) => {
83
+ const img = views[index];
84
+ if (!img?.url) return;
85
+ setCropTarget({ index, src: img.url, name: img.name || `image-${index}.png` });
86
+ },
87
+ [views],
88
+ );
89
+
90
+ const lightboxImages = views.filter((v) => v.url).map((v) => ({ url: v.url as string, name: v.name }));
29
91
 
30
92
  if (readonly) {
31
- if (!value) return <EmptyValue />;
32
-
33
- const images = Array.isArray(value) ? value : [value];
93
+ if (!value || lightboxImages.length === 0) return <EmptyValue />;
94
+
34
95
  return (
35
- <div className="flex flex-wrap gap-2">
36
- {images.map((img: any, idx: number) => (
37
- <img
38
- key={idx}
39
- src={img.url || ''}
40
- alt={img.name || `Image ${idx + 1}`}
41
- className="size-20 rounded-md object-cover border border-gray-200"
96
+ <>
97
+ <div className="flex flex-wrap gap-2">
98
+ {lightboxImages.map((img, idx) => (
99
+ <button
100
+ key={idx}
101
+ type="button"
102
+ onClick={() => setLightboxIndex(idx)}
103
+ className="group relative overflow-hidden rounded-md border border-border focus:outline-none focus:ring-2 focus:ring-ring"
104
+ aria-label={t('fields.image.enlarge', { name: img.name || t('fields.image.imageAlt', { index: idx + 1 }) })}
105
+ >
106
+ <img
107
+ src={img.url}
108
+ alt={img.name || t('fields.image.imageAlt', { index: idx + 1 })}
109
+ className="size-20 object-cover transition-transform duration-150 group-hover:scale-105"
110
+ />
111
+ <span className="pointer-events-none absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/10" />
112
+ </button>
113
+ ))}
114
+ </div>
115
+ {lightboxIndex !== null && (
116
+ <ImageLightbox
117
+ images={lightboxImages}
118
+ index={lightboxIndex}
119
+ open
120
+ onOpenChange={(o) => !o && setLightboxIndex(null)}
121
+ onIndexChange={setLightboxIndex}
42
122
  />
43
- ))}
44
- </div>
123
+ )}
124
+ </>
45
125
  );
46
126
  }
47
127
 
48
- const images = value ? (Array.isArray(value) ? value : [value]) : [];
49
-
50
128
  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
51
129
  const selectedFiles = Array.from(e.target.files || []);
52
130
  if (selectedFiles.length === 0) return;
@@ -56,13 +134,8 @@ export function ImageField({ value, onChange, field, readonly, ...props }: Field
56
134
  const imageObjects = await Promise.all(
57
135
  selectedFiles.map(async (file) => {
58
136
  const result = await upload(file);
59
- return {
60
- name: result.name,
61
- original_name: file.name,
62
- size: result.size,
63
- mime_type: result.mimeType,
64
- url: result.url,
65
- };
137
+ remember(result, file.name);
138
+ return fileValueForSubmit(result, file.name);
66
139
  }),
67
140
  );
68
141
 
@@ -87,43 +160,6 @@ export function ImageField({ value, onChange, field, readonly, ...props }: Field
87
160
  }
88
161
  };
89
162
 
90
- const handleCropConfirm = useCallback(
91
- async (blob: Blob, name: string) => {
92
- if (!cropTarget) return;
93
- setUploading(true);
94
- try {
95
- const result = await upload(blob);
96
- const next = {
97
- name: result.name || name,
98
- original_name: name,
99
- size: result.size,
100
- mime_type: result.mimeType,
101
- url: result.url,
102
- };
103
- if (multiple) {
104
- const updated = [...images];
105
- updated[cropTarget.index] = next;
106
- onChange(updated);
107
- } else {
108
- onChange(next);
109
- }
110
- } finally {
111
- setUploading(false);
112
- setCropTarget(null);
113
- }
114
- },
115
- [cropTarget, images, multiple, onChange, upload],
116
- );
117
-
118
- const openCropper = useCallback(
119
- (index: number) => {
120
- const img = images[index];
121
- if (!img?.url) return;
122
- setCropTarget({ index, src: img.url, name: img.name || `image-${index}.png` });
123
- },
124
- [images],
125
- );
126
-
127
163
  return (
128
164
  <div className={props.className}>
129
165
  <input
@@ -136,13 +172,13 @@ export function ImageField({ value, onChange, field, readonly, ...props }: Field
136
172
  />
137
173
 
138
174
  <div className="space-y-2">
139
- {images.length > 0 && (
175
+ {views.length > 0 && (
140
176
  <div className="grid grid-cols-4 gap-2">
141
- {images.map((img: any, idx: number) => (
177
+ {views.map((img, idx) => (
142
178
  <div key={idx} className="relative group">
143
179
  <img
144
180
  src={img.url || ''}
145
- alt={img.name || `Image ${idx + 1}`}
181
+ alt={img.name || t('fields.image.imageAlt', { index: idx + 1 })}
146
182
  className="size-20 rounded-md object-cover border border-gray-200"
147
183
  />
148
184
  <div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
@@ -153,7 +189,7 @@ export function ImageField({ value, onChange, field, readonly, ...props }: Field
153
189
  size="sm"
154
190
  onClick={() => openCropper(idx)}
155
191
  className="h-6 w-6 p-0"
156
- aria-label={`Crop image ${idx + 1}`}
192
+ aria-label={t('fields.image.crop', { index: idx + 1 })}
157
193
  data-testid={`image-field-crop-${idx}`}
158
194
  >
159
195
  <CropIcon className="size-3" />
@@ -165,6 +201,7 @@ export function ImageField({ value, onChange, field, readonly, ...props }: Field
165
201
  size="sm"
166
202
  onClick={() => handleRemove(idx)}
167
203
  className="h-6 w-6 p-0"
204
+ aria-label={t('fields.image.remove', { index: idx + 1 })}
168
205
  >
169
206
  <X className="size-3" />
170
207
  </Button>
@@ -187,7 +224,11 @@ export function ImageField({ value, onChange, field, readonly, ...props }: Field
187
224
  ) : (
188
225
  <ImageIcon className="size-4 mr-2" />
189
226
  )}
190
- {uploading ? 'Uploading…' : images.length > 0 ? 'Add More Images' : 'Upload Image'}
227
+ {uploading
228
+ ? t('fields.image.uploading')
229
+ : images.length > 0
230
+ ? t('fields.image.addMore')
231
+ : t('fields.image.upload')}
191
232
  </Button>
192
233
  </div>
193
234
 
@@ -0,0 +1,52 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react';
2
+ import { describe, it, expect } from 'vitest';
3
+ import { I18nProvider } from '@object-ui/i18n';
4
+ import { ImageLightbox, type ImageLightboxProps } from './ImageLightbox';
5
+
6
+ const imgs = [
7
+ { url: '/api/v1/storage/files/a', name: 'a.png' },
8
+ { url: '/api/v1/storage/files/b', name: 'b.png' },
9
+ { url: '/api/v1/storage/files/c', name: 'c.png' },
10
+ ];
11
+
12
+ const renderLightbox = (props: ImageLightboxProps) =>
13
+ render(
14
+ <I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
15
+ <ImageLightbox {...props} />
16
+ </I18nProvider>,
17
+ );
18
+
19
+ describe('ImageLightbox', () => {
20
+ it('renders the image at the requested index', () => {
21
+ renderLightbox({ images: imgs, index: 1, open: true, onOpenChange: () => {} });
22
+ const img = screen.getByRole('img') as HTMLImageElement;
23
+ expect(img.src).toContain('/api/v1/storage/files/b');
24
+ });
25
+
26
+ it('shows navigation and a position counter for multiple images', () => {
27
+ renderLightbox({ images: imgs, index: 0, open: true, onOpenChange: () => {} });
28
+ expect(screen.getByText('1 / 3')).toBeInTheDocument();
29
+ expect(screen.getByLabelText('Next image')).toBeInTheDocument();
30
+ expect(screen.getByLabelText('Previous image')).toBeInTheDocument();
31
+ });
32
+
33
+ it('omits navigation for a single image', () => {
34
+ renderLightbox({ images: [imgs[0]], index: 0, open: true, onOpenChange: () => {} });
35
+ expect(screen.queryByLabelText('Next image')).not.toBeInTheDocument();
36
+ expect(screen.queryByText('1 / 1')).not.toBeInTheDocument();
37
+ });
38
+
39
+ it('advances to the next image and wraps around', () => {
40
+ const seen: number[] = [];
41
+ renderLightbox({ images: imgs, index: 2, open: true, onOpenChange: () => {}, onIndexChange: (i) => seen.push(i) });
42
+ fireEvent.click(screen.getByLabelText('Next image'));
43
+ expect(seen).toEqual([0]); // wrapped 2 -> 0
44
+ });
45
+
46
+ it('steps to the previous image and wraps around', () => {
47
+ const seen: number[] = [];
48
+ renderLightbox({ images: imgs, index: 0, open: true, onOpenChange: () => {}, onIndexChange: (i) => seen.push(i) });
49
+ fireEvent.click(screen.getByLabelText('Previous image'));
50
+ expect(seen).toEqual([2]); // wrapped 0 -> 2
51
+ });
52
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ import * as React from 'react';
10
+ import { Dialog, DialogContent, DialogTitle } from '@object-ui/components';
11
+ import { useObjectTranslation } from '@object-ui/i18n';
12
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
13
+
14
+ export interface LightboxImage {
15
+ url: string;
16
+ name?: string;
17
+ }
18
+
19
+ export interface ImageLightboxProps {
20
+ images: LightboxImage[];
21
+ /** Index of the image shown when the lightbox opens. */
22
+ index: number;
23
+ open: boolean;
24
+ onOpenChange: (open: boolean) => void;
25
+ /** Called when the user navigates to another image (prev/next, arrow keys). */
26
+ onIndexChange?: (index: number) => void;
27
+ }
28
+
29
+ /**
30
+ * Full-screen image preview with prev/next navigation for a set of images.
31
+ *
32
+ * Controlled: the caller owns `open` and the current `index`. Built on the
33
+ * shared `Dialog`, so backdrop-click and Escape close it for free; this adds
34
+ * left/right arrow-key navigation and a position counter for the multi-image
35
+ * case. A single image simply omits the arrows and counter.
36
+ */
37
+ export function ImageLightbox({ images, index, open, onOpenChange, onIndexChange }: ImageLightboxProps) {
38
+ const { t } = useObjectTranslation();
39
+ const count = images.length;
40
+ const current = images[index];
41
+
42
+ const go = React.useCallback(
43
+ (delta: number) => {
44
+ if (count <= 1) return;
45
+ const next = (index + delta + count) % count;
46
+ onIndexChange?.(next);
47
+ },
48
+ [count, index, onIndexChange],
49
+ );
50
+
51
+ const handleKeyDown = React.useCallback(
52
+ (e: React.KeyboardEvent) => {
53
+ if (e.key === 'ArrowRight') {
54
+ e.preventDefault();
55
+ go(1);
56
+ } else if (e.key === 'ArrowLeft') {
57
+ e.preventDefault();
58
+ go(-1);
59
+ }
60
+ },
61
+ [go],
62
+ );
63
+
64
+ if (!current) return null;
65
+
66
+ return (
67
+ <Dialog open={open} onOpenChange={onOpenChange}>
68
+ <DialogContent
69
+ className="max-w-[92vw] sm:max-w-4xl p-0 bg-transparent border-0 shadow-none"
70
+ onKeyDown={handleKeyDown}
71
+ >
72
+ {/* Title is required for a labelled dialog but visually redundant here. */}
73
+ <DialogTitle className="sr-only">{current.name || t('fields.image.preview')}</DialogTitle>
74
+ <div className="relative flex items-center justify-center">
75
+ <img
76
+ src={current.url}
77
+ alt={current.name || t('fields.image.imageAlt', { index: index + 1 })}
78
+ className="max-h-[85vh] max-w-full rounded-md object-contain"
79
+ />
80
+
81
+ {count > 1 && (
82
+ <>
83
+ <button
84
+ type="button"
85
+ onClick={() => go(-1)}
86
+ aria-label={t('fields.image.previous')}
87
+ className="absolute left-2 top-1/2 -translate-y-1/2 flex size-9 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors"
88
+ >
89
+ <ChevronLeft className="size-5" />
90
+ </button>
91
+ <button
92
+ type="button"
93
+ onClick={() => go(1)}
94
+ aria-label={t('fields.image.next')}
95
+ className="absolute right-2 top-1/2 -translate-y-1/2 flex size-9 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors"
96
+ >
97
+ <ChevronRight className="size-5" />
98
+ </button>
99
+ <div className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-2.5 py-1 text-xs font-medium text-white">
100
+ {t('fields.image.counter', { current: index + 1, total: count })}
101
+ </div>
102
+ </>
103
+ )}
104
+ </div>
105
+ </DialogContent>
106
+ </Dialog>
107
+ );
108
+ }
@@ -0,0 +1,136 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * Regression (#3108): a multi-value lookup hydrated its committed ids with a
5
+ * SERIAL `findOne` per id — 41 selected users meant 41 sequential round-trips,
6
+ * during which the trigger rendered the bare "Select…" placeholder,
7
+ * indistinguishable from having no value at all.
8
+ *
9
+ * Fixed behaviour under test:
10
+ * 1. unresolved ids are fetched in ONE batched `$in` query (chunked), not
11
+ * one findOne per id;
12
+ * 2. while hydration is in flight the trigger shows a loading indicator and
13
+ * the committed-value count instead of the empty placeholder;
14
+ * 3. the loading state settles (falls back to placeholder) when hydration
15
+ * fails, instead of spinning forever.
16
+ */
17
+
18
+ import * as React from 'react';
19
+ import { describe, it, expect, vi, afterEach } from 'vitest';
20
+ import { render, cleanup, waitFor, screen } from '@testing-library/react';
21
+ import { LookupField } from './LookupField';
22
+
23
+ afterEach(cleanup);
24
+
25
+ const USERS = [
26
+ { id: 'u1', name: 'Ada Lovelace' },
27
+ { id: 'u2', name: 'Grace Hopper' },
28
+ { id: 'u3', name: 'Margaret Hamilton' },
29
+ ];
30
+
31
+ describe('LookupField — multi-value hydration batches and shows loading (#3108)', () => {
32
+ it('hydrates several ids with one $in query instead of a findOne per id', async () => {
33
+ const find = vi.fn(async (_obj: string, params: any) => {
34
+ const wanted: any[] = params?.$filter?.id?.$in ?? [];
35
+ return { data: USERS.filter((u) => wanted.includes(u.id)) };
36
+ });
37
+ const findOne = vi.fn(async () => null);
38
+ render(
39
+ <LookupField
40
+ value={['u1', 'u2', 'u3']}
41
+ onChange={() => {}}
42
+ dataSource={{ find, findOne } as never}
43
+ field={{ reference_to: 'sys_user', multiple: true } as never}
44
+ />,
45
+ );
46
+
47
+ await waitFor(() => expect(screen.getByText('Ada Lovelace')).toBeTruthy());
48
+ expect(screen.getByText('Grace Hopper')).toBeTruthy();
49
+ expect(screen.getByText('Margaret Hamilton')).toBeTruthy();
50
+
51
+ // One batched round-trip, zero per-id GETs.
52
+ expect(findOne).not.toHaveBeenCalled();
53
+ const hydrationCalls = find.mock.calls.filter((c) => c[1]?.$filter?.id?.$in);
54
+ expect(hydrationCalls).toHaveLength(1);
55
+ expect(hydrationCalls[0][1].$filter.id.$in).toEqual(['u1', 'u2', 'u3']);
56
+ });
57
+
58
+ it('shows the committed count + spinner while hydration is in flight, never the empty placeholder', async () => {
59
+ let resolveFind!: (v: any) => void;
60
+ const find = vi.fn(
61
+ () => new Promise((resolve) => { resolveFind = resolve; }),
62
+ );
63
+ render(
64
+ <LookupField
65
+ value={['u1', 'u2', 'u3']}
66
+ onChange={() => {}}
67
+ dataSource={{ find } as never}
68
+ field={{ reference_to: 'sys_user', multiple: true } as never}
69
+ />,
70
+ );
71
+
72
+ // In-flight: loading indicator + raw committed count, no "Select…".
73
+ await waitFor(() => expect(screen.getByTestId('lookup-hydrating')).toBeTruthy());
74
+ expect(screen.queryByText('Select...')).toBeNull();
75
+ expect(screen.getByText(/3/)).toBeTruthy();
76
+
77
+ resolveFind({ data: USERS });
78
+ await waitFor(() => expect(screen.getByText('Ada Lovelace')).toBeTruthy());
79
+ expect(screen.queryByTestId('lookup-hydrating')).toBeNull();
80
+ });
81
+
82
+ it('settles (no infinite spinner) when the hydration fetch fails', async () => {
83
+ const find = vi.fn(async (_obj: string, params: any) => {
84
+ if (params?.$filter?.id?.$in) throw new Error('boom');
85
+ return { data: [] };
86
+ });
87
+ render(
88
+ <LookupField
89
+ value={['u1', 'u2']}
90
+ onChange={() => {}}
91
+ dataSource={{ find } as never}
92
+ field={{ reference_to: 'sys_user', multiple: true } as never}
93
+ />,
94
+ );
95
+
96
+ await waitFor(() => expect(find).toHaveBeenCalled());
97
+ // The failed attempt is marked settled — the spinner clears instead of
98
+ // spinning forever (pre-existing fallback: unresolvable ids drop).
99
+ await waitFor(() => expect(screen.queryByTestId('lookup-hydrating')).toBeNull());
100
+ });
101
+
102
+ it('readonly mode shows the loading count instead of an empty value while hydrating', async () => {
103
+ let resolveFind!: (v: any) => void;
104
+ const find = vi.fn(
105
+ () => new Promise((resolve) => { resolveFind = resolve; }),
106
+ );
107
+ render(
108
+ <LookupField
109
+ value={['u1', 'u2']}
110
+ onChange={() => {}}
111
+ readonly
112
+ dataSource={{ find } as never}
113
+ field={{ reference_to: 'sys_user', multiple: true } as never}
114
+ />,
115
+ );
116
+
117
+ await waitFor(() => expect(screen.getByTestId('lookup-hydrating')).toBeTruthy());
118
+
119
+ resolveFind({ data: USERS.slice(0, 2) });
120
+ await waitFor(() => expect(screen.getByText('Ada Lovelace')).toBeTruthy());
121
+ });
122
+
123
+ it('keeps the cheap findOne GET for a single unresolved primary id', async () => {
124
+ const find = vi.fn(async () => ({ data: [] }));
125
+ const findOne = vi.fn(async () => ({ id: 'u1', name: 'Ada' }));
126
+ render(
127
+ <LookupField
128
+ value={['u1']}
129
+ onChange={() => {}}
130
+ dataSource={{ find, findOne } as never}
131
+ field={{ reference_to: 'sys_user', multiple: true } as never}
132
+ />,
133
+ );
134
+ await waitFor(() => expect(findOne).toHaveBeenCalledWith('sys_user', 'u1'));
135
+ });
136
+ });
@@ -0,0 +1,61 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * Regression (objectstack #3508): a lookup whose committed value is NOT the
5
+ * primary id (`id_field: 'name'` — e.g. approval `position` approvers, which
6
+ * the engine routes by machine name) must hydrate its display label by
7
+ * FILTERING on that field. The old path always called `findOne(object, value)`
8
+ * — a primary-id GET — so a stored machine name never resolved and the field
9
+ * showed an empty placeholder over a real value.
10
+ */
11
+
12
+ import * as React from 'react';
13
+ import { describe, it, expect, vi, afterEach } from 'vitest';
14
+ import { render, cleanup, waitFor } from '@testing-library/react';
15
+ import { LookupField } from './LookupField';
16
+
17
+ afterEach(cleanup);
18
+
19
+ describe('LookupField — id_field hydration (#3508)', () => {
20
+ it('hydrates by filtering on id_field when it is not the primary id', async () => {
21
+ const find = vi.fn(async () => ({
22
+ data: [{ id: 'pos_1', name: 'sales_manager', label: 'Sales Manager' }],
23
+ }));
24
+ const findOne = vi.fn(async () => null);
25
+ render(
26
+ <LookupField
27
+ value="sales_manager"
28
+ onChange={() => {}}
29
+ dataSource={{ find, findOne } as never}
30
+ field={{
31
+ reference_to: 'sys_position',
32
+ id_field: 'name',
33
+ display_field: 'label',
34
+ multiple: false,
35
+ } as never}
36
+ />,
37
+ );
38
+ await waitFor(() => expect(find).toHaveBeenCalled());
39
+ // The stored machine name is not a primary id — findOne(object, name)
40
+ // would 404; hydration must go through $filter on the committed field.
41
+ expect(findOne).not.toHaveBeenCalled();
42
+ const hydration = find.mock.calls.find(
43
+ (c) => (c as unknown[])[1] && ((c as never[])[1] as { $filter?: Record<string, unknown> }).$filter?.name === 'sales_manager',
44
+ );
45
+ expect(hydration).toBeTruthy();
46
+ });
47
+
48
+ it('keeps findOne hydration for the default primary-id lookup', async () => {
49
+ const find = vi.fn(async () => ({ data: [] }));
50
+ const findOne = vi.fn(async () => ({ id: 'u1', name: 'Ada' }));
51
+ render(
52
+ <LookupField
53
+ value="u1"
54
+ onChange={() => {}}
55
+ dataSource={{ find, findOne } as never}
56
+ field={{ reference_to: 'sys_user', multiple: false } as never}
57
+ />,
58
+ );
59
+ await waitFor(() => expect(findOne).toHaveBeenCalledWith('sys_user', 'u1'));
60
+ });
61
+ });