@object-ui/plugin-list 3.1.5 → 3.3.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/CHANGELOG.md +34 -0
- package/README.md +21 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +30492 -38346
- package/dist/index.umd.cjs +30 -38
- package/dist/{src → packages/plugin-list/src}/ListView.d.ts +17 -1
- package/dist/packages/plugin-list/src/ListView.d.ts.map +1 -0
- package/dist/packages/plugin-list/src/ListView.stories.d.ts.map +1 -0
- package/dist/packages/plugin-list/src/ObjectGallery.d.ts.map +1 -0
- package/dist/packages/plugin-list/src/UserFilters.d.ts.map +1 -0
- package/dist/packages/plugin-list/src/ViewSwitcher.d.ts.map +1 -0
- package/dist/packages/plugin-list/src/components/TabBar.d.ts.map +1 -0
- package/dist/{src → packages/plugin-list/src}/index.d.ts +1 -1
- package/dist/packages/plugin-list/src/index.d.ts.map +1 -0
- package/dist/plugin-list.css +1 -2
- package/package.json +35 -13
- package/.turbo/turbo-build.log +0 -24
- package/dist/src/ListView.d.ts.map +0 -1
- package/dist/src/ListView.stories.d.ts.map +0 -1
- package/dist/src/ObjectGallery.d.ts.map +0 -1
- package/dist/src/UserFilters.d.ts.map +0 -1
- package/dist/src/ViewSwitcher.d.ts.map +0 -1
- package/dist/src/components/TabBar.d.ts.map +0 -1
- package/dist/src/index.d.ts.map +0 -1
- package/src/ListView.stories.tsx +0 -64
- package/src/ListView.tsx +0 -1688
- package/src/ObjectGallery.tsx +0 -308
- package/src/UserFilters.tsx +0 -453
- package/src/ViewSwitcher.tsx +0 -113
- package/src/__tests__/ConditionalFormatting.test.ts +0 -285
- package/src/__tests__/DataFetch.test.tsx +0 -253
- package/src/__tests__/Export.test.tsx +0 -175
- package/src/__tests__/FilterNormalization.test.ts +0 -162
- package/src/__tests__/GalleryGrouping.test.tsx +0 -237
- package/src/__tests__/GalleryTimelineSpecConfig.test.tsx +0 -203
- package/src/__tests__/ListView.test.tsx +0 -2151
- package/src/__tests__/ListViewGroupingPropagation.test.tsx +0 -250
- package/src/__tests__/ListViewPersistence.test.tsx +0 -129
- package/src/__tests__/ObjectGallery.test.tsx +0 -208
- package/src/__tests__/TabBar.test.tsx +0 -199
- package/src/__tests__/UserFilters.test.tsx +0 -486
- package/src/components/TabBar.tsx +0 -120
- package/src/index.tsx +0 -78
- package/tsconfig.json +0 -18
- package/vite.config.ts +0 -56
- package/vitest.config.ts +0 -12
- package/vitest.setup.ts +0 -1
- /package/dist/{src → packages/plugin-list/src}/ListView.stories.d.ts +0 -0
- /package/dist/{src → packages/plugin-list/src}/ObjectGallery.d.ts +0 -0
- /package/dist/{src → packages/plugin-list/src}/UserFilters.d.ts +0 -0
- /package/dist/{src → packages/plugin-list/src}/ViewSwitcher.d.ts +0 -0
- /package/dist/{src → packages/plugin-list/src}/components/TabBar.d.ts +0 -0
|
@@ -1,175 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,162 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,237 +0,0 @@
|
|
|
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
|
-
});
|