@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.
@@ -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
+ });