@object-ui/fields 3.1.1 → 3.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/fields",
3
- "version": "3.1.1",
3
+ "version": "3.1.3",
4
4
  "description": "Field renderers and registry for Object UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,11 +23,11 @@
23
23
  ],
24
24
  "dependencies": {
25
25
  "clsx": "^2.1.1",
26
- "lucide-react": "^0.576.0",
26
+ "lucide-react": "^0.577.0",
27
27
  "tailwind-merge": "^3.5.0",
28
- "@object-ui/components": "3.1.1",
29
- "@object-ui/core": "3.1.1",
30
- "@object-ui/types": "3.1.1"
28
+ "@object-ui/components": "3.1.3",
29
+ "@object-ui/core": "3.1.3",
30
+ "@object-ui/types": "3.1.3"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "react": "^18.0.0 || ^19.0.0",
@@ -788,7 +788,7 @@ describe('LookupCellRenderer', () => {
788
788
  it('should render object name when value is an object', () => {
789
789
  render(
790
790
  <LookupCellRenderer
791
- value={{ name: 'Acme Corp', _id: '123' }}
791
+ value={{ name: 'Acme Corp', id: '123' }}
792
792
  field={{ name: 'account', type: 'lookup' } as any}
793
793
  />
794
794
  );
@@ -798,17 +798,17 @@ describe('LookupCellRenderer', () => {
798
798
  it('should render object label when name is missing', () => {
799
799
  render(
800
800
  <LookupCellRenderer
801
- value={{ label: 'Widget Co', _id: '456' }}
801
+ value={{ label: 'Widget Co', id: '456' }}
802
802
  field={{ name: 'account', type: 'lookup' } as any}
803
803
  />
804
804
  );
805
805
  expect(screen.getByText('Widget Co')).toBeInTheDocument();
806
806
  });
807
807
 
808
- it('should render object _id as fallback', () => {
808
+ it('should render object id as fallback', () => {
809
809
  render(
810
810
  <LookupCellRenderer
811
- value={{ _id: '789' }}
811
+ value={{ id: '789' }}
812
812
  field={{ name: 'account', type: 'lookup' } as any}
813
813
  />
814
814
  );
@@ -1041,3 +1041,132 @@ describe('PercentCellRenderer progress-type fields', () => {
1041
1041
  expect(bar).toHaveAttribute('aria-valuenow', '50');
1042
1042
  });
1043
1043
  });
1044
+
1045
+ // =========================================================================
1046
+ // Object value safety (coerceToSafeValue) — prevents React error #310
1047
+ // =========================================================================
1048
+ import {
1049
+ NumberCellRenderer,
1050
+ CurrencyCellRenderer,
1051
+ FormulaCellRenderer,
1052
+ coerceToSafeValue,
1053
+ } from '../index';
1054
+
1055
+ describe('coerceToSafeValue', () => {
1056
+ it('should pass through primitives unchanged', () => {
1057
+ expect(coerceToSafeValue('hello')).toBe('hello');
1058
+ expect(coerceToSafeValue(42)).toBe(42);
1059
+ expect(coerceToSafeValue(true)).toBe(true);
1060
+ expect(coerceToSafeValue(null)).toBe(null);
1061
+ expect(coerceToSafeValue(undefined)).toBe(undefined);
1062
+ });
1063
+
1064
+ it('should extract number from MongoDB $numberDecimal', () => {
1065
+ expect(coerceToSafeValue({ $numberDecimal: '250000' })).toBe(250000);
1066
+ });
1067
+
1068
+ it('should extract string from MongoDB $oid', () => {
1069
+ expect(coerceToSafeValue({ $oid: 'abc123' })).toBe('abc123');
1070
+ });
1071
+
1072
+ it('should extract string from MongoDB $date', () => {
1073
+ expect(coerceToSafeValue({ $date: '2024-01-01T00:00:00Z' })).toBe('2024-01-01T00:00:00Z');
1074
+ });
1075
+
1076
+ it('should extract name from expanded reference object', () => {
1077
+ expect(coerceToSafeValue({ id: 'x', name: 'Acme Corp' })).toBe('Acme Corp');
1078
+ });
1079
+
1080
+ it('should extract label when name is not present', () => {
1081
+ expect(coerceToSafeValue({ id: 'x', label: 'Active' })).toBe('Active');
1082
+ });
1083
+
1084
+ it('should fall back to id when no name/label', () => {
1085
+ expect(coerceToSafeValue({ id: 'abc123' })).toBe('abc123');
1086
+ });
1087
+
1088
+ it('should handle arrays of primitives', () => {
1089
+ expect(coerceToSafeValue(['a', 'b', 'c'])).toBe('a, b, c');
1090
+ });
1091
+
1092
+ it('should handle arrays of objects', () => {
1093
+ expect(coerceToSafeValue([{ name: 'Alice' }, { name: 'Bob' }])).toBe('Alice, Bob');
1094
+ });
1095
+
1096
+ it('should convert Date to ISO string', () => {
1097
+ const d = new Date('2024-06-15T12:00:00Z');
1098
+ expect(coerceToSafeValue(d)).toBe('2024-06-15T12:00:00.000Z');
1099
+ });
1100
+ });
1101
+
1102
+ describe('NumberCellRenderer object safety', () => {
1103
+ it('should handle MongoDB $numberDecimal without crashing', () => {
1104
+ const { container } = render(
1105
+ <NumberCellRenderer
1106
+ value={{ $numberDecimal: '250000' }}
1107
+ field={{ name: 'amount', type: 'number' } as any}
1108
+ />
1109
+ );
1110
+ expect(container.innerHTML).not.toBe('');
1111
+ expect(screen.getByText('250,000')).toBeInTheDocument();
1112
+ });
1113
+
1114
+ it('should handle expanded reference object without crashing', () => {
1115
+ const { container } = render(
1116
+ <NumberCellRenderer
1117
+ value={{ id: 'abc', name: 'Not a number' }}
1118
+ field={{ name: 'amount', type: 'number' } as any}
1119
+ />
1120
+ );
1121
+ expect(container.innerHTML).not.toBe('');
1122
+ // Should render the extracted name string, not crash
1123
+ expect(screen.getByText('Not a number')).toBeInTheDocument();
1124
+ });
1125
+ });
1126
+
1127
+ describe('CurrencyCellRenderer object safety', () => {
1128
+ it('should handle MongoDB $numberDecimal', () => {
1129
+ const { container } = render(
1130
+ <CurrencyCellRenderer
1131
+ value={{ $numberDecimal: '5000' }}
1132
+ field={{ name: 'price', type: 'currency', currency: 'USD' } as any}
1133
+ />
1134
+ );
1135
+ expect(container.innerHTML).not.toBe('');
1136
+ expect(screen.getByText(/5,000/)).toBeInTheDocument();
1137
+ });
1138
+ });
1139
+
1140
+ describe('TextCellRenderer object safety', () => {
1141
+ it('should extract name from object instead of [object Object]', () => {
1142
+ render(
1143
+ <TextCellRenderer
1144
+ value={{ id: 'abc', name: 'Acme Corp' }}
1145
+ field={{ name: 'company', type: 'text' } as any}
1146
+ />
1147
+ );
1148
+ expect(screen.getByText('Acme Corp')).toBeInTheDocument();
1149
+ });
1150
+
1151
+ it('should handle arrays of objects', () => {
1152
+ render(
1153
+ <TextCellRenderer
1154
+ value={[{ name: 'Alice' }, { name: 'Bob' }]}
1155
+ field={{ name: 'contacts', type: 'text' } as any}
1156
+ />
1157
+ );
1158
+ expect(screen.getByText('Alice, Bob')).toBeInTheDocument();
1159
+ });
1160
+ });
1161
+
1162
+ describe('FormulaCellRenderer object safety', () => {
1163
+ it('should extract value from MongoDB $numberDecimal', () => {
1164
+ render(
1165
+ <FormulaCellRenderer
1166
+ value={{ $numberDecimal: '42.5' }}
1167
+ field={{ name: 'calc', type: 'formula' } as any}
1168
+ />
1169
+ );
1170
+ expect(screen.getByText('42.5')).toBeInTheDocument();
1171
+ });
1172
+ });
@@ -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' },