@object-ui/fields 3.1.2 → 3.1.4

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 (91) hide show
  1. package/dist/AddressField-B6fPb2Fs.js +3 -0
  2. package/dist/AddressField-Ctsxkj35.cjs +1 -0
  3. package/dist/AutoNumberField-DXIYJfiB.js +3 -0
  4. package/dist/AutoNumberField-f76XU1bR.cjs +1 -0
  5. package/dist/AvatarField-BHpex3Wa.js +3 -0
  6. package/dist/AvatarField-CXOdYavl.cjs +1 -0
  7. package/dist/BooleanField-Bs0urhsx.js +3 -0
  8. package/dist/BooleanField-DeexI_O2.cjs +1 -0
  9. package/dist/CodeField-DDOMH8E8.cjs +1 -0
  10. package/dist/CodeField-tRACqngO.js +3 -0
  11. package/dist/ColorField-Bar6bOJw.js +3 -0
  12. package/dist/ColorField-CM9CxZ9h.cjs +1 -0
  13. package/dist/CurrencyField-CezTX_rA.cjs +1 -0
  14. package/dist/CurrencyField-D4VwXI9k.js +3 -0
  15. package/dist/DateField-DDUllj7w.cjs +1 -0
  16. package/dist/DateField-DjGiK6BH.js +3 -0
  17. package/dist/DateTimeField-DBh-dYdR.js +3 -0
  18. package/dist/DateTimeField-DDqgVN3b.cjs +1 -0
  19. package/dist/EmailField-B-3nY8Pr.cjs +1 -0
  20. package/dist/EmailField-Cp6yJlpH.js +3 -0
  21. package/dist/FileField-C1bcL7tu.cjs +1 -0
  22. package/dist/FileField-DUgt6ai3.js +3 -0
  23. package/dist/FormulaField-BLV1KgkQ.js +3 -0
  24. package/dist/FormulaField-CsbQIZ7S.cjs +1 -0
  25. package/dist/GeolocationField-BbIRH51u.js +3 -0
  26. package/dist/GeolocationField-UFu9VR-x.cjs +1 -0
  27. package/dist/GridField-2m74haE0.cjs +1 -0
  28. package/dist/GridField-O3Jd1NuJ.js +3 -0
  29. package/dist/ImageField-Cna7g6KL.cjs +1 -0
  30. package/dist/ImageField-rc1Nzcvb.js +3 -0
  31. package/dist/LocationField-DpRe9QlH.cjs +1 -0
  32. package/dist/LocationField-QZPPYo0J.js +3 -0
  33. package/dist/LookupField-BX4sK8NG.js +904 -0
  34. package/dist/LookupField-C9rPrHi_.cjs +1 -0
  35. package/dist/MasterDetailField-BXncIT1s.js +3 -0
  36. package/dist/MasterDetailField-TNP2825Z.cjs +1 -0
  37. package/dist/NumberField-DCHTzmDG.js +3 -0
  38. package/dist/NumberField-SqRwbcY8.cjs +1 -0
  39. package/dist/ObjectField-BEUHpn_X.js +3 -0
  40. package/dist/ObjectField-BWZwaFIG.cjs +1 -0
  41. package/dist/PasswordField-BZ_OeQyw.js +3 -0
  42. package/dist/PasswordField-kLp-ZyPm.cjs +1 -0
  43. package/dist/PercentField-B2t2knlc.js +3 -0
  44. package/dist/PercentField-BzhD6tQm.cjs +1 -0
  45. package/dist/PhoneField-CirvuysP.cjs +1 -0
  46. package/dist/PhoneField-CkrCFmcO.js +3 -0
  47. package/dist/QRCodeField-C4OOLCbE.cjs +1 -0
  48. package/dist/QRCodeField-CQoGow8y.js +3 -0
  49. package/dist/RatingField-CZG8SgQ8.cjs +1 -0
  50. package/dist/RatingField-DecDJkou.js +3 -0
  51. package/dist/RichTextField-Bog2i4NN.cjs +1 -0
  52. package/dist/RichTextField-b-8QbNCJ.js +3 -0
  53. package/dist/SelectField-CAa6c0cB.cjs +1 -0
  54. package/dist/SelectField-DMy5IxRc.js +3 -0
  55. package/dist/SignatureField-BvTPE7Dn.js +3 -0
  56. package/dist/SignatureField-jsb-0AYp.cjs +1 -0
  57. package/dist/SliderField-B7WvKFv7.js +3 -0
  58. package/dist/SliderField-DRjEJSxK.cjs +1 -0
  59. package/dist/SummaryField-DA16Edq3.cjs +1 -0
  60. package/dist/SummaryField-DVSVfKmY.js +3 -0
  61. package/dist/TextAreaField--ut87UiB.js +3 -0
  62. package/dist/TextAreaField-Cxp3DFC6.cjs +1 -0
  63. package/dist/TextField-CkEDGbkQ.js +3 -0
  64. package/dist/TextField-aLAzBaLZ.cjs +1 -0
  65. package/dist/TimeField-D0lF0y_I.cjs +1 -0
  66. package/dist/TimeField-DA_S6lQV.js +3 -0
  67. package/dist/UrlField-D0ugmD3f.cjs +1 -0
  68. package/dist/UrlField-DCnXHR1A.js +3 -0
  69. package/dist/UserField-DGlorzyG.cjs +1 -0
  70. package/dist/UserField-V0GmvyGV.js +3 -0
  71. package/dist/VectorField-BSD85ntU.cjs +1 -0
  72. package/dist/VectorField-CVi5yrrc.js +3 -0
  73. package/dist/fields/src/index.d.ts +8 -0
  74. package/dist/fields/src/record-picker-dialog.stories.d.ts +25 -0
  75. package/dist/fields/src/record-picker.test.d.ts +1 -0
  76. package/dist/fields/src/widgets/LookupField.d.ts +13 -2
  77. package/dist/fields/src/widgets/RecordPickerDialog.d.ts +166 -0
  78. package/dist/fields/src/widgets/_cell-renderer-bridge.d.ts +3 -0
  79. package/dist/index.cjs +4 -9
  80. package/dist/index.js +2229 -2682
  81. package/dist/jsx-runtime-DHCF5ANT.cjs +6 -0
  82. package/dist/jsx-runtime-MxADXuRH.js +189 -0
  83. package/package.json +8 -7
  84. package/src/__tests__/cell-renderers.test.tsx +133 -4
  85. package/src/complex-widgets.test.tsx +426 -2
  86. package/src/index.tsx +76 -22
  87. package/src/record-picker-dialog.stories.tsx +160 -0
  88. package/src/record-picker.test.tsx +1696 -0
  89. package/src/widgets/LookupField.tsx +485 -80
  90. package/src/widgets/RecordPickerDialog.tsx +1182 -0
  91. package/src/widgets/_cell-renderer-bridge.ts +19 -0
@@ -1,5 +1,5 @@
1
- import { render, screen, fireEvent } from '@testing-library/react';
2
- import { describe, it, expect, vi } from 'vitest';
1
+ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
3
  import { LookupField } from './widgets/LookupField';
4
4
  import { MasterDetailField } from './widgets/MasterDetailField';
5
5
  import { GridField } from './widgets/GridField';
@@ -60,6 +60,430 @@ describe('Complex & Relationship Widgets', () => {
60
60
  });
61
61
  });
62
62
 
63
+ describe('LookupField — Dynamic DataSource', () => {
64
+ const mockDataSource = {
65
+ find: vi.fn(),
66
+ findOne: vi.fn(),
67
+ create: vi.fn(),
68
+ update: vi.fn(),
69
+ delete: vi.fn(),
70
+ };
71
+
72
+ const dynamicField = {
73
+ ...mockField,
74
+ label: 'Customer',
75
+ reference_to: 'customers',
76
+ reference_field: 'name',
77
+ } as any;
78
+
79
+ const dynamicProps: FieldWidgetProps<any> = {
80
+ ...baseProps,
81
+ field: dynamicField,
82
+ dataSource: mockDataSource,
83
+ };
84
+
85
+ beforeEach(() => {
86
+ vi.clearAllMocks();
87
+ });
88
+
89
+ it('fetches data from DataSource when dialog opens', async () => {
90
+ mockDataSource.find.mockResolvedValue({
91
+ data: [
92
+ { id: '1', name: 'Acme Corp' },
93
+ { id: '2', name: 'Beta Inc' },
94
+ ],
95
+ total: 2,
96
+ });
97
+
98
+ render(<LookupField {...dynamicProps} />);
99
+
100
+ // Open dialog
101
+ await act(async () => {
102
+ fireEvent.click(screen.getByRole('button', { name: /Select/i }));
103
+ });
104
+
105
+ await waitFor(() => {
106
+ expect(mockDataSource.find).toHaveBeenCalledWith('customers', {
107
+ $top: 50,
108
+ });
109
+ });
110
+
111
+ await waitFor(() => {
112
+ expect(screen.getByText('Acme Corp')).toBeInTheDocument();
113
+ expect(screen.getByText('Beta Inc')).toBeInTheDocument();
114
+ });
115
+ });
116
+
117
+ it('shows loading state while fetching', async () => {
118
+ // Make find never resolve during this test
119
+ mockDataSource.find.mockReturnValue(new Promise(() => {}));
120
+
121
+ render(<LookupField {...dynamicProps} />);
122
+
123
+ await act(async () => {
124
+ fireEvent.click(screen.getByRole('button', { name: /Select/i }));
125
+ });
126
+
127
+ await waitFor(() => {
128
+ expect(screen.getByRole('status')).toBeInTheDocument();
129
+ expect(screen.getByText('Loading…')).toBeInTheDocument();
130
+ });
131
+ });
132
+
133
+ it('shows error state with retry on fetch failure', async () => {
134
+ mockDataSource.find.mockRejectedValue(new Error('Network error'));
135
+
136
+ render(<LookupField {...dynamicProps} />);
137
+
138
+ await act(async () => {
139
+ fireEvent.click(screen.getByRole('button', { name: /Select/i }));
140
+ });
141
+
142
+ await waitFor(() => {
143
+ expect(screen.getByRole('alert')).toBeInTheDocument();
144
+ expect(screen.getByText('Network error')).toBeInTheDocument();
145
+ expect(screen.getByText('Retry')).toBeInTheDocument();
146
+ });
147
+
148
+ // Click retry
149
+ mockDataSource.find.mockResolvedValue({
150
+ data: [{ id: '1', name: 'Acme Corp' }],
151
+ total: 1,
152
+ });
153
+
154
+ await act(async () => {
155
+ fireEvent.click(screen.getByText('Retry'));
156
+ });
157
+
158
+ await waitFor(() => {
159
+ expect(screen.getByText('Acme Corp')).toBeInTheDocument();
160
+ });
161
+ });
162
+
163
+ it('shows "No options found" when DataSource returns empty', async () => {
164
+ mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
165
+
166
+ render(<LookupField {...dynamicProps} />);
167
+
168
+ await act(async () => {
169
+ fireEvent.click(screen.getByRole('button', { name: /Select/i }));
170
+ });
171
+
172
+ await waitFor(() => {
173
+ expect(screen.getByText('No options found')).toBeInTheDocument();
174
+ });
175
+ });
176
+
177
+ it('sends $search param on search input', async () => {
178
+ mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
179
+
180
+ render(<LookupField {...dynamicProps} />);
181
+
182
+ await act(async () => {
183
+ fireEvent.click(screen.getByRole('button', { name: /Select/i }));
184
+ });
185
+
186
+ // Wait for initial load
187
+ await waitFor(() => {
188
+ expect(mockDataSource.find).toHaveBeenCalledTimes(1);
189
+ });
190
+
191
+ // Type in search
192
+ await act(async () => {
193
+ fireEvent.change(screen.getByPlaceholderText('Search...'), {
194
+ target: { value: 'acme' },
195
+ });
196
+ });
197
+
198
+ // Wait for debounced search
199
+ await waitFor(() => {
200
+ expect(mockDataSource.find).toHaveBeenCalledWith('customers', {
201
+ $top: 50,
202
+ $search: 'acme',
203
+ });
204
+ }, { timeout: 500 });
205
+ });
206
+
207
+ it('selects a dynamically loaded option', async () => {
208
+ const onChange = vi.fn();
209
+ mockDataSource.find.mockResolvedValue({
210
+ data: [
211
+ { id: '1', name: 'Acme Corp' },
212
+ { id: '2', name: 'Beta Inc' },
213
+ ],
214
+ total: 2,
215
+ });
216
+
217
+ render(<LookupField {...dynamicProps} onChange={onChange} />);
218
+
219
+ await act(async () => {
220
+ fireEvent.click(screen.getByRole('button', { name: /Select/i }));
221
+ });
222
+
223
+ await waitFor(() => {
224
+ expect(screen.getByText('Acme Corp')).toBeInTheDocument();
225
+ });
226
+
227
+ await act(async () => {
228
+ fireEvent.click(screen.getByText('Acme Corp'));
229
+ });
230
+
231
+ expect(onChange).toHaveBeenCalledWith('1');
232
+ });
233
+
234
+ it('falls back to static options when no DataSource', () => {
235
+ const staticField = {
236
+ ...mockField,
237
+ options: [
238
+ { value: 's1', label: 'Static 1' },
239
+ { value: 's2', label: 'Static 2' },
240
+ ],
241
+ } as any;
242
+ render(<LookupField {...baseProps} field={staticField} readonly value="s1" />);
243
+ expect(screen.getByText('Static 1')).toBeInTheDocument();
244
+ });
245
+
246
+ it('shows total count hint when more results available', async () => {
247
+ mockDataSource.find.mockResolvedValue({
248
+ data: Array.from({ length: 50 }, (_, i) => ({
249
+ id: String(i),
250
+ name: `Record ${i}`,
251
+ })),
252
+ total: 200,
253
+ });
254
+
255
+ render(<LookupField {...dynamicProps} />);
256
+
257
+ await act(async () => {
258
+ fireEvent.click(screen.getByRole('button', { name: /Select/i }));
259
+ });
260
+
261
+ await waitFor(() => {
262
+ expect(screen.getByText(/Showing 50 of 200/)).toBeInTheDocument();
263
+ });
264
+ });
265
+
266
+ it('displays description field for options', async () => {
267
+ mockDataSource.find.mockResolvedValue({
268
+ data: [
269
+ { id: '1', name: 'Acme Corp', industry: 'Technology' },
270
+ { id: '2', name: 'Beta Inc', industry: 'Finance' },
271
+ ],
272
+ total: 2,
273
+ });
274
+
275
+ const fieldWithDesc = {
276
+ ...dynamicField,
277
+ description_field: 'industry',
278
+ } as any;
279
+
280
+ render(<LookupField {...dynamicProps} field={fieldWithDesc} />);
281
+
282
+ await act(async () => {
283
+ fireEvent.click(screen.getByRole('button', { name: /Select/i }));
284
+ });
285
+
286
+ await waitFor(() => {
287
+ expect(screen.getByText('Acme Corp')).toBeInTheDocument();
288
+ expect(screen.getByText('Technology')).toBeInTheDocument();
289
+ expect(screen.getByText('Finance')).toBeInTheDocument();
290
+ });
291
+ });
292
+
293
+ it('shows create-new button when no results and onCreateNew is provided', async () => {
294
+ mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
295
+ const onCreateNew = vi.fn();
296
+
297
+ render(<LookupField {...dynamicProps} onCreateNew={onCreateNew} />);
298
+
299
+ await act(async () => {
300
+ fireEvent.click(screen.getByRole('button', { name: /Select/i }));
301
+ });
302
+
303
+ await waitFor(() => {
304
+ expect(screen.getByText('No options found')).toBeInTheDocument();
305
+ expect(screen.getByText('Create new')).toBeInTheDocument();
306
+ });
307
+
308
+ await act(async () => {
309
+ fireEvent.click(screen.getByText('Create new'));
310
+ });
311
+
312
+ expect(onCreateNew).toHaveBeenCalledWith('');
313
+ });
314
+
315
+ it('navigates options with arrow keys and selects with Enter', async () => {
316
+ const onChange = vi.fn();
317
+ mockDataSource.find.mockResolvedValue({
318
+ data: [
319
+ { id: '1', name: 'Alpha' },
320
+ { id: '2', name: 'Beta' },
321
+ { id: '3', name: 'Gamma' },
322
+ ],
323
+ total: 3,
324
+ });
325
+
326
+ render(<LookupField {...dynamicProps} onChange={onChange} />);
327
+
328
+ await act(async () => {
329
+ fireEvent.click(screen.getByRole('button', { name: /Select/i }));
330
+ });
331
+
332
+ await waitFor(() => {
333
+ expect(screen.getByText('Alpha')).toBeInTheDocument();
334
+ });
335
+
336
+ const searchInput = screen.getByPlaceholderText('Search...');
337
+
338
+ // Arrow down twice: -1 → 0 (Alpha) → 1 (Beta)
339
+ await act(async () => {
340
+ fireEvent.keyDown(searchInput, { key: 'ArrowDown' });
341
+ });
342
+ await act(async () => {
343
+ fireEvent.keyDown(searchInput, { key: 'ArrowDown' });
344
+ });
345
+
346
+ // Press Enter to select
347
+ await act(async () => {
348
+ fireEvent.keyDown(searchInput, { key: 'Enter' });
349
+ });
350
+
351
+ expect(onChange).toHaveBeenCalledWith('2');
352
+ });
353
+
354
+ it('resolves reference_to from nested field.field (createFieldRenderer wrapper)', async () => {
355
+ // Simulates how createFieldRenderer wraps the field: the real metadata
356
+ // (reference_to, reference_field, etc.) is nested inside field.field.
357
+ const onChange = vi.fn();
358
+ mockDataSource.find.mockResolvedValue({
359
+ data: [
360
+ { id: 'o1', name: 'Order 001' },
361
+ { id: 'o2', name: 'Order 002' },
362
+ ],
363
+ total: 2,
364
+ });
365
+
366
+ const wrappedField = {
367
+ name: 'order',
368
+ label: 'Order',
369
+ // In the wrapper, the actual objectSchema metadata is nested
370
+ field: {
371
+ name: 'order',
372
+ type: 'lookup',
373
+ reference_to: 'orders',
374
+ reference_field: 'name',
375
+ },
376
+ // dataSource lands at the wrapper level
377
+ dataSource: mockDataSource,
378
+ } as any;
379
+
380
+ render(
381
+ <LookupField
382
+ value={null}
383
+ onChange={onChange}
384
+ field={wrappedField}
385
+ readonly={false}
386
+ />
387
+ );
388
+
389
+ await act(async () => {
390
+ fireEvent.click(screen.getByRole('button', { name: /Select/i }));
391
+ });
392
+
393
+ await waitFor(() => {
394
+ expect(mockDataSource.find).toHaveBeenCalledWith('orders', { $top: 50 });
395
+ });
396
+
397
+ await waitFor(() => {
398
+ expect(screen.getByText('Order 001')).toBeInTheDocument();
399
+ expect(screen.getByText('Order 002')).toBeInTheDocument();
400
+ });
401
+ });
402
+
403
+ it('supports ObjectStack "reference" convention (not just "reference_to")', async () => {
404
+ // ObjectStack backend uses `reference` instead of `reference_to`
405
+ const onChange = vi.fn();
406
+ mockDataSource.find.mockResolvedValue({
407
+ data: [
408
+ { id: 'a1', name: 'Acme Corp' },
409
+ { id: 'a2', name: 'Beta Inc' },
410
+ ],
411
+ total: 2,
412
+ });
413
+
414
+ const wrappedField = {
415
+ name: 'account',
416
+ label: 'Account',
417
+ field: {
418
+ name: 'account',
419
+ type: 'lookup',
420
+ reference: 'account', // ObjectStack convention
421
+ },
422
+ dataSource: mockDataSource,
423
+ } as any;
424
+
425
+ render(
426
+ <LookupField
427
+ value={null}
428
+ onChange={onChange}
429
+ field={wrappedField}
430
+ readonly={false}
431
+ />
432
+ );
433
+
434
+ await act(async () => {
435
+ fireEvent.click(screen.getByRole('button', { name: /Select/i }));
436
+ });
437
+
438
+ await waitFor(() => {
439
+ expect(mockDataSource.find).toHaveBeenCalledWith('account', { $top: 50 });
440
+ });
441
+
442
+ await waitFor(() => {
443
+ expect(screen.getByText('Acme Corp')).toBeInTheDocument();
444
+ expect(screen.getByText('Beta Inc')).toBeInTheDocument();
445
+ });
446
+ });
447
+
448
+ it('supports flat "reference" field without wrapper nesting', async () => {
449
+ // When field metadata is flat (no field.field nesting)
450
+ const onChange = vi.fn();
451
+ mockDataSource.find.mockResolvedValue({
452
+ data: [
453
+ { id: 'p1', name: 'Product A' },
454
+ ],
455
+ total: 1,
456
+ });
457
+
458
+ render(
459
+ <LookupField
460
+ value={null}
461
+ onChange={onChange}
462
+ field={{
463
+ name: 'product',
464
+ label: 'Product',
465
+ type: 'lookup',
466
+ reference: 'products', // ObjectStack convention, flat field
467
+ } as any}
468
+ readonly={false}
469
+ dataSource={mockDataSource}
470
+ />
471
+ );
472
+
473
+ await act(async () => {
474
+ fireEvent.click(screen.getByRole('button', { name: /Select/i }));
475
+ });
476
+
477
+ await waitFor(() => {
478
+ expect(mockDataSource.find).toHaveBeenCalledWith('products', { $top: 50 });
479
+ });
480
+
481
+ await waitFor(() => {
482
+ expect(screen.getByText('Product A')).toBeInTheDocument();
483
+ });
484
+ });
485
+ });
486
+
63
487
  describe('MasterDetailField', () => {
64
488
  const items = [
65
489
  { id: '1', label: 'Item 1' },
package/src/index.tsx CHANGED
@@ -60,6 +60,39 @@ export interface CellRendererProps {
60
60
  onChange?: (value: any) => void;
61
61
  }
62
62
 
63
+ /**
64
+ * Coerce a value to a safe primitive for rendering.
65
+ * Handles MongoDB wrapper types ($numberDecimal, $oid, $date), expanded
66
+ * reference objects, and arrays so that no raw object is ever passed as
67
+ * a React child — preventing React error #310.
68
+ */
69
+ export function coerceToSafeValue(value: unknown): string | number | boolean | null | undefined {
70
+ if (value == null) return value as null | undefined;
71
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value;
72
+ if (value instanceof Date) return value.toISOString();
73
+ if (Array.isArray(value)) {
74
+ return value.map((v) => {
75
+ if (v != null && typeof v === 'object') {
76
+ const obj = v as Record<string, unknown>;
77
+ return String(obj.name || obj.label || obj.id || obj._id || '[Object]');
78
+ }
79
+ return String(v);
80
+ }).join(', ');
81
+ }
82
+ if (typeof value === 'object') {
83
+ const obj = value as Record<string, unknown>;
84
+ // MongoDB numeric wrapper: { $numberDecimal: "250000" }
85
+ if ('$numberDecimal' in obj) return Number(obj.$numberDecimal);
86
+ // MongoDB ObjectId wrapper: { $oid: "abc123" }
87
+ if ('$oid' in obj) return String(obj.$oid);
88
+ // MongoDB date wrapper: { $date: "2024-01-01T00:00:00Z" }
89
+ if ('$date' in obj) return String(obj.$date);
90
+ // Expanded reference / general object: extract name/label/id
91
+ return String(obj.name || obj.label || obj.id || obj._id || '[Object]');
92
+ }
93
+ return String(value);
94
+ }
95
+
63
96
  /**
64
97
  * Format currency value
65
98
  */
@@ -192,7 +225,8 @@ export function formatDateTime(value: string | Date): string {
192
225
  * Text field cell renderer
193
226
  */
194
227
  export function TextCellRenderer({ value }: CellRendererProps): React.ReactElement {
195
- return <span className="truncate">{(value != null && value !== '') ? String(value) : '-'}</span>;
228
+ const safe = coerceToSafeValue(value);
229
+ return <span className="truncate">{(safe != null && safe !== '') ? String(safe) : '-'}</span>;
196
230
  }
197
231
 
198
232
  /**
@@ -201,11 +235,13 @@ export function TextCellRenderer({ value }: CellRendererProps): React.ReactEleme
201
235
  export function NumberCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
202
236
  if (value == null) return <span className="text-muted-foreground">-</span>;
203
237
 
238
+ const safe = coerceToSafeValue(value);
204
239
  const numField = field as any;
205
240
  const precision = numField.precision ?? 0;
206
- const formatted = typeof value === 'number'
207
- ? new Intl.NumberFormat('en-US', { minimumFractionDigits: precision, maximumFractionDigits: precision }).format(value)
208
- : value;
241
+ const num = Number(safe);
242
+ const formatted = !isNaN(num)
243
+ ? new Intl.NumberFormat('en-US', { minimumFractionDigits: precision, maximumFractionDigits: precision }).format(num)
244
+ : String(safe);
209
245
 
210
246
  return <span className="tabular-nums">{formatted}</span>;
211
247
  }
@@ -216,9 +252,11 @@ export function NumberCellRenderer({ value, field }: CellRendererProps): React.R
216
252
  export function CurrencyCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
217
253
  if (value == null) return <span className="text-muted-foreground">-</span>;
218
254
 
255
+ const safe = coerceToSafeValue(value);
219
256
  const currencyField = field as any;
220
257
  const currency = currencyField.currency || 'USD';
221
- const formatted = formatCurrency(Number(value), currency);
258
+ const num = Number(safe);
259
+ const formatted = !isNaN(num) ? formatCurrency(num, currency) : String(safe);
222
260
 
223
261
  return <span className="tabular-nums font-medium whitespace-nowrap">{formatted}</span>;
224
262
  }
@@ -232,9 +270,13 @@ const WHOLE_PERCENT_FIELD_PATTERN = /progress|completion/;
232
270
  export function PercentCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
233
271
  if (value == null) return <span className="text-muted-foreground">-</span>;
234
272
 
273
+ const safe = coerceToSafeValue(value);
235
274
  const percentField = field as any;
236
275
  const precision = percentField.precision ?? 0;
237
- const numValue = Number(value);
276
+ const numValue = Number(safe);
277
+ if (isNaN(numValue)) {
278
+ return <span className="tabular-nums whitespace-nowrap">{String(safe)}</span>;
279
+ }
238
280
  // Use field name to disambiguate 0-1 fraction vs 0-100 whole number:
239
281
  // Fields like "progress" or "completion" store values as 0-100, not 0-1
240
282
  const isWholePercentField = WHOLE_PERCENT_FIELD_PATTERN.test(field?.name?.toLowerCase() || '');
@@ -319,17 +361,18 @@ export function BooleanCellRenderer({ value, field }: CellRendererProps): React.
319
361
  */
320
362
  export function DateCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
321
363
  if (!value) return <span className="text-muted-foreground">-</span>;
364
+ const safe = coerceToSafeValue(value);
322
365
  const dateField = field as any;
323
366
  const style = dateField.format || 'relative';
324
- const formatted = formatDate(value, style);
367
+ const formatted = formatDate(safe as string | Date, style);
325
368
 
326
369
  // Determine if date is overdue (in the past)
327
- const date = typeof value === 'string' ? new Date(value) : value;
370
+ const date = typeof safe === 'string' ? new Date(safe) : safe;
328
371
  const isValidDate = date instanceof Date && !isNaN(date.getTime());
329
372
  const startOfToday = new Date();
330
373
  startOfToday.setHours(0, 0, 0, 0);
331
374
  const isOverdue = isValidDate && date < startOfToday;
332
- const isoString = isValidDate ? date.toISOString() : String(value);
375
+ const isoString = isValidDate ? date.toISOString() : String(safe);
333
376
 
334
377
  return (
335
378
  <span
@@ -346,8 +389,9 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
346
389
  */
347
390
  export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactElement {
348
391
  if (!value) return <span className="text-muted-foreground">-</span>;
349
- const date = typeof value === 'string' ? new Date(value) : value;
350
- if (isNaN(date.getTime())) return <span className="text-muted-foreground">-</span>;
392
+ const safe = coerceToSafeValue(value);
393
+ const date = typeof safe === 'string' ? new Date(safe) : safe;
394
+ if (!(date instanceof Date) || isNaN(date.getTime())) return <span className="text-muted-foreground">-</span>;
351
395
 
352
396
  const datePart = date.toLocaleDateString(undefined, {
353
397
  month: 'numeric',
@@ -478,12 +522,13 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
478
522
  export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElement {
479
523
  if (!value) return <span>-</span>;
480
524
 
525
+ const safe = String(coerceToSafeValue(value) ?? '');
481
526
  const [copied, setCopied] = React.useState(false);
482
527
 
483
528
  const handleCopy = (e: React.MouseEvent) => {
484
529
  e.stopPropagation();
485
530
  e.preventDefault();
486
- navigator.clipboard.writeText(String(value)).then(() => {
531
+ navigator.clipboard.writeText(safe).then(() => {
487
532
  setCopied(true);
488
533
  setTimeout(() => setCopied(false), 2000);
489
534
  }).catch(() => { /* clipboard not available */ });
@@ -497,10 +542,10 @@ export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElem
497
542
  asChild
498
543
  >
499
544
  <a
500
- href={`mailto:${value}`}
545
+ href={`mailto:${safe}`}
501
546
  onClick={(e) => e.stopPropagation()}
502
547
  >
503
- {value}
548
+ {safe}
504
549
  </a>
505
550
  </Button>
506
551
  <button
@@ -525,6 +570,7 @@ export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElem
525
570
  export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElement {
526
571
  if (!value) return <span>-</span>;
527
572
 
573
+ const safe = String(coerceToSafeValue(value) ?? '');
528
574
  return (
529
575
  <Button
530
576
  variant="link"
@@ -532,12 +578,12 @@ export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElemen
532
578
  asChild
533
579
  >
534
580
  <a
535
- href={value}
581
+ href={safe}
536
582
  target="_blank"
537
583
  rel="noopener noreferrer"
538
584
  onClick={(e) => e.stopPropagation()}
539
585
  >
540
- {value}
586
+ {safe}
541
587
  </a>
542
588
  </Button>
543
589
  );
@@ -549,12 +595,13 @@ export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElemen
549
595
  export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElement {
550
596
  if (!value) return <span>-</span>;
551
597
 
598
+ const safe = String(coerceToSafeValue(value) ?? '');
552
599
  const [copied, setCopied] = React.useState(false);
553
600
 
554
601
  const handleCopy = (e: React.MouseEvent) => {
555
602
  e.stopPropagation();
556
603
  e.preventDefault();
557
- navigator.clipboard.writeText(String(value)).then(() => {
604
+ navigator.clipboard.writeText(safe).then(() => {
558
605
  setCopied(true);
559
606
  setTimeout(() => setCopied(false), 2000);
560
607
  }).catch(() => { /* clipboard not available */ });
@@ -563,12 +610,12 @@ export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElem
563
610
  return (
564
611
  <span className="inline-flex items-center gap-1 group/phone">
565
612
  <a
566
- href={`tel:${value}`}
613
+ href={`tel:${safe}`}
567
614
  className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800"
568
615
  onClick={(e) => e.stopPropagation()}
569
616
  >
570
617
  <PhoneIcon className="h-3 w-3" />
571
- {value}
618
+ {safe}
572
619
  </a>
573
620
  <button
574
621
  type="button"
@@ -667,7 +714,7 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
667
714
  {value.map((item, idx) => {
668
715
  let label: string;
669
716
  if (item != null && typeof item === 'object') {
670
- label = item.name || item.label || item._id || String(item);
717
+ label = item.name || item.label || item.id || item._id || String(item);
671
718
  } else {
672
719
  label = resolveLabel(item);
673
720
  }
@@ -685,7 +732,7 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
685
732
  }
686
733
 
687
734
  if (typeof value === 'object' && value !== null) {
688
- const label = value.name || value.label || value._id || '[Object]';
735
+ const label = value.name || value.label || value.id || value._id || '[Object]';
689
736
  return <span className="truncate">{label}</span>;
690
737
  }
691
738
 
@@ -697,9 +744,10 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
697
744
  * Formula field cell renderer (read-only)
698
745
  */
699
746
  export function FormulaCellRenderer({ value }: CellRendererProps): React.ReactElement {
747
+ const safe = coerceToSafeValue(value);
700
748
  return (
701
749
  <span className="text-gray-700 font-mono text-sm">
702
- {value != null ? String(value) : '-'}
750
+ {safe != null ? String(safe) : '-'}
703
751
  </span>
704
752
  );
705
753
  }
@@ -840,6 +888,11 @@ registerFieldRenderer('status', SelectCellRenderer);
840
888
  registerFieldRenderer('user', UserCellRenderer);
841
889
  registerFieldRenderer('owner', UserCellRenderer);
842
890
 
891
+ // Register getCellRenderer in the bridge so RecordPickerDialog can access it
892
+ // via LookupField without circular imports.
893
+ import { setCellRendererResolver } from './widgets/_cell-renderer-bridge';
894
+ setCellRendererResolver(getCellRenderer);
895
+
843
896
 
844
897
 
845
898
  /**
@@ -1279,6 +1332,7 @@ export * from './widgets/PasswordField';
1279
1332
  export * from './widgets/TextAreaField';
1280
1333
  export * from './widgets/RichTextField';
1281
1334
  export * from './widgets/LookupField';
1335
+ export * from './widgets/RecordPickerDialog';
1282
1336
  export * from './widgets/FileField';
1283
1337
  export * from './widgets/ImageField';
1284
1338
  export * from './widgets/LocationField';