@object-ui/fields 16.1.0 → 17.2.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 (129) hide show
  1. package/CHANGELOG.md +626 -0
  2. package/dist/FieldEditWidget.d.ts +9 -2
  3. package/dist/__tests__/spec-symbol-batch7.test.d.ts +8 -0
  4. package/dist/__tests__/widget-props-contract.test.d.ts +8 -0
  5. package/dist/field-type-alias.d.ts +16 -0
  6. package/dist/index.cjs +5 -5
  7. package/dist/index.d.ts +14 -11
  8. package/dist/index.js +2426 -2100
  9. package/dist/widgets/AddressField.d.ts +2 -2
  10. package/dist/widgets/AutoNumberField.d.ts +2 -2
  11. package/dist/widgets/AvatarField.d.ts +2 -2
  12. package/dist/widgets/BooleanField.d.ts +2 -2
  13. package/dist/widgets/CapabilityMultiSelectField.d.ts +2 -2
  14. package/dist/widgets/CheckboxesField.d.ts +2 -2
  15. package/dist/widgets/CodeField.d.ts +2 -2
  16. package/dist/widgets/ColorField.d.ts +2 -2
  17. package/dist/widgets/CurrencyField.d.ts +2 -2
  18. package/dist/widgets/DateField.d.ts +2 -2
  19. package/dist/widgets/DateTimeField.d.ts +2 -2
  20. package/dist/widgets/EmailField.d.ts +2 -2
  21. package/dist/widgets/FileField.d.ts +2 -2
  22. package/dist/widgets/FilterConditionField.d.ts +34 -2
  23. package/dist/widgets/FormulaField.d.ts +2 -2
  24. package/dist/widgets/GeolocationField.d.ts +2 -2
  25. package/dist/widgets/GridField.d.ts +2 -2
  26. package/dist/widgets/ImageField.d.ts +2 -2
  27. package/dist/widgets/ImageLightbox.d.ts +30 -0
  28. package/dist/widgets/ImageLightbox.test.d.ts +1 -0
  29. package/dist/widgets/LocationField.d.ts +2 -2
  30. package/dist/widgets/LookupField.d.ts +2 -2
  31. package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
  32. package/dist/widgets/LookupField.idField.test.d.ts +1 -0
  33. package/dist/widgets/MasterDetailField.d.ts +2 -2
  34. package/dist/widgets/MultiSelectField.d.ts +2 -2
  35. package/dist/widgets/NumberField.d.ts +2 -2
  36. package/dist/widgets/ObjectField.d.ts +2 -2
  37. package/dist/widgets/ObjectRefField.d.ts +2 -2
  38. package/dist/widgets/PasswordField.d.ts +2 -2
  39. package/dist/widgets/PercentField.d.ts +2 -2
  40. package/dist/widgets/PhoneField.d.ts +2 -2
  41. package/dist/widgets/QRCodeField.d.ts +2 -2
  42. package/dist/widgets/RadioField.d.ts +2 -2
  43. package/dist/widgets/RatingField.d.ts +2 -2
  44. package/dist/widgets/RecipientPickerField.d.ts +2 -2
  45. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  46. package/dist/widgets/RichTextField.d.ts +2 -2
  47. package/dist/widgets/SelectField.d.ts +2 -2
  48. package/dist/widgets/SignatureField.d.ts +2 -2
  49. package/dist/widgets/SliderField.d.ts +2 -2
  50. package/dist/widgets/SummaryField.d.ts +2 -2
  51. package/dist/widgets/TagsField.d.ts +2 -2
  52. package/dist/widgets/TextAreaField.d.ts +2 -2
  53. package/dist/widgets/TextField.d.ts +2 -2
  54. package/dist/widgets/TimeField.d.ts +2 -2
  55. package/dist/widgets/UrlField.d.ts +2 -2
  56. package/dist/widgets/UserField.d.ts +2 -2
  57. package/dist/widgets/VectorField.d.ts +2 -2
  58. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +8 -0
  59. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +8 -0
  60. package/dist/widgets/file-value.d.ts +99 -0
  61. package/dist/widgets/file-value.test.d.ts +1 -0
  62. package/dist/widgets/types.d.ts +119 -3
  63. package/dist/widgets/useFieldTranslation.d.ts +2 -0
  64. package/package.json +9 -8
  65. package/src/FieldEditWidget.test.ts +74 -2
  66. package/src/FieldEditWidget.tsx +50 -8
  67. package/src/__tests__/spec-symbol-batch7.test.ts +112 -0
  68. package/src/__tests__/widget-props-contract.test.tsx +129 -0
  69. package/src/complex-widgets.test.tsx +3 -3
  70. package/src/datetime-widgets.test.tsx +2 -2
  71. package/src/field-type-alias.ts +100 -0
  72. package/src/index.tsx +97 -134
  73. package/src/widgets/AddressField.tsx +2 -2
  74. package/src/widgets/AutoNumberField.tsx +2 -2
  75. package/src/widgets/AvatarField.tsx +2 -2
  76. package/src/widgets/BooleanField.tsx +2 -2
  77. package/src/widgets/CapabilityMultiSelectField.tsx +4 -4
  78. package/src/widgets/CheckboxesField.tsx +12 -9
  79. package/src/widgets/CodeField.tsx +2 -2
  80. package/src/widgets/ColorField.tsx +2 -2
  81. package/src/widgets/CurrencyField.tsx +2 -2
  82. package/src/widgets/DateField.tsx +2 -2
  83. package/src/widgets/DateTimeField.tsx +2 -2
  84. package/src/widgets/EmailField.tsx +2 -2
  85. package/src/widgets/FileField.tsx +90 -59
  86. package/src/widgets/FilterConditionField.tsx +112 -12
  87. package/src/widgets/FormulaField.tsx +2 -2
  88. package/src/widgets/GeolocationField.tsx +2 -2
  89. package/src/widgets/GridField.tsx +3 -3
  90. package/src/widgets/ImageField.tsx +73 -35
  91. package/src/widgets/ImageLightbox.test.tsx +52 -0
  92. package/src/widgets/ImageLightbox.tsx +108 -0
  93. package/src/widgets/LocationField.tsx +2 -2
  94. package/src/widgets/LookupField.hydration.test.tsx +136 -0
  95. package/src/widgets/LookupField.idField.test.tsx +61 -0
  96. package/src/widgets/LookupField.tsx +116 -19
  97. package/src/widgets/MasterDetailField.tsx +2 -2
  98. package/src/widgets/MultiSelectField.tsx +10 -7
  99. package/src/widgets/NumberField.tsx +2 -2
  100. package/src/widgets/ObjectField.tsx +2 -2
  101. package/src/widgets/ObjectRefField.tsx +13 -8
  102. package/src/widgets/PasswordField.tsx +2 -2
  103. package/src/widgets/PeoplePicker.test.tsx +20 -0
  104. package/src/widgets/PeoplePicker.tsx +15 -3
  105. package/src/widgets/PercentField.tsx +2 -2
  106. package/src/widgets/PhoneField.tsx +2 -2
  107. package/src/widgets/QRCodeField.tsx +2 -2
  108. package/src/widgets/RadioField.tsx +10 -7
  109. package/src/widgets/RatingField.tsx +2 -2
  110. package/src/widgets/RecipientPickerField.test.tsx +157 -0
  111. package/src/widgets/RecipientPickerField.tsx +40 -20
  112. package/src/widgets/RichTextField.tsx +2 -2
  113. package/src/widgets/SelectField.tsx +5 -5
  114. package/src/widgets/SignatureField.tsx +2 -2
  115. package/src/widgets/SliderField.tsx +2 -2
  116. package/src/widgets/SummaryField.tsx +2 -2
  117. package/src/widgets/TagsField.tsx +3 -3
  118. package/src/widgets/TextAreaField.tsx +6 -3
  119. package/src/widgets/TextField.tsx +2 -2
  120. package/src/widgets/TimeField.tsx +2 -2
  121. package/src/widgets/UrlField.tsx +2 -2
  122. package/src/widgets/UserField.tsx +2 -2
  123. package/src/widgets/VectorField.tsx +2 -2
  124. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
  125. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
  126. package/src/widgets/file-value.test.ts +230 -0
  127. package/src/widgets/file-value.ts +246 -0
  128. package/src/widgets/types.ts +129 -6
  129. package/src/widgets/useFieldTranslation.ts +30 -0
@@ -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
+ });
@@ -10,7 +10,7 @@ import { cn,
10
10
  PopoverTrigger,
11
11
  PopoverContent, EmptyValue } from '@object-ui/components';
12
12
  import { Search, X, Loader2, AlertCircle, Plus, TableProperties } from 'lucide-react';
13
- import { FieldWidgetProps } from './types';
13
+ import { FieldWidgetComponentProps } from './types';
14
14
  import type { DataSource, QueryParams, LookupColumnDef } from '@object-ui/types';
15
15
  import { RecordPickerDialog, lookupFiltersToRecord } from './RecordPickerDialog';
16
16
  import type { RecordPickerFilterColumn } from './RecordPickerDialog';
@@ -191,7 +191,7 @@ function mapFieldTypeToFilterType(
191
191
  * from the referenced object using `DataSource.find()`.
192
192
  * Falls back to static `options` when no DataSource is available.
193
193
  */
194
- export function LookupField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
194
+ export function LookupField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<any>) {
195
195
  const [isOpen, setIsOpen] = useState(false);
196
196
  const { t } = useFieldTranslation();
197
197
  const listboxId = React.useId();
@@ -205,6 +205,12 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
205
205
  // even when the record wasn't part of the Level 1 popover fetch.
206
206
  const [pickerResolvedRecords, setPickerResolvedRecords] = useState<LookupOption[]>([]);
207
207
 
208
+ // Ids whose hydration attempt has FINISHED — found or not — keyed by
209
+ // String(id). Distinguishes "still loading" from "unresolvable" (deleted
210
+ // record, fetch failure) so the trigger's hydrating state can't stick
211
+ // forever on an id that will never resolve (#3108).
212
+ const [hydrationSettled, setHydrationSettled] = useState<Set<string>>(() => new Set());
213
+
208
214
  // Arrow-key active index (-1 = none)
209
215
  const [activeIndex, setActiveIndex] = useState(-1);
210
216
  const listRef = useRef<HTMLDivElement>(null);
@@ -274,13 +280,13 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
274
280
  }, [fieldMeta?.depends_on, fieldMeta?.dependsOn]);
275
281
 
276
282
  // Resolve dependent field values from explicit prop or SchemaRendererContext.data
277
- const dependentValuesProp = (props as any).dependentValues as Record<string, any> | undefined;
283
+ const dependentValuesProp = props.dependentValues;
278
284
 
279
285
  // Resolve DataSource: explicit prop > field-level > wrapper field > SchemaRendererContext > none
280
286
  const ctx = useContext(SchemaRendererContext);
281
287
  const contextDataSource = ctx?.dataSource ?? null;
282
288
  const dataSource: DataSource | null =
283
- (props as any).dataSource ?? lookupField?.dataSource ?? fieldMeta?.dataSource ?? contextDataSource;
289
+ (props.dataSource as DataSource | null | undefined) ?? lookupField?.dataSource ?? fieldMeta?.dataSource ?? contextDataSource;
284
290
 
285
291
  /** Resolve dependent values from the explicit prop (preferred), the form-data
286
292
  * context provided by @object-ui/react, or finally `ctx.data` (record scope). */
@@ -363,7 +369,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
363
369
 
364
370
  // Optional create-new callback
365
371
  const onCreateNew: ((searchQuery: string) => void) | undefined =
366
- (props as any).onCreateNew ?? lookupField?.onCreateNew;
372
+ props.onCreateNew ?? lookupField?.onCreateNew;
367
373
 
368
374
  // State for the full Record Picker dialog (Level 2)
369
375
  const [isPickerOpen, setIsPickerOpen] = useState(false);
@@ -476,8 +482,13 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
476
482
  (async () => {
477
483
  try {
478
484
  const fetched: LookupOption[] = [];
479
- for (const id of unresolved) {
480
- if (typeof (dataSource as any).findOne === 'function') {
485
+ // Single id: the pre-existing cheap paths — a primary-id `findOne`
486
+ // GET, or an equality filter when the field commits a different
487
+ // column (`id_field: 'name'` — e.g. position machine names,
488
+ // objectstack #3508).
489
+ if (unresolved.length === 1) {
490
+ const id = unresolved[0];
491
+ if (typeof (dataSource as any).findOne === 'function' && idField === 'id') {
481
492
  const rec = await (dataSource as any).findOne(referenceTo, id);
482
493
  if (rec) fetched.push(recordToOption(rec, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
483
494
  } else {
@@ -485,9 +496,35 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
485
496
  $filter: { [idField]: id },
486
497
  $top: 1,
487
498
  } as QueryParams);
488
- const rows = res?.data ?? res ?? [];
499
+ const rows = (res as any)?.data ?? res ?? [];
489
500
  if (rows[0]) fetched.push(recordToOption(rows[0], displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
490
501
  }
502
+ } else {
503
+ // SEVERAL unresolved ids: one `$in` query per chunk. A multi-value
504
+ // field can hold dozens of ids, and the old serial findOne-per-id
505
+ // took seconds while the trigger sat on the empty "Select…"
506
+ // placeholder (#3108).
507
+ // Chunked to LOOKUP_PAGE_SIZE so no request exceeds the page size
508
+ // a server may cap `$top` at; chunks run in parallel.
509
+ const chunks: any[][] = [];
510
+ for (let i = 0; i < unresolved.length; i += LOOKUP_PAGE_SIZE) {
511
+ chunks.push(unresolved.slice(i, i + LOOKUP_PAGE_SIZE));
512
+ }
513
+ const results = await Promise.all(
514
+ chunks.map((chunk) =>
515
+ dataSource.find(referenceTo, {
516
+ $filter: { [idField]: { $in: chunk } },
517
+ $top: chunk.length,
518
+ } as QueryParams),
519
+ ),
520
+ );
521
+ for (const res of results) {
522
+ const rows = (res as any)?.data ?? res ?? [];
523
+ if (!Array.isArray(rows)) continue;
524
+ for (const row of rows) {
525
+ fetched.push(recordToOption(row, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
526
+ }
527
+ }
491
528
  }
492
529
  if (!cancelled && fetched.length) {
493
530
  setPickerResolvedRecords((prev) => {
@@ -498,6 +535,17 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
498
535
  }
499
536
  } catch {
500
537
  // Ignore — chip will fall back to showing the raw id.
538
+ } finally {
539
+ // Mark the attempt settled — found or not — so `hydrating` below
540
+ // clears even for ids that no longer resolve (deleted records,
541
+ // fetch failures) instead of spinning forever.
542
+ if (!cancelled) {
543
+ setHydrationSettled((prev) => {
544
+ const next = new Set(prev);
545
+ for (const id of unresolved) next.add(String(id));
546
+ return next;
547
+ });
548
+ }
501
549
  }
502
550
  })();
503
551
  return () => {
@@ -576,6 +624,31 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
576
624
  [findOption, findOptionLoose, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema],
577
625
  );
578
626
 
627
+ // A value can hold bare ids that no option list resolves YET — the batch
628
+ // hydration above is still in flight. While any such id is pending, the
629
+ // trigger must not present the field as empty: a 41-value lookup rendered
630
+ // the bare "Select…" placeholder for seconds, indistinguishable from
631
+ // having no value at all (#3108).
632
+ const hydrating = useMemo(() => {
633
+ if (!hasDataSource) return false;
634
+ const raw: any[] = multiple
635
+ ? Array.isArray(value) ? value : []
636
+ : value != null && value !== '' ? [value] : [];
637
+ return raw.some(
638
+ (v) =>
639
+ v != null && v !== '' && typeof v !== 'object' && !parseReferenceObjectString(v) &&
640
+ !findOption(v) && !findOptionLoose(v) &&
641
+ !hydrationSettled.has(String(v)),
642
+ );
643
+ }, [hasDataSource, multiple, value, findOption, findOptionLoose, hydrationSettled]);
644
+
645
+ // Committed-value count. During hydration the resolved-option count
646
+ // undercounts (unresolved ids are filtered out below), so the trigger
647
+ // shows this raw count instead.
648
+ const rawSelectedCount = multiple
649
+ ? (Array.isArray(value) ? value : []).filter((v) => v != null && v !== '').length
650
+ : value != null && value !== '' ? 1 : 0;
651
+
579
652
  const selectedOptions = multiple
580
653
  ? (Array.isArray(value) ? value : []).map(resolveSelectedOption).filter(Boolean)
581
654
  : value ? [resolveSelectedOption(value)].filter(Boolean) : [];
@@ -584,7 +657,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
584
657
  // auto-fill sibling fields from it — e.g. a line-item grid copying a product's
585
658
  // unit_price/description when the item is chosen. When provided (single
586
659
  // select), it drives the update and the host owns the resulting value change.
587
- const onSelectRecord = (props as any).onSelectRecord as ((record: LookupOption) => void) | undefined;
660
+ const onSelectRecord = props.onSelectRecord;
588
661
 
589
662
  const handleSelect = useCallback(
590
663
  (option: LookupOption) => {
@@ -797,6 +870,17 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
797
870
 
798
871
  if (readonly) {
799
872
  if (!selectedOptions.length) {
873
+ if (hydrating) {
874
+ return (
875
+ <span
876
+ className="inline-flex items-center gap-1.5 text-sm text-muted-foreground"
877
+ data-testid="lookup-hydrating"
878
+ >
879
+ <Loader2 className="size-3.5 animate-spin" />
880
+ {multiple ? t('table.selected', { count: rawSelectedCount }) : t('lookup.loading')}
881
+ </span>
882
+ );
883
+ }
800
884
  return <EmptyValue />;
801
885
  }
802
886
 
@@ -822,7 +906,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
822
906
  // Compact mode (e.g. inside a line-item grid cell): show the selected value
823
907
  // INSIDE a borderless trigger on a single line — no chip stacked above a
824
908
  // separate "Select…" button (which double-stacks and wastes the row height).
825
- const compact = !!(props as any).compact;
909
+ const compact = !!props.compact;
826
910
  const singleSelectedLabel = selectedOptions[0]?.label || selectedOptions[0]?.[displayField];
827
911
 
828
912
  // Shared field trigger — the anchor for either the inline PeoplePicker
@@ -836,21 +920,34 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
836
920
  compact && 'h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:ring-1 focus-visible:ring-ring/60',
837
921
  )}
838
922
  type="button"
839
- disabled={dependenciesMissing || (props as any).disabled}
840
- data-testid={dependenciesMissing ? 'lookup-trigger-gated' : (((props as any).name || lookupField?.name) ? `lookup-trigger-${(props as any).name || lookupField.name}` : 'lookup-trigger')}
923
+ disabled={dependenciesMissing || props.disabled}
924
+ data-testid={dependenciesMissing ? 'lookup-trigger-gated' : ((props.name || lookupField?.name) ? `lookup-trigger-${props.name || lookupField.name}` : 'lookup-trigger')}
841
925
  title={dependenciesMissing
842
926
  ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
843
927
  : undefined}
844
928
  >
845
- <Search className={cn('size-4 shrink-0 text-muted-foreground', compact ? 'mr-1.5' : 'mr-2')} />
929
+ {hydrating ? (
930
+ <Loader2
931
+ className={cn('size-4 shrink-0 animate-spin text-muted-foreground', compact ? 'mr-1.5' : 'mr-2')}
932
+ data-testid="lookup-hydrating"
933
+ />
934
+ ) : (
935
+ <Search className={cn('size-4 shrink-0 text-muted-foreground', compact ? 'mr-1.5' : 'mr-2')} />
936
+ )}
846
937
  <span className={cn('truncate', compact && selectedOptions.length === 0 && 'text-muted-foreground')}>
847
938
  {dependenciesMissing
848
939
  ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
849
- : compact && !multiple && selectedOptions.length > 0
850
- ? singleSelectedLabel
851
- : selectedOptions.length === 0
852
- ? lookupField?.placeholder || t('common.select')
853
- : multiple ? t('table.selected', { count: selectedOptions.length }) : t('common.select')}
940
+ : hydrating
941
+ // The value EXISTS but its labels are still loading — say so
942
+ // instead of the empty placeholder (#3108).
943
+ ? multiple
944
+ ? t('table.selected', { count: rawSelectedCount })
945
+ : t('lookup.loading')
946
+ : compact && !multiple && selectedOptions.length > 0
947
+ ? singleSelectedLabel
948
+ : selectedOptions.length === 0
949
+ ? lookupField?.placeholder || t('common.select')
950
+ : multiple ? t('table.selected', { count: selectedOptions.length }) : t('common.select')}
854
951
  </span>
855
952
  </Button>
856
953
  );
@@ -1117,7 +1214,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
1117
1214
  type="button"
1118
1215
  // Gated exactly like the main trigger (#2215) — pre-fix this button
1119
1216
  // opened the full unscoped table while the dependency was missing.
1120
- disabled={dependenciesMissing || (props as any).disabled}
1217
+ disabled={dependenciesMissing || props.disabled}
1121
1218
  onClick={() => setIsPickerOpen(true)}
1122
1219
  aria-label={t('lookup.browseAll')}
1123
1220
  title={dependenciesMissing
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import { Button, Badge, cn } from '@object-ui/components';
3
3
  import { Plus, X, ExternalLink } from 'lucide-react';
4
- import { FieldWidgetProps } from './types';
4
+ import { FieldWidgetComponentProps } from './types';
5
5
 
6
6
  /**
7
7
  * Master-Detail relationship data structure
@@ -23,7 +23,7 @@ export function MasterDetailField({
23
23
  readonly,
24
24
  className,
25
25
  ...props
26
- }: FieldWidgetProps<MasterDetailValue[]>) {
26
+ }: FieldWidgetComponentProps<MasterDetailValue[]>) {
27
27
  const items = value || [];
28
28
  const config = field || (props as any).schema;
29
29
 
@@ -1,7 +1,7 @@
1
1
  import React, { useEffect } from 'react';
2
2
  import { Badge, EmptyValue, cn } from '@object-ui/components';
3
3
  import type { OptionLike } from '@object-ui/core';
4
- import { FieldWidgetProps } from './types';
4
+ import { FieldWidgetComponentProps } from './types';
5
5
  import { useCascadingOptions } from './useCascadingOptions';
6
6
 
7
7
  interface Option extends OptionLike { color?: string }
@@ -32,11 +32,11 @@ export function MultiSelectField({
32
32
  emptyHint: _emptyHint,
33
33
  dataSource: _dataSource,
34
34
  ...props
35
- }: FieldWidgetProps<string[]>) {
35
+ }: FieldWidgetComponentProps<string[]>) {
36
36
  const config = (field || schema) as any;
37
37
  const rawOptions: Option[] = config?.options || [];
38
38
  const selected: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
39
- const fieldName = (props as any).name || config?.name || (props as any).id || '';
39
+ const fieldName = props.name || config?.name || props.id || '';
40
40
 
41
41
  const dependsOn = config?.dependsOn ?? dependsOnProp;
42
42
  const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
@@ -98,13 +98,16 @@ export function MultiSelectField({
98
98
  data-testid={fieldName ? `multiselect-${fieldName}` : undefined}
99
99
  >
100
100
  {options.map((opt) => {
101
- const active = selected.includes(opt.value);
101
+ // Multi-value fields store string arrays — stringify the (possibly
102
+ // numeric, #3090-widened) authored value at this boundary.
103
+ const value = String(opt.value);
104
+ const active = selected.includes(value);
102
105
  return (
103
106
  <button
104
107
  type="button"
105
- key={opt.value}
106
- onClick={() => toggle(opt.value)}
107
- disabled={(props as any).disabled}
108
+ key={value}
109
+ onClick={() => toggle(value)}
110
+ disabled={props.disabled}
108
111
  aria-pressed={active}
109
112
  data-testid={`multiselect-option-${opt.value}`}
110
113
  className={cn(
@@ -1,13 +1,13 @@
1
1
  import React from 'react';
2
2
  import { Input, EmptyValue } from '@object-ui/components';
3
3
  import { NumberFieldMetadata } from '@object-ui/types';
4
- import { FieldWidgetProps } from './types';
4
+ import { FieldWidgetComponentProps } from './types';
5
5
 
6
6
  /**
7
7
  * NumberField - Numeric input with optional decimal precision
8
8
  * Supports min/max/step constraints and configurable decimal precision
9
9
  */
10
- export function NumberField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<number>) {
10
+ export function NumberField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<number>) {
11
11
  if (readonly) {
12
12
  return value == null ? <EmptyValue /> : <span className="text-sm">{value}</span>;
13
13
  }
@@ -1,12 +1,12 @@
1
1
  import React, { useState, useEffect } from 'react';
2
2
  import { Textarea, cn, EmptyValue } from '@object-ui/components';
3
- import { FieldWidgetProps } from './types';
3
+ import { FieldWidgetComponentProps } from './types';
4
4
 
5
5
  /**
6
6
  * ObjectField - JSON object editor
7
7
  * Allows editing structured JSON data
8
8
  */
9
- export function ObjectField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
9
+ export function ObjectField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<any>) {
10
10
  const config = field || (props as any).schema;
11
11
 
12
12
  // Initialize string state based on value
@@ -1,7 +1,8 @@
1
1
  import React from 'react';
2
2
  import { Combobox, EmptyValue, cn } from '@object-ui/components';
3
3
  import { SchemaRendererContext } from '@object-ui/react';
4
- import type { FieldWidgetProps } from './types';
4
+ import type { FieldWidgetComponentProps } from './types';
5
+ import { useFieldTranslation } from './useFieldTranslation';
5
6
 
6
7
  /**
7
8
  * ObjectRefField — object-name picker for form fields that store the machine
@@ -31,10 +32,11 @@ export function ObjectRefField({
31
32
  readonly,
32
33
  className,
33
34
  ...props
34
- }: FieldWidgetProps<string>) {
35
+ }: FieldWidgetComponentProps<string>) {
35
36
  const ctx = React.useContext(SchemaRendererContext);
36
- const dataSource: any = (props as any).dataSource ?? (ctx as any)?.dataSource ?? null;
37
- const disabled = (props as any).disabled as boolean | undefined;
37
+ const { t } = useFieldTranslation();
38
+ const dataSource: any = props.dataSource ?? (ctx as any)?.dataSource ?? null;
39
+ const disabled = props.disabled;
38
40
 
39
41
  const [objects, setObjects] = React.useState<ObjectHeader[] | null>(null);
40
42
 
@@ -94,11 +96,14 @@ export function ObjectRefField({
94
96
  options={options}
95
97
  value={value ?? ''}
96
98
  onValueChange={(v) => onChange(v as any)}
97
- placeholder={objects === null ? 'Loading objects…' : 'Select an object'}
98
- searchPlaceholder="Search objects…"
99
- emptyText={objects === null ? 'Loading…' : 'No objects found'}
99
+ placeholder={objects === null ? t('fields.objectRef.loading') : t('fields.objectRef.placeholder')}
100
+ searchPlaceholder={t('fields.objectRef.search')}
101
+ emptyText={objects === null ? t('common.loading') : t('fields.objectRef.empty')}
100
102
  disabled={disabled}
101
- className={cn(className)}
103
+ // Fill the form column like every other input. Combobox's own
104
+ // `w-[200px]` is a component default that left this control stranded at
105
+ // a third of the row while `名称` beside it ran full width.
106
+ className={cn('w-full', className)}
102
107
  />
103
108
  );
104
109
  }
@@ -2,13 +2,13 @@ import React, { useState } from 'react';
2
2
  import { Input } from '@object-ui/components';
3
3
  import { Button } from '@object-ui/components';
4
4
  import { Eye, EyeOff } from 'lucide-react';
5
- import { FieldWidgetProps } from './types';
5
+ import { FieldWidgetComponentProps } from './types';
6
6
 
7
7
  /**
8
8
  * PasswordField - Secure password input with visibility toggle
9
9
  * Includes show/hide toggle button and masks value in readonly mode
10
10
  */
11
- export function PasswordField({ value, onChange, field, readonly, className, ...props }: FieldWidgetProps<string>) {
11
+ export function PasswordField({ value, onChange, field, readonly, className, ...props }: FieldWidgetComponentProps<string>) {
12
12
  const [showPassword, setShowPassword] = useState(false);
13
13
  const config = field || (props as any).schema;
14
14
 
@@ -189,6 +189,26 @@ describe('PeoplePicker', () => {
189
189
  expect(onSelect).toHaveBeenCalledWith('u1');
190
190
  });
191
191
 
192
+ it('keyboard: cursor resets when the results are replaced by a search', async () => {
193
+ // Pins the semantics the render-phase reset must preserve: a NEW result
194
+ // set does not inherit the previous set's cursor. (The reset moved out of
195
+ // an effect into the render phase so it can never land AFTER a subsequent
196
+ // ArrowDown — the CI flake on the test above.)
197
+ const ds = makeDataSource();
198
+ render(
199
+ <PeoplePicker {...baseProps} dataSource={ds} onSelect={vi.fn()} onSelectRecords={vi.fn()} onOpenChange={vi.fn()} />,
200
+ );
201
+ await waitFor(() => expect(screen.getByText('Amy Lin')).toBeTruthy());
202
+ const search = screen.getByTestId('people-picker-search');
203
+ fireEvent.keyDown(search, { key: 'ArrowDown' });
204
+ await waitFor(() =>
205
+ expect(screen.getAllByTestId('person-row')[0].getAttribute('data-active')).toBe('true'),
206
+ );
207
+ fireEvent.change(search, { target: { value: 'bob' } });
208
+ await waitFor(() => expect(screen.getAllByTestId('person-row')).toHaveLength(1));
209
+ expect(screen.getAllByTestId('person-row')[0].getAttribute('data-active')).not.toBe('true');
210
+ });
211
+
192
212
  it('keyboard: Backspace on empty search removes the last chip (multi)', async () => {
193
213
  const ds = makeDataSource();
194
214
  render(
@@ -320,10 +320,22 @@ export function PeoplePicker({
320
320
  () => query.records.map(r => String(getPersonId(r, idField))).join(''),
321
321
  [query.records, idField],
322
322
  );
323
- useEffect(() => {
323
+ // The reset runs in the RENDER PHASE (the "adjusting state during render"
324
+ // pattern), NOT in an effect. An effect flushes asynchronously after the
325
+ // render that delivered the records — so a reset queued by their arrival
326
+ // could land AFTER a subsequent ArrowDown and eat the just-set cursor.
327
+ // That is the residual ArrowDown→Enter flake the signature key above did
328
+ // not close (CI reproduced it under load), and a real fast-fingers UX bug:
329
+ // rows appear, the user presses ArrowDown, the highlight vanishes.
330
+ // Resetting during the same render that shows the new rows makes the
331
+ // ordering deterministic — by the time the rows are visible, the reset
332
+ // has already happened.
333
+ const cursorEpoch = `${query.search}\u0000${recordsSignature}`;
334
+ const [seenCursorEpoch, setSeenCursorEpoch] = useState(cursorEpoch);
335
+ if (seenCursorEpoch !== cursorEpoch) {
336
+ setSeenCursorEpoch(cursorEpoch);
324
337
  setActiveIndex(-1);
325
- // eslint-disable-next-line react-hooks/exhaustive-deps
326
- }, [query.search, recordsSignature]);
338
+ }
327
339
  // Keep it in range if the list shrinks.
328
340
  useEffect(() => {
329
341
  setActiveIndex(i => (i >= navList.length ? navList.length - 1 : i));