@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,162 @@
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 { normalizeFilterCondition, normalizeFilters } from '../ListView';
11
+
12
+ describe('normalizeFilterCondition', () => {
13
+ // =========================================================================
14
+ // `in` operator normalization
15
+ // =========================================================================
16
+ describe('in operator', () => {
17
+ it('converts single-value `in` to `=`', () => {
18
+ expect(normalizeFilterCondition(['status', 'in', ['active']])).toEqual(
19
+ ['status', '=', 'active'],
20
+ );
21
+ });
22
+
23
+ it('converts multi-value `in` to `or` of `=`', () => {
24
+ expect(normalizeFilterCondition(['status', 'in', ['active', 'pending']])).toEqual(
25
+ ['or', ['status', '=', 'active'], ['status', '=', 'pending']],
26
+ );
27
+ });
28
+
29
+ it('returns empty array for empty `in` values', () => {
30
+ expect(normalizeFilterCondition(['status', 'in', []])).toEqual([]);
31
+ });
32
+
33
+ it('handles numeric values in `in`', () => {
34
+ expect(normalizeFilterCondition(['priority', 'in', [1, 2, 3]])).toEqual(
35
+ ['or', ['priority', '=', 1], ['priority', '=', 2], ['priority', '=', 3]],
36
+ );
37
+ });
38
+
39
+ it('handles boolean values in `in`', () => {
40
+ expect(normalizeFilterCondition(['is_active', 'in', [true]])).toEqual(
41
+ ['is_active', '=', true],
42
+ );
43
+ });
44
+ });
45
+
46
+ // =========================================================================
47
+ // `not in` operator normalization
48
+ // =========================================================================
49
+ describe('not in operator', () => {
50
+ it('converts single-value `not in` to `!=`', () => {
51
+ expect(normalizeFilterCondition(['status', 'not in', ['closed']])).toEqual(
52
+ ['status', '!=', 'closed'],
53
+ );
54
+ });
55
+
56
+ it('converts multi-value `not in` to `and` of `!=`', () => {
57
+ expect(normalizeFilterCondition(['status', 'not in', ['closed', 'archived']])).toEqual(
58
+ ['and', ['status', '!=', 'closed'], ['status', '!=', 'archived']],
59
+ );
60
+ });
61
+
62
+ it('returns empty array for empty `not in` values', () => {
63
+ expect(normalizeFilterCondition(['status', 'not in', []])).toEqual([]);
64
+ });
65
+ });
66
+
67
+ // =========================================================================
68
+ // Passthrough for non-in operators
69
+ // =========================================================================
70
+ describe('passthrough', () => {
71
+ it('passes through `=` operator unchanged', () => {
72
+ expect(normalizeFilterCondition(['name', '=', 'Alice'])).toEqual(
73
+ ['name', '=', 'Alice'],
74
+ );
75
+ });
76
+
77
+ it('passes through `!=` operator unchanged', () => {
78
+ expect(normalizeFilterCondition(['status', '!=', null])).toEqual(
79
+ ['status', '!=', null],
80
+ );
81
+ });
82
+
83
+ it('passes through `>` operator unchanged', () => {
84
+ expect(normalizeFilterCondition(['amount', '>', 100])).toEqual(
85
+ ['amount', '>', 100],
86
+ );
87
+ });
88
+
89
+ it('passes through `contains` operator unchanged', () => {
90
+ expect(normalizeFilterCondition(['name', 'contains', 'test'])).toEqual(
91
+ ['name', 'contains', 'test'],
92
+ );
93
+ });
94
+ });
95
+
96
+ // =========================================================================
97
+ // Logical group recursion
98
+ // =========================================================================
99
+ describe('logical groups', () => {
100
+ it('recursively normalizes `and` groups', () => {
101
+ const input = ['and', ['status', 'in', ['a', 'b']], ['name', '=', 'Alice']];
102
+ expect(normalizeFilterCondition(input)).toEqual(
103
+ ['and', ['or', ['status', '=', 'a'], ['status', '=', 'b']], ['name', '=', 'Alice']],
104
+ );
105
+ });
106
+
107
+ it('recursively normalizes `or` groups', () => {
108
+ const input = ['or', ['priority', 'in', [1, 2]], ['status', '=', 'active']];
109
+ expect(normalizeFilterCondition(input)).toEqual(
110
+ ['or', ['or', ['priority', '=', 1], ['priority', '=', 2]], ['status', '=', 'active']],
111
+ );
112
+ });
113
+ });
114
+
115
+ // =========================================================================
116
+ // Edge cases
117
+ // =========================================================================
118
+ describe('edge cases', () => {
119
+ it('handles non-array input gracefully', () => {
120
+ expect(normalizeFilterCondition([] as any)).toEqual([]);
121
+ });
122
+
123
+ it('handles short array input gracefully', () => {
124
+ expect(normalizeFilterCondition(['field'] as any)).toEqual(['field']);
125
+ });
126
+ });
127
+ });
128
+
129
+ describe('normalizeFilters', () => {
130
+ it('normalizes an array of conditions', () => {
131
+ const input = [
132
+ ['status', 'in', ['active', 'pending']],
133
+ ['name', '=', 'Alice'],
134
+ ];
135
+ const result = normalizeFilters(input);
136
+ expect(result).toEqual([
137
+ ['or', ['status', '=', 'active'], ['status', '=', 'pending']],
138
+ ['name', '=', 'Alice'],
139
+ ]);
140
+ });
141
+
142
+ it('filters out empty arrays from normalization', () => {
143
+ const input = [
144
+ ['status', 'in', []],
145
+ ['name', '=', 'Alice'],
146
+ ];
147
+ const result = normalizeFilters(input);
148
+ expect(result).toEqual([
149
+ ['name', '=', 'Alice'],
150
+ ]);
151
+ });
152
+
153
+ it('returns empty array for empty input', () => {
154
+ expect(normalizeFilters([])).toEqual([]);
155
+ });
156
+
157
+ it('handles non-array items gracefully', () => {
158
+ const input = [null, undefined, 'invalid', ['name', '=', 'test']];
159
+ const result = normalizeFilters(input as any);
160
+ expect(result).toEqual([['name', '=', 'test']]);
161
+ });
162
+ });
@@ -0,0 +1,237 @@
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 { ObjectGallery } from '../ObjectGallery';
12
+
13
+ const mockHandleClick = vi.fn();
14
+ const mockNavigationOverlay = {
15
+ isOverlay: false,
16
+ handleClick: mockHandleClick,
17
+ selectedRecord: null,
18
+ isOpen: false,
19
+ close: vi.fn(),
20
+ setIsOpen: vi.fn(),
21
+ mode: 'page' as const,
22
+ width: undefined,
23
+ view: undefined,
24
+ open: vi.fn(),
25
+ };
26
+
27
+ vi.mock('@object-ui/react', () => {
28
+ const React = require('react');
29
+ return {
30
+ useDataScope: () => undefined,
31
+ SchemaRendererContext: React.createContext(null),
32
+ useNavigationOverlay: () => mockNavigationOverlay,
33
+ };
34
+ });
35
+
36
+ vi.mock('@object-ui/components', () => ({
37
+ cn: (...args: any[]) => args.filter(Boolean).join(' '),
38
+ Card: ({ children, onClick, ...props }: any) => (
39
+ <div data-testid="gallery-card" onClick={onClick} {...props}>{children}</div>
40
+ ),
41
+ CardContent: ({ children, ...props }: any) => <div {...props}>{children}</div>,
42
+ NavigationOverlay: ({ children, selectedRecord }: any) => (
43
+ selectedRecord ? <div data-testid="navigation-overlay">{children(selectedRecord)}</div> : null
44
+ ),
45
+ }));
46
+
47
+ vi.mock('@object-ui/core', () => ({
48
+ ComponentRegistry: { register: vi.fn() },
49
+ }));
50
+
51
+ vi.mock('lucide-react', () => ({
52
+ ChevronRight: () => <span data-testid="chevron-right">▸</span>,
53
+ ChevronDown: () => <span data-testid="chevron-down">▾</span>,
54
+ }));
55
+
56
+ const mockItems = [
57
+ { id: '1', name: 'Alpha Widget', category: 'Electronics', image: 'https://example.com/1.jpg' },
58
+ { id: '2', name: 'Beta Gadget', category: 'Electronics', image: 'https://example.com/2.jpg' },
59
+ { id: '3', name: 'Gamma Tool', category: 'Tools', image: 'https://example.com/3.jpg' },
60
+ { id: '4', name: 'Delta Supply', category: 'Office', image: 'https://example.com/4.jpg' },
61
+ { id: '5', name: 'Epsilon Gear', category: 'Tools', image: 'https://example.com/5.jpg' },
62
+ ];
63
+
64
+ describe('ObjectGallery Grouping', () => {
65
+ beforeEach(() => {
66
+ vi.clearAllMocks();
67
+ });
68
+
69
+ it('renders without grouping (flat list) when no grouping config', () => {
70
+ const schema = { objectName: 'products' };
71
+ render(<ObjectGallery schema={schema} data={mockItems} />);
72
+
73
+ // All items visible
74
+ expect(screen.getByText('Alpha Widget')).toBeInTheDocument();
75
+ expect(screen.getByText('Beta Gadget')).toBeInTheDocument();
76
+ expect(screen.getByText('Gamma Tool')).toBeInTheDocument();
77
+ expect(screen.getByText('Delta Supply')).toBeInTheDocument();
78
+ expect(screen.getByText('Epsilon Gear')).toBeInTheDocument();
79
+
80
+ // No group headers
81
+ expect(screen.queryByText('Electronics')).not.toBeInTheDocument();
82
+ expect(screen.queryByText('Tools')).not.toBeInTheDocument();
83
+ });
84
+
85
+ it('renders grouped sections when grouping config is provided', () => {
86
+ const schema = {
87
+ objectName: 'products',
88
+ grouping: {
89
+ fields: [{ field: 'category', order: 'asc' as const, collapsed: false }],
90
+ },
91
+ };
92
+ render(<ObjectGallery schema={schema} data={mockItems} />);
93
+
94
+ // Group headers should be visible
95
+ expect(screen.getByText('Electronics')).toBeInTheDocument();
96
+ expect(screen.getByText('Tools')).toBeInTheDocument();
97
+ expect(screen.getByText('Office')).toBeInTheDocument();
98
+
99
+ // All items should be visible (none collapsed)
100
+ expect(screen.getByText('Alpha Widget')).toBeInTheDocument();
101
+ expect(screen.getByText('Beta Gadget')).toBeInTheDocument();
102
+ expect(screen.getByText('Gamma Tool')).toBeInTheDocument();
103
+ expect(screen.getByText('Delta Supply')).toBeInTheDocument();
104
+ expect(screen.getByText('Epsilon Gear')).toBeInTheDocument();
105
+ });
106
+
107
+ it('shows record count per group', () => {
108
+ const schema = {
109
+ objectName: 'products',
110
+ grouping: {
111
+ fields: [{ field: 'category', order: 'asc' as const, collapsed: false }],
112
+ },
113
+ };
114
+ render(<ObjectGallery schema={schema} data={mockItems} />);
115
+
116
+ // Electronics has 2 items, Tools has 2, Office has 1
117
+ const buttons = screen.getAllByRole('button');
118
+ const electronicsBtn = buttons.find(b => b.textContent?.includes('Electronics'));
119
+ const toolsBtn = buttons.find(b => b.textContent?.includes('Tools'));
120
+ const officeBtn = buttons.find(b => b.textContent?.includes('Office'));
121
+
122
+ expect(electronicsBtn?.textContent).toContain('2');
123
+ expect(toolsBtn?.textContent).toContain('2');
124
+ expect(officeBtn?.textContent).toContain('1');
125
+ });
126
+
127
+ it('collapses a group when clicking the group header', () => {
128
+ const schema = {
129
+ objectName: 'products',
130
+ grouping: {
131
+ fields: [{ field: 'category', order: 'asc' as const, collapsed: false }],
132
+ },
133
+ };
134
+ render(<ObjectGallery schema={schema} data={mockItems} />);
135
+
136
+ // All items visible initially
137
+ expect(screen.getByText('Alpha Widget')).toBeInTheDocument();
138
+ expect(screen.getByText('Beta Gadget')).toBeInTheDocument();
139
+
140
+ // Click Electronics group header to collapse
141
+ const buttons = screen.getAllByRole('button');
142
+ const electronicsBtn = buttons.find(b => b.textContent?.includes('Electronics'))!;
143
+ fireEvent.click(electronicsBtn);
144
+
145
+ // Electronics items should be hidden
146
+ expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument();
147
+ expect(screen.queryByText('Beta Gadget')).not.toBeInTheDocument();
148
+
149
+ // Other items still visible
150
+ expect(screen.getByText('Gamma Tool')).toBeInTheDocument();
151
+ expect(screen.getByText('Delta Supply')).toBeInTheDocument();
152
+ });
153
+
154
+ it('expands a collapsed group when clicking again', () => {
155
+ const schema = {
156
+ objectName: 'products',
157
+ grouping: {
158
+ fields: [{ field: 'category', order: 'asc' as const, collapsed: false }],
159
+ },
160
+ };
161
+ render(<ObjectGallery schema={schema} data={mockItems} />);
162
+
163
+ const buttons = screen.getAllByRole('button');
164
+ const electronicsBtn = buttons.find(b => b.textContent?.includes('Electronics'))!;
165
+
166
+ // Collapse
167
+ fireEvent.click(electronicsBtn);
168
+ expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument();
169
+
170
+ // Expand
171
+ fireEvent.click(electronicsBtn);
172
+ expect(screen.getByText('Alpha Widget')).toBeInTheDocument();
173
+ expect(screen.getByText('Beta Gadget')).toBeInTheDocument();
174
+ });
175
+
176
+ it('respects initial collapsed state from grouping config', () => {
177
+ const schema = {
178
+ objectName: 'products',
179
+ grouping: {
180
+ fields: [{ field: 'category', order: 'asc' as const, collapsed: true }],
181
+ },
182
+ };
183
+ render(<ObjectGallery schema={schema} data={mockItems} />);
184
+
185
+ // Group headers should be visible
186
+ expect(screen.getByText('Electronics')).toBeInTheDocument();
187
+ expect(screen.getByText('Tools')).toBeInTheDocument();
188
+ expect(screen.getByText('Office')).toBeInTheDocument();
189
+
190
+ // All items should be hidden (all groups collapsed by default)
191
+ expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument();
192
+ expect(screen.queryByText('Beta Gadget')).not.toBeInTheDocument();
193
+ expect(screen.queryByText('Gamma Tool')).not.toBeInTheDocument();
194
+ expect(screen.queryByText('Delta Supply')).not.toBeInTheDocument();
195
+ expect(screen.queryByText('Epsilon Gear')).not.toBeInTheDocument();
196
+ });
197
+
198
+ it('shows (empty) label for items with empty grouping field', () => {
199
+ const items = [
200
+ { id: '1', name: 'Item A', category: 'Cat1' },
201
+ { id: '2', name: 'Item B', category: '' },
202
+ { id: '3', name: 'Item C' }, // no category field
203
+ ];
204
+ const schema = {
205
+ objectName: 'products',
206
+ grouping: {
207
+ fields: [{ field: 'category', order: 'asc' as const, collapsed: false }],
208
+ },
209
+ };
210
+ render(<ObjectGallery schema={schema} data={items} />);
211
+
212
+ expect(screen.getByText('Cat1')).toBeInTheDocument();
213
+ expect(screen.getByText('(empty)')).toBeInTheDocument();
214
+ });
215
+
216
+ it('sorts groups by descending order when configured', () => {
217
+ const schema = {
218
+ objectName: 'products',
219
+ grouping: {
220
+ fields: [{ field: 'category', order: 'desc' as const, collapsed: false }],
221
+ },
222
+ };
223
+ render(<ObjectGallery schema={schema} data={mockItems} />);
224
+
225
+ const buttons = screen.getAllByRole('button');
226
+ const labels = buttons.map(b => {
227
+ // Extract the group label text (the <span> inside button)
228
+ const spans = b.querySelectorAll('span');
229
+ return spans[1]?.textContent; // label span
230
+ }).filter(Boolean);
231
+
232
+ // With desc order: Tools > Office > Electronics
233
+ expect(labels[0]).toBe('Tools');
234
+ expect(labels[1]).toBe('Office');
235
+ expect(labels[2]).toBe('Electronics');
236
+ });
237
+ });
@@ -0,0 +1,203 @@
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 type { ListViewSchema } from '@object-ui/types';
11
+
12
+ /**
13
+ * Tests for Gallery/Timeline spec config propagation through ListView's
14
+ * buildViewSchema. We test the internal logic by checking that the
15
+ * ListViewSchema types accept spec config and that the config values are correct.
16
+ */
17
+
18
+ describe('Gallery/Timeline Spec Config Types', () => {
19
+ describe('GalleryConfig on ListViewSchema', () => {
20
+ it('accepts spec gallery config with coverField', () => {
21
+ const schema: ListViewSchema = {
22
+ type: 'list-view',
23
+ objectName: 'products',
24
+ viewType: 'gallery',
25
+ fields: ['name', 'photo'],
26
+ gallery: {
27
+ coverField: 'photo',
28
+ coverFit: 'contain',
29
+ cardSize: 'large',
30
+ titleField: 'name',
31
+ visibleFields: ['status', 'price'],
32
+ },
33
+ };
34
+
35
+ expect(schema.gallery?.coverField).toBe('photo');
36
+ expect(schema.gallery?.coverFit).toBe('contain');
37
+ expect(schema.gallery?.cardSize).toBe('large');
38
+ expect(schema.gallery?.titleField).toBe('name');
39
+ expect(schema.gallery?.visibleFields).toEqual(['status', 'price']);
40
+ });
41
+
42
+ it('accepts all cardSize values', () => {
43
+ const sizes = ['small', 'medium', 'large'] as const;
44
+ sizes.forEach((cardSize) => {
45
+ const schema: ListViewSchema = {
46
+ type: 'list-view',
47
+ objectName: 'products',
48
+ viewType: 'gallery',
49
+ fields: ['name'],
50
+ gallery: { cardSize },
51
+ };
52
+ expect(schema.gallery?.cardSize).toBe(cardSize);
53
+ });
54
+ });
55
+
56
+ it('accepts all coverFit values', () => {
57
+ const fits = ['cover', 'contain', 'fill'] as const;
58
+ fits.forEach((coverFit) => {
59
+ const schema: ListViewSchema = {
60
+ type: 'list-view',
61
+ objectName: 'products',
62
+ viewType: 'gallery',
63
+ fields: ['name'],
64
+ gallery: { coverFit },
65
+ };
66
+ expect(schema.gallery?.coverFit).toBe(coverFit);
67
+ });
68
+ });
69
+
70
+ it('accepts legacy imageField and subtitleField alongside spec fields', () => {
71
+ const schema: ListViewSchema = {
72
+ type: 'list-view',
73
+ objectName: 'products',
74
+ viewType: 'gallery',
75
+ fields: ['name'],
76
+ gallery: {
77
+ coverField: 'photo',
78
+ imageField: 'legacyImg',
79
+ subtitleField: 'description',
80
+ },
81
+ };
82
+
83
+ expect(schema.gallery?.coverField).toBe('photo');
84
+ expect(schema.gallery?.imageField).toBe('legacyImg');
85
+ expect(schema.gallery?.subtitleField).toBe('description');
86
+ });
87
+
88
+ it('accepts gallery config from legacy options as fallback', () => {
89
+ const schema: ListViewSchema = {
90
+ type: 'list-view',
91
+ objectName: 'products',
92
+ viewType: 'gallery',
93
+ fields: ['name'],
94
+ options: {
95
+ gallery: { imageField: 'oldImg', titleField: 'label' },
96
+ },
97
+ };
98
+
99
+ expect(schema.options?.gallery?.imageField).toBe('oldImg');
100
+ expect(schema.options?.gallery?.titleField).toBe('label');
101
+ });
102
+ });
103
+
104
+ describe('TimelineConfig on ListViewSchema', () => {
105
+ it('accepts spec timeline config with all fields', () => {
106
+ const schema: ListViewSchema = {
107
+ type: 'list-view',
108
+ objectName: 'events',
109
+ viewType: 'timeline',
110
+ fields: ['name', 'date'],
111
+ timeline: {
112
+ startDateField: 'start_date',
113
+ endDateField: 'end_date',
114
+ titleField: 'event_name',
115
+ groupByField: 'category',
116
+ colorField: 'priority_color',
117
+ scale: 'month',
118
+ },
119
+ };
120
+
121
+ expect(schema.timeline?.startDateField).toBe('start_date');
122
+ expect(schema.timeline?.endDateField).toBe('end_date');
123
+ expect(schema.timeline?.titleField).toBe('event_name');
124
+ expect(schema.timeline?.groupByField).toBe('category');
125
+ expect(schema.timeline?.colorField).toBe('priority_color');
126
+ expect(schema.timeline?.scale).toBe('month');
127
+ });
128
+
129
+ it('accepts legacy dateField for backward compatibility', () => {
130
+ const schema: ListViewSchema = {
131
+ type: 'list-view',
132
+ objectName: 'events',
133
+ viewType: 'timeline',
134
+ fields: ['name'],
135
+ timeline: {
136
+ startDateField: 'created_at',
137
+ titleField: 'name',
138
+ dateField: 'legacy_date',
139
+ },
140
+ };
141
+
142
+ expect(schema.timeline?.startDateField).toBe('created_at');
143
+ expect(schema.timeline?.dateField).toBe('legacy_date');
144
+ });
145
+
146
+ it('supports all scale values', () => {
147
+ const scales = ['hour', 'day', 'week', 'month', 'quarter', 'year'] as const;
148
+ scales.forEach((scale) => {
149
+ const schema: ListViewSchema = {
150
+ type: 'list-view',
151
+ objectName: 'events',
152
+ viewType: 'timeline',
153
+ fields: ['name'],
154
+ timeline: { startDateField: 'date', titleField: 'name', scale },
155
+ };
156
+ expect(schema.timeline?.scale).toBe(scale);
157
+ });
158
+ });
159
+
160
+ it('accepts timeline config from legacy options as fallback', () => {
161
+ const schema: ListViewSchema = {
162
+ type: 'list-view',
163
+ objectName: 'events',
164
+ viewType: 'timeline',
165
+ fields: ['name'],
166
+ options: {
167
+ timeline: { dateField: 'created_at', titleField: 'name' },
168
+ },
169
+ };
170
+
171
+ expect(schema.options?.timeline?.dateField).toBe('created_at');
172
+ });
173
+ });
174
+
175
+ describe('spec config co-existence', () => {
176
+ it('gallery and timeline configs can coexist on the same ListViewSchema', () => {
177
+ const schema: ListViewSchema = {
178
+ type: 'list-view',
179
+ objectName: 'projects',
180
+ viewType: 'grid',
181
+ fields: ['name', 'date', 'photo'],
182
+ gallery: {
183
+ coverField: 'photo',
184
+ cardSize: 'medium',
185
+ titleField: 'name',
186
+ visibleFields: ['status'],
187
+ },
188
+ timeline: {
189
+ startDateField: 'start_date',
190
+ titleField: 'name',
191
+ scale: 'quarter',
192
+ groupByField: 'team',
193
+ },
194
+ };
195
+
196
+ expect(schema.gallery?.coverField).toBe('photo');
197
+ expect(schema.gallery?.cardSize).toBe('medium');
198
+ expect(schema.timeline?.startDateField).toBe('start_date');
199
+ expect(schema.timeline?.scale).toBe('quarter');
200
+ expect(schema.timeline?.groupByField).toBe('team');
201
+ });
202
+ });
203
+ });