@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/dist/fields/src/index.d.ts +7 -0
- package/dist/fields/src/widgets/LookupField.d.ts +13 -2
- package/dist/index.cjs +4 -4
- package/dist/index.js +1153 -985
- package/package.json +5 -5
- package/src/__tests__/cell-renderers.test.tsx +133 -4
- package/src/complex-widgets.test.tsx +426 -2
- package/src/index.tsx +70 -22
- package/src/widgets/LookupField.tsx +366 -65
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/fields",
|
|
3
|
-
"version": "3.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.
|
|
26
|
+
"lucide-react": "^0.577.0",
|
|
27
27
|
"tailwind-merge": "^3.5.0",
|
|
28
|
-
"@object-ui/components": "3.1.
|
|
29
|
-
"@object-ui/core": "3.1.
|
|
30
|
-
"@object-ui/types": "3.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',
|
|
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',
|
|
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
|
|
808
|
+
it('should render object id as fallback', () => {
|
|
809
809
|
render(
|
|
810
810
|
<LookupCellRenderer
|
|
811
|
-
value={{
|
|
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' },
|