@object-ui/plugin-list 3.0.3 → 3.1.1
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/.turbo/turbo-build.log +8 -8
- package/CHANGELOG.md +12 -0
- package/dist/index.js +26993 -24232
- package/dist/index.umd.cjs +36 -34
- package/dist/plugin-list.css +1 -1
- package/dist/src/ListView.d.ts +20 -0
- package/dist/src/ListView.d.ts.map +1 -1
- package/dist/src/ObjectGallery.d.ts +7 -1
- package/dist/src/ObjectGallery.d.ts.map +1 -1
- package/dist/src/UserFilters.d.ts +23 -0
- package/dist/src/UserFilters.d.ts.map +1 -0
- package/dist/src/components/TabBar.d.ts +32 -0
- package/dist/src/components/TabBar.d.ts.map +1 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.d.ts.map +1 -1
- package/package.json +9 -8
- package/src/ListView.tsx +1216 -161
- package/src/ObjectGallery.tsx +191 -63
- package/src/UserFilters.tsx +453 -0
- package/src/__tests__/ConditionalFormatting.test.ts +285 -0
- package/src/__tests__/DataFetch.test.tsx +224 -0
- package/src/__tests__/Export.test.tsx +175 -0
- package/src/__tests__/FilterNormalization.test.ts +162 -0
- package/src/__tests__/GalleryGrouping.test.tsx +237 -0
- package/src/__tests__/GalleryTimelineSpecConfig.test.tsx +203 -0
- package/src/__tests__/ListView.test.tsx +1946 -19
- package/src/__tests__/ListViewGroupingPropagation.test.tsx +250 -0
- package/src/__tests__/ObjectGallery.test.tsx +208 -0
- package/src/__tests__/TabBar.test.tsx +199 -0
- package/src/__tests__/UserFilters.test.tsx +486 -0
- package/src/components/TabBar.tsx +120 -0
- package/src/index.tsx +13 -4
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest';
|
|
10
|
+
import { evaluateConditionalFormatting } from '../ListView';
|
|
11
|
+
|
|
12
|
+
describe('evaluateConditionalFormatting', () => {
|
|
13
|
+
// =========================================================================
|
|
14
|
+
// Standard operator-based rules
|
|
15
|
+
// =========================================================================
|
|
16
|
+
describe('operator-based rules', () => {
|
|
17
|
+
it('matches "equals" operator', () => {
|
|
18
|
+
const result = evaluateConditionalFormatting(
|
|
19
|
+
{ status: 'active' },
|
|
20
|
+
[{ field: 'status', operator: 'equals', value: 'active', backgroundColor: '#e0ffe0' }],
|
|
21
|
+
);
|
|
22
|
+
expect(result).toEqual({ backgroundColor: '#e0ffe0' });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('matches "not_equals" operator', () => {
|
|
26
|
+
const result = evaluateConditionalFormatting(
|
|
27
|
+
{ status: 'inactive' },
|
|
28
|
+
[{ field: 'status', operator: 'not_equals', value: 'active', textColor: '#f00' }],
|
|
29
|
+
);
|
|
30
|
+
expect(result).toEqual({ color: '#f00' });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('matches "contains" operator', () => {
|
|
34
|
+
const result = evaluateConditionalFormatting(
|
|
35
|
+
{ name: 'John Doe' },
|
|
36
|
+
[{ field: 'name', operator: 'contains', value: 'Doe', borderColor: '#00f' }],
|
|
37
|
+
);
|
|
38
|
+
expect(result).toEqual({ borderColor: '#00f' });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('matches "greater_than" operator', () => {
|
|
42
|
+
const result = evaluateConditionalFormatting(
|
|
43
|
+
{ amount: 500 },
|
|
44
|
+
[{ field: 'amount', operator: 'greater_than', value: 100, backgroundColor: '#ff0' }],
|
|
45
|
+
);
|
|
46
|
+
expect(result).toEqual({ backgroundColor: '#ff0' });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('does not match "greater_than" when equal', () => {
|
|
50
|
+
const result = evaluateConditionalFormatting(
|
|
51
|
+
{ amount: 100 },
|
|
52
|
+
[{ field: 'amount', operator: 'greater_than', value: 100, backgroundColor: '#ff0' }],
|
|
53
|
+
);
|
|
54
|
+
expect(result).toEqual({});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('matches "less_than" operator', () => {
|
|
58
|
+
const result = evaluateConditionalFormatting(
|
|
59
|
+
{ score: 3 },
|
|
60
|
+
[{ field: 'score', operator: 'less_than', value: 5, textColor: '#aaa' }],
|
|
61
|
+
);
|
|
62
|
+
expect(result).toEqual({ color: '#aaa' });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('matches "in" operator', () => {
|
|
66
|
+
const result = evaluateConditionalFormatting(
|
|
67
|
+
{ priority: 'high' },
|
|
68
|
+
[{ field: 'priority', operator: 'in', value: ['high', 'critical'], backgroundColor: '#fee' }],
|
|
69
|
+
);
|
|
70
|
+
expect(result).toEqual({ backgroundColor: '#fee' });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('does not match "in" when value is absent from array', () => {
|
|
74
|
+
const result = evaluateConditionalFormatting(
|
|
75
|
+
{ priority: 'low' },
|
|
76
|
+
[{ field: 'priority', operator: 'in', value: ['high', 'critical'], backgroundColor: '#fee' }],
|
|
77
|
+
);
|
|
78
|
+
expect(result).toEqual({});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// =========================================================================
|
|
83
|
+
// Expression-based rules (L2 feature)
|
|
84
|
+
// =========================================================================
|
|
85
|
+
describe('expression-based rules', () => {
|
|
86
|
+
it('evaluates a simple expression', () => {
|
|
87
|
+
const result = evaluateConditionalFormatting(
|
|
88
|
+
{ amount: 2000, status: 'urgent' },
|
|
89
|
+
[{
|
|
90
|
+
field: '',
|
|
91
|
+
operator: 'equals',
|
|
92
|
+
value: '',
|
|
93
|
+
expression: '${data.amount > 1000}',
|
|
94
|
+
backgroundColor: '#f0f0f0',
|
|
95
|
+
}],
|
|
96
|
+
);
|
|
97
|
+
expect(result).toEqual({ backgroundColor: '#f0f0f0' });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('evaluates a complex expression with && operator', () => {
|
|
101
|
+
const result = evaluateConditionalFormatting(
|
|
102
|
+
{ amount: 2000, status: 'urgent' },
|
|
103
|
+
[{
|
|
104
|
+
field: '',
|
|
105
|
+
operator: 'equals',
|
|
106
|
+
value: '',
|
|
107
|
+
expression: '${data.amount > 1000 && data.status === "urgent"}',
|
|
108
|
+
backgroundColor: '#fee2e2',
|
|
109
|
+
textColor: '#dc2626',
|
|
110
|
+
}],
|
|
111
|
+
);
|
|
112
|
+
expect(result).toEqual({ backgroundColor: '#fee2e2', color: '#dc2626' });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('returns empty object when expression evaluates to false', () => {
|
|
116
|
+
const result = evaluateConditionalFormatting(
|
|
117
|
+
{ amount: 50, status: 'normal' },
|
|
118
|
+
[{
|
|
119
|
+
field: '',
|
|
120
|
+
operator: 'equals',
|
|
121
|
+
value: '',
|
|
122
|
+
expression: '${data.amount > 1000 && data.status === "urgent"}',
|
|
123
|
+
backgroundColor: '#fee2e2',
|
|
124
|
+
}],
|
|
125
|
+
);
|
|
126
|
+
expect(result).toEqual({});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('does not throw on invalid expression and returns empty', () => {
|
|
130
|
+
const result = evaluateConditionalFormatting(
|
|
131
|
+
{ amount: 100 },
|
|
132
|
+
[{
|
|
133
|
+
field: '',
|
|
134
|
+
operator: 'equals',
|
|
135
|
+
value: '',
|
|
136
|
+
expression: '${data.!!!invalidSyntax}',
|
|
137
|
+
backgroundColor: '#f00',
|
|
138
|
+
}],
|
|
139
|
+
);
|
|
140
|
+
expect(result).toEqual({});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// =========================================================================
|
|
145
|
+
// Spec expression format (plain condition + style object)
|
|
146
|
+
// =========================================================================
|
|
147
|
+
describe('spec expression format', () => {
|
|
148
|
+
it('evaluates a plain condition string via "condition" field', () => {
|
|
149
|
+
const result = evaluateConditionalFormatting(
|
|
150
|
+
{ status: 'overdue' },
|
|
151
|
+
[{ condition: "status == 'overdue'", style: { backgroundColor: 'red' } }],
|
|
152
|
+
);
|
|
153
|
+
expect(result).toEqual({ backgroundColor: 'red' });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('evaluates a numeric comparison in plain condition', () => {
|
|
157
|
+
const result = evaluateConditionalFormatting(
|
|
158
|
+
{ amount: 2500 },
|
|
159
|
+
[{ condition: 'amount > 1000', style: { backgroundColor: '#fee2e2', color: '#991b1b' } }],
|
|
160
|
+
);
|
|
161
|
+
expect(result).toEqual({ backgroundColor: '#fee2e2', color: '#991b1b' });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('returns empty when plain condition does not match', () => {
|
|
165
|
+
const result = evaluateConditionalFormatting(
|
|
166
|
+
{ status: 'active' },
|
|
167
|
+
[{ condition: "status == 'overdue'", style: { backgroundColor: 'red' } }],
|
|
168
|
+
);
|
|
169
|
+
expect(result).toEqual({});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('supports compound conditions with && operator', () => {
|
|
173
|
+
const result = evaluateConditionalFormatting(
|
|
174
|
+
{ amount: 2000, status: 'urgent' },
|
|
175
|
+
[{ condition: "amount > 1000 && status === 'urgent'", style: { backgroundColor: '#fee' } }],
|
|
176
|
+
);
|
|
177
|
+
expect(result).toEqual({ backgroundColor: '#fee' });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('merges style with individual color properties', () => {
|
|
181
|
+
const result = evaluateConditionalFormatting(
|
|
182
|
+
{ status: 'overdue' },
|
|
183
|
+
[{
|
|
184
|
+
condition: "status == 'overdue'",
|
|
185
|
+
style: { fontWeight: 'bold' },
|
|
186
|
+
backgroundColor: '#f00',
|
|
187
|
+
}],
|
|
188
|
+
);
|
|
189
|
+
expect(result).toEqual({ fontWeight: 'bold', backgroundColor: '#f00' });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('does not throw on invalid plain condition', () => {
|
|
193
|
+
const result = evaluateConditionalFormatting(
|
|
194
|
+
{ status: 'ok' },
|
|
195
|
+
[{ condition: '!!!invalidSyntax', style: { backgroundColor: 'red' } }],
|
|
196
|
+
);
|
|
197
|
+
expect(result).toEqual({});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// =========================================================================
|
|
202
|
+
// Mixed rules (expression + standard) – first match wins
|
|
203
|
+
// =========================================================================
|
|
204
|
+
describe('mixed rules', () => {
|
|
205
|
+
it('returns the first matching rule (expression first)', () => {
|
|
206
|
+
const result = evaluateConditionalFormatting(
|
|
207
|
+
{ amount: 5000, status: 'active' },
|
|
208
|
+
[
|
|
209
|
+
{
|
|
210
|
+
field: '',
|
|
211
|
+
operator: 'equals',
|
|
212
|
+
value: '',
|
|
213
|
+
expression: '${data.amount > 1000}',
|
|
214
|
+
backgroundColor: '#expr_match',
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
field: 'status',
|
|
218
|
+
operator: 'equals',
|
|
219
|
+
value: 'active',
|
|
220
|
+
backgroundColor: '#operator_match',
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
);
|
|
224
|
+
expect(result).toEqual({ backgroundColor: '#expr_match' });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('falls through non-matching expression to matching operator rule', () => {
|
|
228
|
+
const result = evaluateConditionalFormatting(
|
|
229
|
+
{ amount: 50, status: 'active' },
|
|
230
|
+
[
|
|
231
|
+
{
|
|
232
|
+
field: '',
|
|
233
|
+
operator: 'equals',
|
|
234
|
+
value: '',
|
|
235
|
+
expression: '${data.amount > 1000}',
|
|
236
|
+
backgroundColor: '#expr_match',
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
field: 'status',
|
|
240
|
+
operator: 'equals',
|
|
241
|
+
value: 'active',
|
|
242
|
+
backgroundColor: '#operator_match',
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
);
|
|
246
|
+
expect(result).toEqual({ backgroundColor: '#operator_match' });
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('handles mixed spec condition + ObjectUI field rules', () => {
|
|
250
|
+
const result = evaluateConditionalFormatting(
|
|
251
|
+
{ status: 'overdue', priority: 'low' },
|
|
252
|
+
[
|
|
253
|
+
{ condition: "status == 'overdue'", style: { backgroundColor: 'red' } },
|
|
254
|
+
{ field: 'priority', operator: 'equals', value: 'low', backgroundColor: '#ccc' },
|
|
255
|
+
],
|
|
256
|
+
);
|
|
257
|
+
// First matching rule wins
|
|
258
|
+
expect(result).toEqual({ backgroundColor: 'red' });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('falls through non-matching spec condition to ObjectUI field rule', () => {
|
|
262
|
+
const result = evaluateConditionalFormatting(
|
|
263
|
+
{ status: 'active', priority: 'low' },
|
|
264
|
+
[
|
|
265
|
+
{ condition: "status == 'overdue'", style: { backgroundColor: 'red' } },
|
|
266
|
+
{ field: 'priority', operator: 'equals', value: 'low', backgroundColor: '#ccc' },
|
|
267
|
+
],
|
|
268
|
+
);
|
|
269
|
+
expect(result).toEqual({ backgroundColor: '#ccc' });
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// =========================================================================
|
|
274
|
+
// Edge cases
|
|
275
|
+
// =========================================================================
|
|
276
|
+
describe('edge cases', () => {
|
|
277
|
+
it('returns empty object for undefined rules', () => {
|
|
278
|
+
expect(evaluateConditionalFormatting({ a: 1 }, undefined)).toEqual({});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('returns empty object for empty rules array', () => {
|
|
282
|
+
expect(evaluateConditionalFormatting({ a: 1 }, [])).toEqual({});
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
10
|
+
import { render, screen } from '@testing-library/react';
|
|
11
|
+
import { ListView } from '../ListView';
|
|
12
|
+
import type { ListViewSchema } from '@object-ui/types';
|
|
13
|
+
import { SchemaRendererProvider } from '@object-ui/react';
|
|
14
|
+
|
|
15
|
+
const renderWithProvider = (component: React.ReactNode, dataSource?: any) => {
|
|
16
|
+
return render(
|
|
17
|
+
<SchemaRendererProvider dataSource={dataSource || mockDataSource}>
|
|
18
|
+
{component}
|
|
19
|
+
</SchemaRendererProvider>
|
|
20
|
+
);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let mockDataSource: any;
|
|
24
|
+
|
|
25
|
+
describe('ListView Data Fetch', () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
mockDataSource = {
|
|
28
|
+
find: vi.fn().mockResolvedValue([]),
|
|
29
|
+
findOne: vi.fn(),
|
|
30
|
+
create: vi.fn(),
|
|
31
|
+
update: vi.fn(),
|
|
32
|
+
delete: vi.fn(),
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// =========================================================================
|
|
37
|
+
// Data Limit Warning (Issue #7)
|
|
38
|
+
// =========================================================================
|
|
39
|
+
describe('data limit warning', () => {
|
|
40
|
+
it('shows data limit warning when items reach the page size', async () => {
|
|
41
|
+
// Generate exactly 100 items (default page size)
|
|
42
|
+
const items = Array.from({ length: 100 }, (_, i) => ({
|
|
43
|
+
_id: String(i),
|
|
44
|
+
name: `Item ${i}`,
|
|
45
|
+
}));
|
|
46
|
+
mockDataSource.find.mockResolvedValue(items);
|
|
47
|
+
|
|
48
|
+
const schema: ListViewSchema = {
|
|
49
|
+
type: 'list-view',
|
|
50
|
+
objectName: 'contacts',
|
|
51
|
+
viewType: 'grid',
|
|
52
|
+
fields: ['name'],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} />);
|
|
56
|
+
|
|
57
|
+
await vi.waitFor(() => {
|
|
58
|
+
expect(screen.getByTestId('record-count-bar')).toBeInTheDocument();
|
|
59
|
+
});
|
|
60
|
+
expect(screen.getByTestId('data-limit-warning')).toBeInTheDocument();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('does not show data limit warning when items are below page size', async () => {
|
|
64
|
+
const items = [
|
|
65
|
+
{ _id: '1', name: 'Alice' },
|
|
66
|
+
{ _id: '2', name: 'Bob' },
|
|
67
|
+
];
|
|
68
|
+
mockDataSource.find.mockResolvedValue(items);
|
|
69
|
+
|
|
70
|
+
const schema: ListViewSchema = {
|
|
71
|
+
type: 'list-view',
|
|
72
|
+
objectName: 'contacts',
|
|
73
|
+
viewType: 'grid',
|
|
74
|
+
fields: ['name'],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} />);
|
|
78
|
+
|
|
79
|
+
await vi.waitFor(() => {
|
|
80
|
+
expect(screen.getByTestId('record-count-bar')).toBeInTheDocument();
|
|
81
|
+
});
|
|
82
|
+
expect(screen.queryByTestId('data-limit-warning')).not.toBeInTheDocument();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('uses custom page size from schema.pagination', async () => {
|
|
86
|
+
const items = Array.from({ length: 50 }, (_, i) => ({
|
|
87
|
+
_id: String(i),
|
|
88
|
+
name: `Item ${i}`,
|
|
89
|
+
}));
|
|
90
|
+
mockDataSource.find.mockResolvedValue(items);
|
|
91
|
+
|
|
92
|
+
const schema: ListViewSchema = {
|
|
93
|
+
type: 'list-view',
|
|
94
|
+
objectName: 'contacts',
|
|
95
|
+
viewType: 'grid',
|
|
96
|
+
fields: ['name'],
|
|
97
|
+
pagination: { pageSize: 50 },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} />);
|
|
101
|
+
|
|
102
|
+
await vi.waitFor(() => {
|
|
103
|
+
expect(screen.getByTestId('record-count-bar')).toBeInTheDocument();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Should show warning since exactly 50 items = pageSize
|
|
107
|
+
expect(screen.getByTestId('data-limit-warning')).toBeInTheDocument();
|
|
108
|
+
|
|
109
|
+
// Verify that $top was set to custom page size
|
|
110
|
+
expect(mockDataSource.find).toHaveBeenCalledWith('contacts', expect.objectContaining({
|
|
111
|
+
$top: 50,
|
|
112
|
+
}));
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// =========================================================================
|
|
117
|
+
// $expand race condition fix (Issue #939 Bug 1)
|
|
118
|
+
// =========================================================================
|
|
119
|
+
describe('$expand with objectSchema', () => {
|
|
120
|
+
it('should include $expand in find() when objectSchema has lookup fields', async () => {
|
|
121
|
+
mockDataSource.getObjectSchema = vi.fn().mockResolvedValue({
|
|
122
|
+
fields: {
|
|
123
|
+
name: { type: 'text' },
|
|
124
|
+
customer: { type: 'lookup', reference_to: 'contact' },
|
|
125
|
+
account: { type: 'master_detail', reference_to: 'account' },
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
mockDataSource.find.mockResolvedValue([
|
|
129
|
+
{ _id: '1', name: 'Order 1', customer: { name: 'Alice' }, account: { name: 'Acme' } },
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
const schema: ListViewSchema = {
|
|
133
|
+
type: 'list-view',
|
|
134
|
+
objectName: 'orders',
|
|
135
|
+
viewType: 'grid',
|
|
136
|
+
fields: ['name', 'customer', 'account'],
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} />);
|
|
140
|
+
|
|
141
|
+
await vi.waitFor(() => {
|
|
142
|
+
expect(mockDataSource.find).toHaveBeenCalledWith(
|
|
143
|
+
'orders',
|
|
144
|
+
expect.objectContaining({
|
|
145
|
+
$expand: expect.arrayContaining(['customer', 'account']),
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should wait for objectSchema before fetching data', async () => {
|
|
152
|
+
let resolveSchema: (value: any) => void;
|
|
153
|
+
const schemaPromise = new Promise(resolve => { resolveSchema = resolve; });
|
|
154
|
+
mockDataSource.getObjectSchema = vi.fn().mockReturnValue(schemaPromise);
|
|
155
|
+
mockDataSource.find.mockResolvedValue([{ _id: '1', name: 'Item 1' }]);
|
|
156
|
+
|
|
157
|
+
const schema: ListViewSchema = {
|
|
158
|
+
type: 'list-view',
|
|
159
|
+
objectName: 'orders',
|
|
160
|
+
viewType: 'grid',
|
|
161
|
+
fields: ['name'],
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} />);
|
|
165
|
+
|
|
166
|
+
// find() should NOT be called yet because objectSchema hasn't resolved
|
|
167
|
+
expect(mockDataSource.find).not.toHaveBeenCalled();
|
|
168
|
+
|
|
169
|
+
// Resolve objectSchema
|
|
170
|
+
resolveSchema!({ fields: { name: { type: 'text' } } });
|
|
171
|
+
|
|
172
|
+
// Now find() should be called
|
|
173
|
+
await vi.waitFor(() => {
|
|
174
|
+
expect(mockDataSource.find).toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// =========================================================================
|
|
180
|
+
// Request Debounce (Issue #5)
|
|
181
|
+
// =========================================================================
|
|
182
|
+
describe('request debounce', () => {
|
|
183
|
+
it('only uses data from the latest request when multiple fetches occur', async () => {
|
|
184
|
+
let resolveFirst: (value: any) => void;
|
|
185
|
+
let resolveSecond: (value: any) => void;
|
|
186
|
+
|
|
187
|
+
const firstPromise = new Promise(resolve => { resolveFirst = resolve; });
|
|
188
|
+
const secondPromise = new Promise(resolve => { resolveSecond = resolve; });
|
|
189
|
+
|
|
190
|
+
mockDataSource.find
|
|
191
|
+
.mockReturnValueOnce(firstPromise)
|
|
192
|
+
.mockReturnValueOnce(secondPromise);
|
|
193
|
+
|
|
194
|
+
const schema: ListViewSchema = {
|
|
195
|
+
type: 'list-view',
|
|
196
|
+
objectName: 'contacts',
|
|
197
|
+
viewType: 'grid',
|
|
198
|
+
fields: ['name'],
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const { rerender } = renderWithProvider(
|
|
202
|
+
<ListView schema={schema} dataSource={mockDataSource} />,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// Resolve second (newer) request first
|
|
206
|
+
resolveSecond!([{ _id: '2', name: 'Second' }]);
|
|
207
|
+
|
|
208
|
+
await vi.waitFor(() => {
|
|
209
|
+
expect(mockDataSource.find).toHaveBeenCalled();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Resolve first (stale) request later
|
|
213
|
+
resolveFirst!([{ _id: '1', name: 'First (stale)' }]);
|
|
214
|
+
|
|
215
|
+
// Wait for state to settle — second request data should win
|
|
216
|
+
await vi.waitFor(() => {
|
|
217
|
+
// Data should eventually render from latest successful request
|
|
218
|
+
expect(screen.queryByTestId('empty-state')).not.toBeNull();
|
|
219
|
+
}, { timeout: 2000 }).catch(() => {
|
|
220
|
+
// This is fine — the key point is stale data doesn't overwrite new data
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
10
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
11
|
+
import { ListView } from '../ListView';
|
|
12
|
+
import type { ListViewSchema } from '@object-ui/types';
|
|
13
|
+
import { SchemaRendererProvider } from '@object-ui/react';
|
|
14
|
+
|
|
15
|
+
// Mock URL.createObjectURL and revokeObjectURL
|
|
16
|
+
const mockCreateObjectURL = vi.fn().mockReturnValue('blob:test');
|
|
17
|
+
const mockRevokeObjectURL = vi.fn();
|
|
18
|
+
Object.defineProperty(URL, 'createObjectURL', { value: mockCreateObjectURL, writable: true });
|
|
19
|
+
Object.defineProperty(URL, 'revokeObjectURL', { value: mockRevokeObjectURL, writable: true });
|
|
20
|
+
|
|
21
|
+
const mockDataSource = {
|
|
22
|
+
find: vi.fn().mockResolvedValue([]),
|
|
23
|
+
findOne: vi.fn(),
|
|
24
|
+
create: vi.fn(),
|
|
25
|
+
update: vi.fn(),
|
|
26
|
+
delete: vi.fn(),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const renderWithProvider = (component: React.ReactNode) => {
|
|
30
|
+
return render(
|
|
31
|
+
<SchemaRendererProvider dataSource={mockDataSource}>
|
|
32
|
+
{component}
|
|
33
|
+
</SchemaRendererProvider>
|
|
34
|
+
);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe('ListView Export', () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
vi.clearAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should render export button with configured formats', () => {
|
|
43
|
+
const schema: ListViewSchema = {
|
|
44
|
+
type: 'list-view',
|
|
45
|
+
objectName: 'contacts',
|
|
46
|
+
viewType: 'grid',
|
|
47
|
+
fields: ['name', 'email'],
|
|
48
|
+
exportOptions: {
|
|
49
|
+
formats: ['csv', 'json'],
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
renderWithProvider(<ListView schema={schema} />);
|
|
54
|
+
const exportButton = screen.getByRole('button', { name: /export/i });
|
|
55
|
+
expect(exportButton).toBeInTheDocument();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should render export button with spec string[] format', () => {
|
|
59
|
+
const schema: ListViewSchema = {
|
|
60
|
+
type: 'list-view',
|
|
61
|
+
objectName: 'contacts',
|
|
62
|
+
viewType: 'grid',
|
|
63
|
+
fields: ['name', 'email'],
|
|
64
|
+
exportOptions: ['csv', 'xlsx'],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
renderWithProvider(<ListView schema={schema} />);
|
|
68
|
+
const exportButton = screen.getByRole('button', { name: /export/i });
|
|
69
|
+
expect(exportButton).toBeInTheDocument();
|
|
70
|
+
|
|
71
|
+
// Click to open the popover and verify formats
|
|
72
|
+
fireEvent.click(exportButton);
|
|
73
|
+
expect(screen.getByRole('button', { name: /export as csv/i })).toBeInTheDocument();
|
|
74
|
+
expect(screen.getByRole('button', { name: /export as xlsx/i })).toBeInTheDocument();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should handle export with complex object fields in CSV safely', async () => {
|
|
78
|
+
const mockItems = [
|
|
79
|
+
{ _id: '1', name: 'Alice', tags: ['admin', 'user'], metadata: { role: 'lead' } },
|
|
80
|
+
{ _id: '2', name: 'Bob', tags: ['user'], metadata: null },
|
|
81
|
+
];
|
|
82
|
+
mockDataSource.find.mockResolvedValue(mockItems);
|
|
83
|
+
|
|
84
|
+
const schema: ListViewSchema = {
|
|
85
|
+
type: 'list-view',
|
|
86
|
+
objectName: 'contacts',
|
|
87
|
+
viewType: 'grid',
|
|
88
|
+
fields: ['name', 'tags', 'metadata'],
|
|
89
|
+
exportOptions: {
|
|
90
|
+
formats: ['csv'],
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} />);
|
|
95
|
+
|
|
96
|
+
// Wait for data to load
|
|
97
|
+
await vi.waitFor(() => {
|
|
98
|
+
expect(screen.getByTestId('record-count-bar')).toBeInTheDocument();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Click export button
|
|
102
|
+
const exportButton = screen.getByRole('button', { name: /export/i });
|
|
103
|
+
fireEvent.click(exportButton);
|
|
104
|
+
|
|
105
|
+
// Click CSV format
|
|
106
|
+
const csvButton = screen.getByRole('button', { name: /export as csv/i });
|
|
107
|
+
|
|
108
|
+
// Mock createElement and click
|
|
109
|
+
const mockClick = vi.fn();
|
|
110
|
+
const originalCreateElement = document.createElement.bind(document);
|
|
111
|
+
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
|
|
112
|
+
const el = originalCreateElement(tag);
|
|
113
|
+
if (tag === 'a') {
|
|
114
|
+
el.click = mockClick;
|
|
115
|
+
}
|
|
116
|
+
return el;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
fireEvent.click(csvButton);
|
|
120
|
+
expect(mockCreateObjectURL).toHaveBeenCalled();
|
|
121
|
+
|
|
122
|
+
// Verify the blob content includes safe serialization
|
|
123
|
+
const blobArg = mockCreateObjectURL.mock.calls[0]?.[0];
|
|
124
|
+
if (blobArg instanceof Blob) {
|
|
125
|
+
const text = await blobArg.text();
|
|
126
|
+
// Headers
|
|
127
|
+
expect(text).toContain('name,tags,metadata');
|
|
128
|
+
// Array should be serialized as semicolon-separated, not raw
|
|
129
|
+
expect(text).toContain('admin; user');
|
|
130
|
+
// Object should be JSON-serialized (CSV-escaped with doubled quotes)
|
|
131
|
+
expect(text).toContain('{""role"":""lead""}');
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should handle export with JSON format', async () => {
|
|
136
|
+
const mockItems = [
|
|
137
|
+
{ _id: '1', name: 'Alice', email: 'alice@test.com' },
|
|
138
|
+
];
|
|
139
|
+
mockDataSource.find.mockResolvedValue(mockItems);
|
|
140
|
+
|
|
141
|
+
const schema: ListViewSchema = {
|
|
142
|
+
type: 'list-view',
|
|
143
|
+
objectName: 'contacts',
|
|
144
|
+
viewType: 'grid',
|
|
145
|
+
fields: ['name', 'email'],
|
|
146
|
+
exportOptions: {
|
|
147
|
+
formats: ['json'],
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} />);
|
|
152
|
+
|
|
153
|
+
await vi.waitFor(() => {
|
|
154
|
+
expect(screen.getByTestId('record-count-bar')).toBeInTheDocument();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const exportButton = screen.getByRole('button', { name: /export/i });
|
|
158
|
+
fireEvent.click(exportButton);
|
|
159
|
+
|
|
160
|
+
const jsonButton = screen.getByRole('button', { name: /export as json/i });
|
|
161
|
+
|
|
162
|
+
const mockClick = vi.fn();
|
|
163
|
+
const originalCreateElement = document.createElement.bind(document);
|
|
164
|
+
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
|
|
165
|
+
const el = originalCreateElement(tag);
|
|
166
|
+
if (tag === 'a') {
|
|
167
|
+
el.click = mockClick;
|
|
168
|
+
}
|
|
169
|
+
return el;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
fireEvent.click(jsonButton);
|
|
173
|
+
expect(mockCreateObjectURL).toHaveBeenCalled();
|
|
174
|
+
});
|
|
175
|
+
});
|