@object-ui/plugin-list 3.0.3 → 3.1.0
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/dist/index.js +26941 -24204
- 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 +1200 -159
- package/src/ObjectGallery.tsx +191 -63
- package/src/UserFilters.tsx +461 -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 +1884 -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 +494 -0
- package/src/components/TabBar.tsx +120 -0
- package/src/index.tsx +13 -4
|
@@ -0,0 +1,250 @@
|
|
|
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 React from 'react';
|
|
12
|
+
|
|
13
|
+
// Capture the schema prop passed to SchemaRenderer
|
|
14
|
+
let capturedSchema: any = null;
|
|
15
|
+
|
|
16
|
+
vi.mock('@object-ui/react', () => ({
|
|
17
|
+
SchemaRenderer: (props: any) => {
|
|
18
|
+
capturedSchema = props.schema;
|
|
19
|
+
return <div data-testid="schema-renderer" data-schema-type={props.schema?.type}>{props.schema?.type}</div>;
|
|
20
|
+
},
|
|
21
|
+
useNavigationOverlay: () => ({
|
|
22
|
+
isOverlay: false,
|
|
23
|
+
handleClick: vi.fn(),
|
|
24
|
+
selectedRecord: null,
|
|
25
|
+
isOpen: false,
|
|
26
|
+
close: vi.fn(),
|
|
27
|
+
setIsOpen: vi.fn(),
|
|
28
|
+
mode: 'page' as const,
|
|
29
|
+
width: undefined,
|
|
30
|
+
view: undefined,
|
|
31
|
+
open: vi.fn(),
|
|
32
|
+
}),
|
|
33
|
+
useDensityMode: () => ['comfortable', vi.fn()] as const,
|
|
34
|
+
SchemaRendererProvider: ({ children }: any) => <div>{children}</div>,
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
vi.mock('@object-ui/components', () => ({
|
|
38
|
+
cn: (...args: any[]) => args.filter(Boolean).join(' '),
|
|
39
|
+
Input: (props: any) => <input {...props} />,
|
|
40
|
+
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
|
41
|
+
Badge: ({ children, ...props }: any) => <span {...props}>{children}</span>,
|
|
42
|
+
Popover: ({ children }: any) => <div>{children}</div>,
|
|
43
|
+
PopoverTrigger: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
44
|
+
PopoverContent: ({ children }: any) => <div>{children}</div>,
|
|
45
|
+
FilterBuilder: () => null,
|
|
46
|
+
SortBuilder: () => null,
|
|
47
|
+
NavigationOverlay: () => null,
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
vi.mock('@object-ui/mobile', () => ({
|
|
51
|
+
usePullToRefresh: () => ({ pullRef: { current: null } }),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
vi.mock('@object-ui/core', async (importOriginal) => {
|
|
55
|
+
const actual = await importOriginal<Record<string, unknown>>();
|
|
56
|
+
return {
|
|
57
|
+
...actual,
|
|
58
|
+
ExpressionEvaluator: {
|
|
59
|
+
evaluate: vi.fn((expr: string) => expr),
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
vi.mock('@object-ui/i18n', () => ({
|
|
65
|
+
useObjectTranslation: () => ({
|
|
66
|
+
t: (key: string, fallback?: string) => fallback || key,
|
|
67
|
+
}),
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
vi.mock('../ViewSwitcher', () => ({
|
|
71
|
+
ViewSwitcher: ({ currentView, onViewChange }: any) => (
|
|
72
|
+
<div data-testid="view-switcher">
|
|
73
|
+
<button aria-label="Grid" onClick={() => onViewChange('grid')}>Grid</button>
|
|
74
|
+
<button aria-label="Kanban" onClick={() => onViewChange('kanban')}>Kanban</button>
|
|
75
|
+
<button aria-label="Gallery" onClick={() => onViewChange('gallery')}>Gallery</button>
|
|
76
|
+
</div>
|
|
77
|
+
),
|
|
78
|
+
ViewType: {},
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
vi.mock('../UserFilters', () => ({
|
|
82
|
+
UserFilters: () => null,
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
vi.mock('lucide-react', () => ({
|
|
86
|
+
Search: () => <span>Search</span>,
|
|
87
|
+
SlidersHorizontal: () => <span>Sliders</span>,
|
|
88
|
+
ArrowUpDown: () => <span>Sort</span>,
|
|
89
|
+
X: () => <span>X</span>,
|
|
90
|
+
EyeOff: () => <span>EyeOff</span>,
|
|
91
|
+
Group: () => <span>Group</span>,
|
|
92
|
+
Paintbrush: () => <span>Color</span>,
|
|
93
|
+
Ruler: () => <span>Ruler</span>,
|
|
94
|
+
Inbox: () => <span>Inbox</span>,
|
|
95
|
+
Download: () => <span>Download</span>,
|
|
96
|
+
AlignJustify: () => <span>Density</span>,
|
|
97
|
+
Share2: () => <span>Share</span>,
|
|
98
|
+
Printer: () => <span>Print</span>,
|
|
99
|
+
Plus: () => <span>Plus</span>,
|
|
100
|
+
icons: {},
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
import { ListView } from '../ListView';
|
|
104
|
+
|
|
105
|
+
const groupingConfig = {
|
|
106
|
+
fields: [{ field: 'category', order: 'asc' as const, collapsed: false }],
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const testData = [
|
|
110
|
+
{ id: '1', name: 'Product A', category: 'Electronics' },
|
|
111
|
+
{ id: '2', name: 'Product B', category: 'Tools' },
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
describe('ListView grouping config propagation', () => {
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
capturedSchema = null;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('passes grouping config to grid view schema', () => {
|
|
120
|
+
render(
|
|
121
|
+
<ListView
|
|
122
|
+
schema={{
|
|
123
|
+
type: 'list-view',
|
|
124
|
+
objectName: 'products',
|
|
125
|
+
viewType: 'grid',
|
|
126
|
+
fields: ['name', 'category'],
|
|
127
|
+
grouping: groupingConfig,
|
|
128
|
+
data: testData,
|
|
129
|
+
}}
|
|
130
|
+
/>,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(capturedSchema).toBeDefined();
|
|
134
|
+
expect(capturedSchema.type).toBe('object-grid');
|
|
135
|
+
expect(capturedSchema.grouping).toEqual(groupingConfig);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('passes grouping config to kanban view schema', () => {
|
|
139
|
+
render(
|
|
140
|
+
<ListView
|
|
141
|
+
schema={{
|
|
142
|
+
type: 'list-view',
|
|
143
|
+
objectName: 'products',
|
|
144
|
+
viewType: 'kanban',
|
|
145
|
+
fields: ['name', 'category'],
|
|
146
|
+
grouping: groupingConfig,
|
|
147
|
+
data: testData,
|
|
148
|
+
kanban: { groupField: 'status' },
|
|
149
|
+
}}
|
|
150
|
+
showViewSwitcher={true}
|
|
151
|
+
/>,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// Switch to kanban view
|
|
155
|
+
const kanbanBtn = screen.getByLabelText('Kanban');
|
|
156
|
+
fireEvent.click(kanbanBtn);
|
|
157
|
+
|
|
158
|
+
expect(capturedSchema).toBeDefined();
|
|
159
|
+
expect(capturedSchema.type).toBe('object-kanban');
|
|
160
|
+
expect(capturedSchema.grouping).toEqual(groupingConfig);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('passes grouping config to gallery view schema', () => {
|
|
164
|
+
render(
|
|
165
|
+
<ListView
|
|
166
|
+
schema={{
|
|
167
|
+
type: 'list-view',
|
|
168
|
+
objectName: 'products',
|
|
169
|
+
viewType: 'gallery',
|
|
170
|
+
fields: ['name', 'category'],
|
|
171
|
+
grouping: groupingConfig,
|
|
172
|
+
data: testData,
|
|
173
|
+
}}
|
|
174
|
+
showViewSwitcher={true}
|
|
175
|
+
/>,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// Switch to gallery view
|
|
179
|
+
const galleryBtn = screen.getByLabelText('Gallery');
|
|
180
|
+
fireEvent.click(galleryBtn);
|
|
181
|
+
|
|
182
|
+
expect(capturedSchema).toBeDefined();
|
|
183
|
+
expect(capturedSchema.type).toBe('object-gallery');
|
|
184
|
+
expect(capturedSchema.grouping).toEqual(groupingConfig);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('updates grid schema when user toggles grouping via toolbar', async () => {
|
|
188
|
+
render(
|
|
189
|
+
<ListView
|
|
190
|
+
schema={{
|
|
191
|
+
type: 'list-view',
|
|
192
|
+
objectName: 'products',
|
|
193
|
+
viewType: 'grid',
|
|
194
|
+
fields: ['name', 'category'],
|
|
195
|
+
data: testData,
|
|
196
|
+
}}
|
|
197
|
+
/>,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// Initially no grouping
|
|
201
|
+
expect(capturedSchema).toBeDefined();
|
|
202
|
+
expect(capturedSchema.type).toBe('object-grid');
|
|
203
|
+
expect(capturedSchema.grouping).toBeUndefined();
|
|
204
|
+
|
|
205
|
+
// Open Group popover
|
|
206
|
+
const groupButton = screen.getByRole('button', { name: /group/i });
|
|
207
|
+
fireEvent.click(groupButton);
|
|
208
|
+
|
|
209
|
+
// Select the 'category' field checkbox in the group field list
|
|
210
|
+
const fieldList = screen.getByTestId('group-field-list');
|
|
211
|
+
const checkboxes = fieldList.querySelectorAll('input[type="checkbox"]');
|
|
212
|
+
// Find the checkbox for 'category'
|
|
213
|
+
const categoryCheckbox = Array.from(checkboxes).find(cb => {
|
|
214
|
+
const label = cb.closest('label');
|
|
215
|
+
return label?.textContent?.includes('category');
|
|
216
|
+
});
|
|
217
|
+
expect(categoryCheckbox).toBeDefined();
|
|
218
|
+
fireEvent.click(categoryCheckbox!);
|
|
219
|
+
|
|
220
|
+
// Schema should now include grouping config
|
|
221
|
+
expect(capturedSchema.grouping).toBeDefined();
|
|
222
|
+
expect(capturedSchema.grouping.fields).toEqual(
|
|
223
|
+
expect.arrayContaining([
|
|
224
|
+
expect.objectContaining({ field: 'category' }),
|
|
225
|
+
]),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
// Toggle off — click the checkbox again
|
|
229
|
+
fireEvent.click(categoryCheckbox!);
|
|
230
|
+
expect(capturedSchema.grouping).toBeUndefined();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('does not pass grouping when no grouping config exists', () => {
|
|
234
|
+
render(
|
|
235
|
+
<ListView
|
|
236
|
+
schema={{
|
|
237
|
+
type: 'list-view',
|
|
238
|
+
objectName: 'products',
|
|
239
|
+
viewType: 'grid',
|
|
240
|
+
fields: ['name', 'category'],
|
|
241
|
+
data: testData,
|
|
242
|
+
}}
|
|
243
|
+
/>,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
expect(capturedSchema).toBeDefined();
|
|
247
|
+
expect(capturedSchema.type).toBe('object-grid');
|
|
248
|
+
expect(capturedSchema.grouping).toBeUndefined();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
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 } from 'vitest';
|
|
10
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
11
|
+
import { ObjectGallery } from '../ObjectGallery';
|
|
12
|
+
|
|
13
|
+
// Mock useDataScope, useSchemaContext, and useNavigationOverlay
|
|
14
|
+
const mockHandleClick = vi.fn();
|
|
15
|
+
const mockNavigationOverlay = {
|
|
16
|
+
isOverlay: false,
|
|
17
|
+
handleClick: mockHandleClick,
|
|
18
|
+
selectedRecord: null,
|
|
19
|
+
isOpen: false,
|
|
20
|
+
close: vi.fn(),
|
|
21
|
+
setIsOpen: vi.fn(),
|
|
22
|
+
mode: 'page' as const,
|
|
23
|
+
width: undefined,
|
|
24
|
+
view: undefined,
|
|
25
|
+
open: vi.fn(),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
vi.mock('@object-ui/react', () => {
|
|
29
|
+
const React = require('react');
|
|
30
|
+
return {
|
|
31
|
+
useDataScope: () => undefined,
|
|
32
|
+
SchemaRendererContext: React.createContext(null),
|
|
33
|
+
useNavigationOverlay: () => mockNavigationOverlay,
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
vi.mock('@object-ui/components', () => ({
|
|
38
|
+
cn: (...args: any[]) => args.filter(Boolean).join(' '),
|
|
39
|
+
Card: ({ children, onClick, ...props }: any) => (
|
|
40
|
+
<div data-testid="gallery-card" onClick={onClick} {...props}>{children}</div>
|
|
41
|
+
),
|
|
42
|
+
CardContent: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
|
43
|
+
NavigationOverlay: ({ children, selectedRecord }: any) => (
|
|
44
|
+
selectedRecord ? <div data-testid="navigation-overlay">{children(selectedRecord)}</div> : null
|
|
45
|
+
),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
vi.mock('@object-ui/core', () => ({
|
|
49
|
+
ComponentRegistry: { register: vi.fn() },
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
const mockItems = [
|
|
53
|
+
{ id: '1', name: 'Item 1', image: 'https://example.com/1.jpg' },
|
|
54
|
+
{ id: '2', name: 'Item 2', image: 'https://example.com/2.jpg' },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
describe('ObjectGallery', () => {
|
|
58
|
+
it('renders gallery items', () => {
|
|
59
|
+
const schema = { objectName: 'products' };
|
|
60
|
+
render(<ObjectGallery schema={schema} data={mockItems} />);
|
|
61
|
+
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
|
62
|
+
expect(screen.getByText('Item 2')).toBeInTheDocument();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('calls navigation.handleClick on card click', () => {
|
|
66
|
+
const schema = {
|
|
67
|
+
objectName: 'products',
|
|
68
|
+
navigation: { mode: 'drawer' as const },
|
|
69
|
+
};
|
|
70
|
+
render(<ObjectGallery schema={schema} data={mockItems} />);
|
|
71
|
+
|
|
72
|
+
const cards = screen.getAllByTestId('gallery-card');
|
|
73
|
+
fireEvent.click(cards[0]);
|
|
74
|
+
|
|
75
|
+
expect(mockHandleClick).toHaveBeenCalledWith(mockItems[0]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('renders with cursor-pointer when navigation is configured', () => {
|
|
79
|
+
const schema = {
|
|
80
|
+
objectName: 'products',
|
|
81
|
+
navigation: { mode: 'drawer' as const },
|
|
82
|
+
};
|
|
83
|
+
render(<ObjectGallery schema={schema} data={mockItems} />);
|
|
84
|
+
|
|
85
|
+
const cards = screen.getAllByTestId('gallery-card');
|
|
86
|
+
expect(cards.length).toBe(2);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('renders with cursor-pointer when onCardClick is provided', () => {
|
|
90
|
+
const onCardClick = vi.fn();
|
|
91
|
+
const schema = { objectName: 'products' };
|
|
92
|
+
render(<ObjectGallery schema={schema} data={mockItems} onCardClick={onCardClick} />);
|
|
93
|
+
|
|
94
|
+
const cards = screen.getAllByTestId('gallery-card');
|
|
95
|
+
fireEvent.click(cards[0]);
|
|
96
|
+
|
|
97
|
+
expect(mockHandleClick).toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ============================
|
|
101
|
+
// Spec GalleryConfig integration
|
|
102
|
+
// ============================
|
|
103
|
+
describe('Spec GalleryConfig', () => {
|
|
104
|
+
it('schema.gallery.coverField drives cover image', () => {
|
|
105
|
+
const data = [
|
|
106
|
+
{ id: '1', name: 'Photo A', photo: 'https://example.com/a.jpg' },
|
|
107
|
+
];
|
|
108
|
+
const schema = {
|
|
109
|
+
objectName: 'albums',
|
|
110
|
+
gallery: { coverField: 'photo' },
|
|
111
|
+
};
|
|
112
|
+
render(<ObjectGallery schema={schema} data={data} />);
|
|
113
|
+
const img = screen.getByRole('img');
|
|
114
|
+
expect(img).toHaveAttribute('src', 'https://example.com/a.jpg');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('schema.gallery.cardSize controls grid layout class', () => {
|
|
118
|
+
const data = [{ id: '1', name: 'Item', image: 'https://example.com/1.jpg' }];
|
|
119
|
+
|
|
120
|
+
// small cards → more columns
|
|
121
|
+
const { container: c1 } = render(
|
|
122
|
+
<ObjectGallery schema={{ objectName: 'a', gallery: { cardSize: 'small' } }} data={data} />,
|
|
123
|
+
);
|
|
124
|
+
expect(c1.querySelector('[role="list"]')?.className).toContain('grid-cols-2');
|
|
125
|
+
|
|
126
|
+
// large cards → fewer columns
|
|
127
|
+
const { container: c2 } = render(
|
|
128
|
+
<ObjectGallery schema={{ objectName: 'a', gallery: { cardSize: 'large' } }} data={data} />,
|
|
129
|
+
);
|
|
130
|
+
expect(c2.querySelector('[role="list"]')?.className).toContain('lg:grid-cols-3');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('schema.gallery.coverFit applies object-contain class', () => {
|
|
134
|
+
const data = [{ id: '1', name: 'Item', thumb: 'https://example.com/1.jpg' }];
|
|
135
|
+
const schema = {
|
|
136
|
+
objectName: 'items',
|
|
137
|
+
gallery: { coverField: 'thumb', coverFit: 'contain' as const },
|
|
138
|
+
};
|
|
139
|
+
render(<ObjectGallery schema={schema} data={data} />);
|
|
140
|
+
const img = screen.getByRole('img');
|
|
141
|
+
expect(img.className).toContain('object-contain');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('schema.gallery.visibleFields shows additional fields on card', () => {
|
|
145
|
+
const data = [
|
|
146
|
+
{ id: '1', name: 'Item 1', status: 'active', category: 'books' },
|
|
147
|
+
];
|
|
148
|
+
const schema = {
|
|
149
|
+
objectName: 'items',
|
|
150
|
+
gallery: { visibleFields: ['status', 'category'] },
|
|
151
|
+
};
|
|
152
|
+
render(<ObjectGallery schema={schema} data={data} />);
|
|
153
|
+
expect(screen.getByText('active')).toBeInTheDocument();
|
|
154
|
+
expect(screen.getByText('books')).toBeInTheDocument();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('schema.gallery.titleField overrides default title', () => {
|
|
158
|
+
const data = [
|
|
159
|
+
{ id: '1', name: 'Default Name', displayName: 'Custom Title' },
|
|
160
|
+
];
|
|
161
|
+
const schema = {
|
|
162
|
+
objectName: 'items',
|
|
163
|
+
gallery: { titleField: 'displayName' },
|
|
164
|
+
};
|
|
165
|
+
render(<ObjectGallery schema={schema} data={data} />);
|
|
166
|
+
expect(screen.getByText('Custom Title')).toBeInTheDocument();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('falls back to legacy imageField when gallery.coverField is not set', () => {
|
|
170
|
+
const data = [
|
|
171
|
+
{ id: '1', name: 'Item', legacyImg: 'https://example.com/legacy.jpg' },
|
|
172
|
+
];
|
|
173
|
+
const schema = {
|
|
174
|
+
objectName: 'items',
|
|
175
|
+
imageField: 'legacyImg',
|
|
176
|
+
};
|
|
177
|
+
render(<ObjectGallery schema={schema} data={data} />);
|
|
178
|
+
const img = screen.getByRole('img');
|
|
179
|
+
expect(img).toHaveAttribute('src', 'https://example.com/legacy.jpg');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('falls back to legacy titleField when gallery.titleField is not set', () => {
|
|
183
|
+
const data = [
|
|
184
|
+
{ id: '1', name: 'Default', label: 'Legacy Title' },
|
|
185
|
+
];
|
|
186
|
+
const schema = {
|
|
187
|
+
objectName: 'items',
|
|
188
|
+
titleField: 'label',
|
|
189
|
+
};
|
|
190
|
+
render(<ObjectGallery schema={schema} data={data} />);
|
|
191
|
+
expect(screen.getByText('Legacy Title')).toBeInTheDocument();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('spec gallery.coverField takes priority over legacy imageField', () => {
|
|
195
|
+
const data = [
|
|
196
|
+
{ id: '1', name: 'Item', photo: 'https://spec.com/a.jpg', oldImg: 'https://old.com/b.jpg' },
|
|
197
|
+
];
|
|
198
|
+
const schema = {
|
|
199
|
+
objectName: 'items',
|
|
200
|
+
imageField: 'oldImg',
|
|
201
|
+
gallery: { coverField: 'photo' },
|
|
202
|
+
};
|
|
203
|
+
render(<ObjectGallery schema={schema} data={data} />);
|
|
204
|
+
const img = screen.getByRole('img');
|
|
205
|
+
expect(img).toHaveAttribute('src', 'https://spec.com/a.jpg');
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
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 } from 'vitest';
|
|
10
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
11
|
+
import { TabBar } from '../components/TabBar';
|
|
12
|
+
import type { ViewTab } from '../components/TabBar';
|
|
13
|
+
|
|
14
|
+
describe('TabBar', () => {
|
|
15
|
+
const baseTabs: ViewTab[] = [
|
|
16
|
+
{ name: 'all', label: 'All Records', isDefault: true },
|
|
17
|
+
{ name: 'active', label: 'Active' },
|
|
18
|
+
{ name: 'archived', label: 'Archived' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
it('should render tab bar with tabs', () => {
|
|
22
|
+
render(<TabBar tabs={baseTabs} />);
|
|
23
|
+
expect(screen.getByTestId('view-tabs')).toBeInTheDocument();
|
|
24
|
+
expect(screen.getByTestId('view-tab-all')).toBeInTheDocument();
|
|
25
|
+
expect(screen.getByTestId('view-tab-active')).toBeInTheDocument();
|
|
26
|
+
expect(screen.getByTestId('view-tab-archived')).toBeInTheDocument();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should render tab labels', () => {
|
|
30
|
+
render(<TabBar tabs={baseTabs} />);
|
|
31
|
+
expect(screen.getByText('All Records')).toBeInTheDocument();
|
|
32
|
+
expect(screen.getByText('Active')).toBeInTheDocument();
|
|
33
|
+
expect(screen.getByText('Archived')).toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should not render when tabs array is empty', () => {
|
|
37
|
+
const { container } = render(<TabBar tabs={[]} />);
|
|
38
|
+
expect(container.innerHTML).toBe('');
|
|
39
|
+
expect(screen.queryByTestId('view-tabs')).not.toBeInTheDocument();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// isDefault tab should be selected by default
|
|
43
|
+
it('should select isDefault tab initially', () => {
|
|
44
|
+
render(<TabBar tabs={baseTabs} />);
|
|
45
|
+
const defaultTab = screen.getByTestId('view-tab-all');
|
|
46
|
+
expect(defaultTab).toHaveAttribute('aria-selected', 'true');
|
|
47
|
+
const otherTab = screen.getByTestId('view-tab-active');
|
|
48
|
+
expect(otherTab).toHaveAttribute('aria-selected', 'false');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should select first tab when no isDefault is set', () => {
|
|
52
|
+
const tabs: ViewTab[] = [
|
|
53
|
+
{ name: 'alpha', label: 'Alpha' },
|
|
54
|
+
{ name: 'beta', label: 'Beta' },
|
|
55
|
+
];
|
|
56
|
+
render(<TabBar tabs={tabs} />);
|
|
57
|
+
expect(screen.getByTestId('view-tab-alpha')).toHaveAttribute('aria-selected', 'true');
|
|
58
|
+
expect(screen.getByTestId('view-tab-beta')).toHaveAttribute('aria-selected', 'false');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Tab click changes active tab
|
|
62
|
+
it('should switch active tab on click', () => {
|
|
63
|
+
render(<TabBar tabs={baseTabs} />);
|
|
64
|
+
const activeTab = screen.getByTestId('view-tab-active');
|
|
65
|
+
fireEvent.click(activeTab);
|
|
66
|
+
expect(activeTab).toHaveAttribute('aria-selected', 'true');
|
|
67
|
+
expect(screen.getByTestId('view-tab-all')).toHaveAttribute('aria-selected', 'false');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should call onTabChange callback on click', () => {
|
|
71
|
+
const onTabChange = vi.fn();
|
|
72
|
+
render(<TabBar tabs={baseTabs} onTabChange={onTabChange} />);
|
|
73
|
+
fireEvent.click(screen.getByTestId('view-tab-active'));
|
|
74
|
+
expect(onTabChange).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(onTabChange).toHaveBeenCalledWith(expect.objectContaining({ name: 'active', label: 'Active' }));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Pinned tabs always visible
|
|
79
|
+
it('should always show pinned tabs even if visible is false', () => {
|
|
80
|
+
const tabs: ViewTab[] = [
|
|
81
|
+
{ name: 'all', label: 'All', isDefault: true },
|
|
82
|
+
{ name: 'pinned-tab', label: 'Pinned', pinned: true, visible: 'false' },
|
|
83
|
+
{ name: 'hidden', label: 'Hidden', visible: 'false' },
|
|
84
|
+
];
|
|
85
|
+
render(<TabBar tabs={tabs} />);
|
|
86
|
+
expect(screen.getByTestId('view-tab-pinned-tab')).toBeInTheDocument();
|
|
87
|
+
expect(screen.queryByTestId('view-tab-hidden')).not.toBeInTheDocument();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Hidden tabs filtered out
|
|
91
|
+
it('should filter out hidden tabs (visible: "false")', () => {
|
|
92
|
+
const tabs: ViewTab[] = [
|
|
93
|
+
{ name: 'all', label: 'All Records' },
|
|
94
|
+
{ name: 'hidden', label: 'Hidden Tab', visible: 'false' },
|
|
95
|
+
];
|
|
96
|
+
render(<TabBar tabs={tabs} />);
|
|
97
|
+
expect(screen.getByText('All Records')).toBeInTheDocument();
|
|
98
|
+
expect(screen.queryByText('Hidden Tab')).not.toBeInTheDocument();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should filter out tabs with visible: boolean false', () => {
|
|
102
|
+
const tabs: ViewTab[] = [
|
|
103
|
+
{ name: 'all', label: 'All Records' },
|
|
104
|
+
{ name: 'hidden', label: 'Hidden Tab', visible: false as any },
|
|
105
|
+
];
|
|
106
|
+
render(<TabBar tabs={tabs} />);
|
|
107
|
+
expect(screen.getByText('All Records')).toBeInTheDocument();
|
|
108
|
+
expect(screen.queryByText('Hidden Tab')).not.toBeInTheDocument();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Order sorting
|
|
112
|
+
it('should sort tabs by order property', () => {
|
|
113
|
+
const tabs: ViewTab[] = [
|
|
114
|
+
{ name: 'c', label: 'Third', order: 3 },
|
|
115
|
+
{ name: 'a', label: 'First', order: 1 },
|
|
116
|
+
{ name: 'b', label: 'Second', order: 2 },
|
|
117
|
+
];
|
|
118
|
+
render(<TabBar tabs={tabs} />);
|
|
119
|
+
const tabContainer = screen.getByTestId('view-tabs');
|
|
120
|
+
const buttons = tabContainer.querySelectorAll('button');
|
|
121
|
+
expect(buttons[0]).toHaveTextContent('First');
|
|
122
|
+
expect(buttons[1]).toHaveTextContent('Second');
|
|
123
|
+
expect(buttons[2]).toHaveTextContent('Third');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Icon rendering
|
|
127
|
+
it('should render Lucide icon when icon prop is provided', () => {
|
|
128
|
+
const tabs: ViewTab[] = [
|
|
129
|
+
{ name: 'starred', label: 'Starred', icon: 'star' },
|
|
130
|
+
];
|
|
131
|
+
render(<TabBar tabs={tabs} />);
|
|
132
|
+
const tab = screen.getByTestId('view-tab-starred');
|
|
133
|
+
// Lucide icons render as SVG elements
|
|
134
|
+
const svg = tab.querySelector('svg');
|
|
135
|
+
expect(svg).toBeTruthy();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should handle kebab-case icon names', () => {
|
|
139
|
+
const tabs: ViewTab[] = [
|
|
140
|
+
{ name: 'test', label: 'Test', icon: 'arrow-right' },
|
|
141
|
+
];
|
|
142
|
+
render(<TabBar tabs={tabs} />);
|
|
143
|
+
const tab = screen.getByTestId('view-tab-test');
|
|
144
|
+
const svg = tab.querySelector('svg');
|
|
145
|
+
expect(svg).toBeTruthy();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should not render icon when icon is not provided', () => {
|
|
149
|
+
const tabs: ViewTab[] = [
|
|
150
|
+
{ name: 'noicon', label: 'No Icon' },
|
|
151
|
+
];
|
|
152
|
+
render(<TabBar tabs={tabs} />);
|
|
153
|
+
const tab = screen.getByTestId('view-tab-noicon');
|
|
154
|
+
const svg = tab.querySelector('svg');
|
|
155
|
+
expect(svg).toBeFalsy();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Controlled activeTab
|
|
159
|
+
it('should respect controlled activeTab prop', () => {
|
|
160
|
+
render(<TabBar tabs={baseTabs} activeTab="archived" />);
|
|
161
|
+
expect(screen.getByTestId('view-tab-archived')).toHaveAttribute('aria-selected', 'true');
|
|
162
|
+
expect(screen.getByTestId('view-tab-all')).toHaveAttribute('aria-selected', 'false');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Tab role attributes
|
|
166
|
+
it('should have proper ARIA attributes', () => {
|
|
167
|
+
render(<TabBar tabs={baseTabs} />);
|
|
168
|
+
const tabBar = screen.getByTestId('view-tabs');
|
|
169
|
+
expect(tabBar).toHaveAttribute('role', 'tablist');
|
|
170
|
+
const tab = screen.getByTestId('view-tab-all');
|
|
171
|
+
expect(tab).toHaveAttribute('role', 'tab');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Tab with filter config
|
|
175
|
+
it('should pass tab with filter to onTabChange', () => {
|
|
176
|
+
const onTabChange = vi.fn();
|
|
177
|
+
const tabs: ViewTab[] = [
|
|
178
|
+
{ name: 'all', label: 'All', isDefault: true },
|
|
179
|
+
{
|
|
180
|
+
name: 'active',
|
|
181
|
+
label: 'Active',
|
|
182
|
+
filter: { logic: 'and', conditions: [{ field: 'status', operator: 'eq', value: 'active' }] },
|
|
183
|
+
},
|
|
184
|
+
];
|
|
185
|
+
render(<TabBar tabs={tabs} onTabChange={onTabChange} />);
|
|
186
|
+
fireEvent.click(screen.getByTestId('view-tab-active'));
|
|
187
|
+
expect(onTabChange).toHaveBeenCalledWith(
|
|
188
|
+
expect.objectContaining({
|
|
189
|
+
name: 'active',
|
|
190
|
+
filter: expect.objectContaining({
|
|
191
|
+
logic: 'and',
|
|
192
|
+
conditions: expect.arrayContaining([
|
|
193
|
+
expect.objectContaining({ field: 'status' }),
|
|
194
|
+
]),
|
|
195
|
+
}),
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
});
|