argent-grid 0.2.0 → 0.3.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/AGENTS.md +70 -27
- package/e2e/advanced.spec.ts +1 -1
- package/e2e/benchmark.spec.ts +7 -7
- package/e2e/cell-renderers.spec.ts +152 -0
- package/e2e/debug-streaming.spec.ts +31 -0
- package/e2e/dnd.spec.ts +73 -0
- package/e2e/screenshots.spec.ts +1 -1
- package/e2e/visual.spec.ts +30 -9
- package/e2e/visual.spec.ts-snapshots/checkbox-renderer-mixed.png +0 -0
- package/e2e/visual.spec.ts-snapshots/debug.png +0 -0
- package/e2e/visual.spec.ts-snapshots/grid-column-group-headers.png +0 -0
- package/e2e/visual.spec.ts-snapshots/grid-default.png +0 -0
- package/e2e/visual.spec.ts-snapshots/grid-empty-state.png +0 -0
- package/e2e/visual.spec.ts-snapshots/grid-filter-popup.png +0 -0
- package/e2e/visual.spec.ts-snapshots/grid-scroll-borders.png +0 -0
- package/e2e/visual.spec.ts-snapshots/grid-sidebar-buttons.png +0 -0
- package/e2e/visual.spec.ts-snapshots/grid-text-filter.png +0 -0
- package/e2e/visual.spec.ts-snapshots/grid-with-selection.png +0 -0
- package/e2e/visual.spec.ts-snapshots/rating-renderer-varied.png +0 -0
- package/package.json +5 -5
- package/plan.md +30 -34
- package/src/lib/components/argent-grid.component.css +258 -549
- package/src/lib/components/argent-grid.component.html +272 -306
- package/src/lib/components/argent-grid.component.ts +585 -135
- package/src/lib/components/argent-grid.regressions.spec.ts +301 -0
- package/src/lib/components/argent-grid.selection.spec.ts +2 -2
- package/src/lib/components/set-filter/set-filter.component.spec.ts +191 -0
- package/src/lib/components/set-filter/set-filter.component.ts +7 -2
- package/src/lib/rendering/canvas-renderer.spec.ts +148 -1
- package/src/lib/rendering/canvas-renderer.ts +177 -286
- package/src/lib/rendering/render/cells.ts +122 -5
- package/src/lib/rendering/render/column-utils.ts +27 -5
- package/src/lib/rendering/render/hit-test.ts +6 -11
- package/src/lib/rendering/render/index.ts +15 -6
- package/src/lib/rendering/render/lines.ts +12 -6
- package/src/lib/rendering/render/primitives.ts +269 -7
- package/src/lib/rendering/render/types.ts +2 -1
- package/src/lib/rendering/render/walk.ts +39 -19
- package/src/lib/services/grid.service.spec.ts +76 -0
- package/src/lib/services/grid.service.ts +451 -114
- package/src/lib/themes/theme-quartz.ts +2 -2
- package/src/lib/types/ag-grid-types.ts +500 -0
- package/src/stories/Advanced.stories.ts +78 -17
- package/src/stories/ArgentGrid.stories.ts +50 -26
- package/src/stories/Benchmark.stories.ts +17 -15
- package/src/stories/CellRenderers.stories.ts +205 -31
- package/src/stories/Filtering.stories.ts +56 -16
- package/src/stories/Grouping.stories.ts +86 -13
- package/src/stories/Streaming.stories.ts +57 -0
- package/src/stories/Theming.stories.ts +23 -10
- package/src/stories/Tooltips.stories.ts +381 -0
- package/src/stories/benchmark-wrapper.component.ts +69 -29
- package/src/stories/story-utils.ts +88 -0
- package/src/stories/streaming-wrapper.component.ts +441 -0
- package/tsconfig.json +1 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { ArgentGridComponent } from './argent-grid.component';
|
|
3
|
+
|
|
4
|
+
describe('ArgentGridComponent - Regression Protection', () => {
|
|
5
|
+
let component: ArgentGridComponent<any>;
|
|
6
|
+
let mockCdr: any;
|
|
7
|
+
let mockElementRef: any;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
// Mock canvas context
|
|
11
|
+
HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue({
|
|
12
|
+
measureText: vi.fn().mockReturnValue({ width: 50 }),
|
|
13
|
+
} as any);
|
|
14
|
+
|
|
15
|
+
mockCdr = {
|
|
16
|
+
detectChanges: vi.fn(),
|
|
17
|
+
markForCheck: vi.fn(),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
mockElementRef = {
|
|
21
|
+
nativeElement: {
|
|
22
|
+
getBoundingClientRect: () => ({ left: 100, top: 100, right: 1100, bottom: 900 }),
|
|
23
|
+
offsetWidth: 1000,
|
|
24
|
+
offsetHeight: 800,
|
|
25
|
+
style: {},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
component = new ArgentGridComponent(mockCdr, mockElementRef);
|
|
30
|
+
|
|
31
|
+
// Mock canvasRenderer
|
|
32
|
+
(component as any).canvasRenderer = {
|
|
33
|
+
getHitTestResult: vi.fn().mockReturnValue({ rowIndex: 0, columnIndex: 0 }),
|
|
34
|
+
render: vi.fn(),
|
|
35
|
+
renderFrame: vi.fn(),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
component.columnDefs = [{ field: 'id', headerName: 'ID' }];
|
|
39
|
+
component.rowData = [{ id: 1 }];
|
|
40
|
+
component.ngOnInit();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should hide row group panel by default', () => {
|
|
44
|
+
expect(component.isRowGroupPanelVisible()).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should show row group panel when always is set', () => {
|
|
48
|
+
component.gridOptions = { rowGroupPanelShow: 'always' };
|
|
49
|
+
// Simulate option change
|
|
50
|
+
(component as any).onGridOptionsChanged(component.gridOptions);
|
|
51
|
+
expect(component.isRowGroupPanelVisible()).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should position header menu relative to container', () => {
|
|
55
|
+
const mockEvent = {
|
|
56
|
+
stopPropagation: vi.fn(),
|
|
57
|
+
target: {
|
|
58
|
+
getBoundingClientRect: () => ({ left: 200, top: 120, right: 220, bottom: 140 }),
|
|
59
|
+
},
|
|
60
|
+
} as any;
|
|
61
|
+
|
|
62
|
+
const col = component.getApi().getAllColumns()[0];
|
|
63
|
+
component.onHeaderMenuClick(mockEvent, col);
|
|
64
|
+
|
|
65
|
+
expect(component.activeHeaderMenu).toBe(col);
|
|
66
|
+
// Container is at 100, 100. Icon is at 200, 120.
|
|
67
|
+
// Relative X = IconLeft - ContainerLeft = 200 - 100 = 100
|
|
68
|
+
// Relative Y = IconBottom - ContainerTop + 4 = 140 - 100 + 4 = 44
|
|
69
|
+
expect(component.headerMenuPosition.x).toBe(100);
|
|
70
|
+
expect(component.headerMenuPosition.y).toBe(44);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should position context menu relative to container', () => {
|
|
74
|
+
const mockEvent = {
|
|
75
|
+
preventDefault: vi.fn(),
|
|
76
|
+
stopPropagation: vi.fn(),
|
|
77
|
+
clientX: 250,
|
|
78
|
+
clientY: 300,
|
|
79
|
+
} as any;
|
|
80
|
+
|
|
81
|
+
// Mock row node finding logic
|
|
82
|
+
const rowNode = { id: '1', data: { id: 1 }, selected: false, setSelected: vi.fn() };
|
|
83
|
+
vi.spyOn(component.getApi(), 'getDisplayedRowAtIndex').mockReturnValue(rowNode as any);
|
|
84
|
+
|
|
85
|
+
component.onCanvasContextMenu(mockEvent);
|
|
86
|
+
|
|
87
|
+
// Relative X = clientX - ContainerLeft = 250 - 100 = 150
|
|
88
|
+
// Relative Y = clientY - ContainerTop = 300 - 100 = 200
|
|
89
|
+
expect(component.contextMenuPosition.x).toBe(150);
|
|
90
|
+
expect(component.contextMenuPosition.y).toBe(200);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should update rowGroupColumns when columns change', () => {
|
|
94
|
+
expect(component.rowGroupColumns.length).toBe(0);
|
|
95
|
+
|
|
96
|
+
// Add group column via API
|
|
97
|
+
component.getApi().addRowGroupColumn('id');
|
|
98
|
+
|
|
99
|
+
// The component listens to gridStateChanged$ which calls updateRowGroupColumns
|
|
100
|
+
expect(component.rowGroupColumns.length).toBe(1);
|
|
101
|
+
expect(component.rowGroupColumns[0].colId).toBe('id');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ─── Header Filter Button ──────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
describe('ArgentGridComponent - Header Filter Button', () => {
|
|
108
|
+
let component: ArgentGridComponent<any>;
|
|
109
|
+
|
|
110
|
+
beforeEach(() => {
|
|
111
|
+
HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue({
|
|
112
|
+
measureText: vi.fn().mockReturnValue({ width: 50 }),
|
|
113
|
+
} as any);
|
|
114
|
+
|
|
115
|
+
const mockCdr = { detectChanges: vi.fn(), markForCheck: vi.fn() };
|
|
116
|
+
const mockElementRef = {
|
|
117
|
+
nativeElement: {
|
|
118
|
+
getBoundingClientRect: () => ({ left: 0, top: 0 }),
|
|
119
|
+
offsetWidth: 1000,
|
|
120
|
+
offsetHeight: 800,
|
|
121
|
+
style: {},
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
component = new ArgentGridComponent(mockCdr as any, mockElementRef as any);
|
|
126
|
+
component.columnDefs = [
|
|
127
|
+
{ field: 'name', filter: 'text' },
|
|
128
|
+
{ field: 'dept', filter: 'set' },
|
|
129
|
+
{ field: 'salary', filter: 'number', floatingFilter: true },
|
|
130
|
+
{ field: 'id' }, // no filter
|
|
131
|
+
{ field: 'status', filter: 'text', suppressHeaderFilterButton: true },
|
|
132
|
+
];
|
|
133
|
+
component.rowData = [{ name: 'Alice', dept: 'Eng', salary: 100, id: 1, status: 'active' }];
|
|
134
|
+
component.ngOnInit();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('hasHeaderFilterButton', () => {
|
|
138
|
+
it('should return true for column with filter and no floatingFilter', () => {
|
|
139
|
+
const col = component
|
|
140
|
+
.getApi()
|
|
141
|
+
.getAllColumns()
|
|
142
|
+
.find((c) => c.field === 'name')!;
|
|
143
|
+
expect(component.hasHeaderFilterButton(col)).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should return true for set-filter column', () => {
|
|
147
|
+
const col = component
|
|
148
|
+
.getApi()
|
|
149
|
+
.getAllColumns()
|
|
150
|
+
.find((c) => c.field === 'dept')!;
|
|
151
|
+
expect(component.hasHeaderFilterButton(col)).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should return false when floatingFilter is enabled', () => {
|
|
155
|
+
const col = component
|
|
156
|
+
.getApi()
|
|
157
|
+
.getAllColumns()
|
|
158
|
+
.find((c) => c.field === 'salary')!;
|
|
159
|
+
expect(component.hasHeaderFilterButton(col)).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should return false when no filter configured', () => {
|
|
163
|
+
const col = component
|
|
164
|
+
.getApi()
|
|
165
|
+
.getAllColumns()
|
|
166
|
+
.find((c) => c.field === 'id')!;
|
|
167
|
+
expect(component.hasHeaderFilterButton(col)).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should return false when suppressHeaderFilterButton is true', () => {
|
|
171
|
+
const col = component
|
|
172
|
+
.getApi()
|
|
173
|
+
.getAllColumns()
|
|
174
|
+
.find((c) => c.field === 'status')!;
|
|
175
|
+
expect(component.hasHeaderFilterButton(col)).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should return false for the selection column', () => {
|
|
179
|
+
const fakeSelCol = { colId: 'ag-Grid-SelectionColumn' } as any;
|
|
180
|
+
expect(component.hasHeaderFilterButton(fakeSelCol)).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('isColumnFiltered', () => {
|
|
185
|
+
it('should return false when no filter is applied', () => {
|
|
186
|
+
const col = component
|
|
187
|
+
.getApi()
|
|
188
|
+
.getAllColumns()
|
|
189
|
+
.find((c) => c.field === 'name')!;
|
|
190
|
+
expect(component.isColumnFiltered(col)).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should return true when a filter is active on the column', () => {
|
|
194
|
+
component
|
|
195
|
+
.getApi()
|
|
196
|
+
.setFilterModel({ name: { filterType: 'text', type: 'contains', filter: 'Alice' } });
|
|
197
|
+
const col = component
|
|
198
|
+
.getApi()
|
|
199
|
+
.getAllColumns()
|
|
200
|
+
.find((c) => c.field === 'name')!;
|
|
201
|
+
expect(component.isColumnFiltered(col)).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should return false for a different column when only one filtered', () => {
|
|
205
|
+
component
|
|
206
|
+
.getApi()
|
|
207
|
+
.setFilterModel({ name: { filterType: 'text', type: 'contains', filter: 'Alice' } });
|
|
208
|
+
const col = component
|
|
209
|
+
.getApi()
|
|
210
|
+
.getAllColumns()
|
|
211
|
+
.find((c) => c.field === 'dept')!;
|
|
212
|
+
expect(component.isColumnFiltered(col)).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('openColumnsPanel', () => {
|
|
217
|
+
it('should make sideBarVisible true', () => {
|
|
218
|
+
component.sideBarVisible = false;
|
|
219
|
+
component.openColumnsPanel();
|
|
220
|
+
expect(component.sideBarVisible).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should set activeToolPanel to columns', () => {
|
|
224
|
+
component.activeToolPanel = null;
|
|
225
|
+
component.openColumnsPanel();
|
|
226
|
+
expect(component.activeToolPanel).toBe('columns');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should close any open header menu', () => {
|
|
230
|
+
const col = component.getApi().getAllColumns()[0];
|
|
231
|
+
component.activeHeaderMenu = col;
|
|
232
|
+
component.openColumnsPanel();
|
|
233
|
+
expect(component.activeHeaderMenu).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ─── openSetFilter restores selected values ─────────────────────────────────
|
|
239
|
+
|
|
240
|
+
describe('ArgentGridComponent - openSetFilter restores filter state', () => {
|
|
241
|
+
let component: ArgentGridComponent<any>;
|
|
242
|
+
|
|
243
|
+
beforeEach(() => {
|
|
244
|
+
HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue({
|
|
245
|
+
measureText: vi.fn().mockReturnValue({ width: 50 }),
|
|
246
|
+
} as any);
|
|
247
|
+
|
|
248
|
+
const mockCdr = { detectChanges: vi.fn(), markForCheck: vi.fn() };
|
|
249
|
+
const mockElementRef = {
|
|
250
|
+
nativeElement: {
|
|
251
|
+
getBoundingClientRect: () => ({ left: 0, top: 0 }),
|
|
252
|
+
offsetWidth: 1000,
|
|
253
|
+
offsetHeight: 800,
|
|
254
|
+
style: {},
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
component = new ArgentGridComponent(mockCdr as any, mockElementRef as any);
|
|
259
|
+
component.columnDefs = [{ field: 'dept', filter: 'set' }];
|
|
260
|
+
component.rowData = [{ dept: 'Engineering' }, { dept: 'Sales' }, { dept: 'Marketing' }];
|
|
261
|
+
component.ngOnInit();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('should set setFilterSelectedValues to null when no existing filter', () => {
|
|
265
|
+
const col = component.getApi().getAllColumns()[0];
|
|
266
|
+
component.openSetFilter(null, col, { x: 0, y: 0 });
|
|
267
|
+
expect(component.setFilterSelectedValues).toBeNull();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should restore previously selected values from the filter model', () => {
|
|
271
|
+
component.getApi().setFilterModel({
|
|
272
|
+
dept: { filterType: 'set', values: ['Engineering', 'Sales'] },
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const col = component.getApi().getAllColumns()[0];
|
|
276
|
+
component.openSetFilter(null, col, { x: 0, y: 0 });
|
|
277
|
+
|
|
278
|
+
expect(component.setFilterSelectedValues).toEqual(['Engineering', 'Sales']);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('should set setFilterSelectedValues to null for non-set filter models', () => {
|
|
282
|
+
// Manually set a text filter (wrong type) under the dept colId
|
|
283
|
+
component.getApi().setFilterModel({
|
|
284
|
+
dept: { filterType: 'text', type: 'contains', filter: 'Eng' },
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const col = component.getApi().getAllColumns()[0];
|
|
288
|
+
component.openSetFilter(null, col, { x: 0, y: 0 });
|
|
289
|
+
|
|
290
|
+
expect(component.setFilterSelectedValues).toBeNull();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should populate setFilterValues with all unique values for the column', () => {
|
|
294
|
+
const col = component.getApi().getAllColumns()[0];
|
|
295
|
+
component.openSetFilter(null, col, { x: 0, y: 0 });
|
|
296
|
+
|
|
297
|
+
expect(component.setFilterValues).toContain('Engineering');
|
|
298
|
+
expect(component.setFilterValues).toContain('Sales');
|
|
299
|
+
expect(component.setFilterValues).toContain('Marketing');
|
|
300
|
+
});
|
|
301
|
+
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
|
|
2
2
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
3
|
-
import {
|
|
4
|
-
import { ArgentGridComponent } from './argent-grid.component';
|
|
3
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
5
4
|
import { ArgentGridModule } from '../argent-grid.module';
|
|
6
5
|
import { GridService } from '../services/grid.service';
|
|
6
|
+
import { ArgentGridComponent } from './argent-grid.component';
|
|
7
7
|
|
|
8
8
|
interface TestData {
|
|
9
9
|
id: number;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { SetFilterComponent } from './set-filter.component';
|
|
3
|
+
|
|
4
|
+
describe('SetFilterComponent', () => {
|
|
5
|
+
let component: SetFilterComponent<string>;
|
|
6
|
+
|
|
7
|
+
const values = ['Engineering', 'Sales', 'Marketing', 'Engineering', 'Sales'];
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
component = new SetFilterComponent();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// ── initialSelectedValues ──────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
describe('initialSelectedValues input', () => {
|
|
16
|
+
it('should check all items when initialSelectedValues is null (default)', () => {
|
|
17
|
+
component.values = values;
|
|
18
|
+
component.initialSelectedValues = null;
|
|
19
|
+
component.ngOnInit();
|
|
20
|
+
|
|
21
|
+
expect(component.allValues.every((v) => v.selected)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should pre-check only the provided values when initialSelectedValues is set', () => {
|
|
25
|
+
component.values = values;
|
|
26
|
+
component.initialSelectedValues = ['Engineering'];
|
|
27
|
+
component.ngOnInit();
|
|
28
|
+
|
|
29
|
+
const eng = component.allValues.find((v) => v.value === 'Engineering');
|
|
30
|
+
const sales = component.allValues.find((v) => v.value === 'Sales');
|
|
31
|
+
const mkt = component.allValues.find((v) => v.value === 'Marketing');
|
|
32
|
+
|
|
33
|
+
expect(eng?.selected).toBe(true);
|
|
34
|
+
expect(sales?.selected).toBe(false);
|
|
35
|
+
expect(mkt?.selected).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should populate selectedValues matching initialSelectedValues', () => {
|
|
39
|
+
component.values = values;
|
|
40
|
+
component.initialSelectedValues = ['Sales', 'Marketing'];
|
|
41
|
+
component.ngOnInit();
|
|
42
|
+
|
|
43
|
+
expect(component.selectedValues).toEqual(expect.arrayContaining(['Sales', 'Marketing']));
|
|
44
|
+
expect(component.selectedValues).not.toContain('Engineering');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should handle empty initialSelectedValues (none checked)', () => {
|
|
48
|
+
component.values = values;
|
|
49
|
+
component.initialSelectedValues = [];
|
|
50
|
+
component.ngOnInit();
|
|
51
|
+
|
|
52
|
+
expect(component.allValues.every((v) => !v.selected)).toBe(true);
|
|
53
|
+
expect(component.selectedValues).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should handle initialSelectedValues with values not present in list gracefully', () => {
|
|
57
|
+
component.values = values;
|
|
58
|
+
component.initialSelectedValues = ['NonExistent'];
|
|
59
|
+
component.ngOnInit();
|
|
60
|
+
|
|
61
|
+
expect(component.allValues.every((v) => !v.selected)).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ── resetFilter ────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
describe('resetFilter', () => {
|
|
68
|
+
it('should re-select all items after reset, regardless of initialSelectedValues', () => {
|
|
69
|
+
component.values = values;
|
|
70
|
+
component.initialSelectedValues = ['Engineering'];
|
|
71
|
+
component.ngOnInit();
|
|
72
|
+
|
|
73
|
+
// Confirm only Engineering is selected before reset
|
|
74
|
+
expect(component.allValues.filter((v) => v.selected).length).toBe(1);
|
|
75
|
+
|
|
76
|
+
const emit = vi.fn();
|
|
77
|
+
component.filterChanged.emit = emit;
|
|
78
|
+
component.resetFilter();
|
|
79
|
+
|
|
80
|
+
// After reset, all items should be selected
|
|
81
|
+
expect(component.allValues.every((v) => v.selected)).toBe(true);
|
|
82
|
+
expect(emit).toHaveBeenCalledWith(
|
|
83
|
+
expect.arrayContaining(['Engineering', 'Sales', 'Marketing'])
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should clear search text on reset', () => {
|
|
88
|
+
component.values = values;
|
|
89
|
+
component.ngOnInit();
|
|
90
|
+
component.searchText = 'Eng';
|
|
91
|
+
|
|
92
|
+
component.resetFilter();
|
|
93
|
+
|
|
94
|
+
expect(component.searchText).toBe('');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ── selectAll / clearAll ───────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
describe('selectAll', () => {
|
|
101
|
+
it('should mark all items selected', () => {
|
|
102
|
+
component.values = values;
|
|
103
|
+
component.initialSelectedValues = ['Engineering'];
|
|
104
|
+
component.ngOnInit();
|
|
105
|
+
|
|
106
|
+
component.selectAll();
|
|
107
|
+
|
|
108
|
+
expect(component.allValues.every((v) => v.selected)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('clearAll', () => {
|
|
113
|
+
it('should uncheck all items', () => {
|
|
114
|
+
component.values = values;
|
|
115
|
+
component.ngOnInit();
|
|
116
|
+
|
|
117
|
+
component.clearAll();
|
|
118
|
+
|
|
119
|
+
expect(component.allValues.every((v) => !v.selected)).toBe(true);
|
|
120
|
+
expect(component.selectedValues).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── applyFilter ────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
describe('applyFilter', () => {
|
|
127
|
+
it('should emit only selected values', () => {
|
|
128
|
+
component.values = values;
|
|
129
|
+
component.initialSelectedValues = ['Engineering'];
|
|
130
|
+
component.ngOnInit();
|
|
131
|
+
|
|
132
|
+
const emit = vi.fn();
|
|
133
|
+
component.filterChanged.emit = emit;
|
|
134
|
+
component.applyFilter();
|
|
135
|
+
|
|
136
|
+
expect(emit).toHaveBeenCalledWith(['Engineering']);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ── filteredValues (search) ────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
describe('filteredValues', () => {
|
|
143
|
+
beforeEach(() => {
|
|
144
|
+
component.values = values;
|
|
145
|
+
component.ngOnInit();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should return all values when search is empty', () => {
|
|
149
|
+
expect(component.filteredValues.length).toBe(3); // unique: Eng, Sales, Mkt
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should filter by search text (case-insensitive)', () => {
|
|
153
|
+
component.searchText = 'eng';
|
|
154
|
+
expect(component.filteredValues.length).toBe(1);
|
|
155
|
+
expect(component.filteredValues[0].value).toBe('Engineering');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should return empty when search matches nothing', () => {
|
|
159
|
+
component.searchText = 'zzz';
|
|
160
|
+
expect(component.filteredValues.length).toBe(0);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ── value counts ──────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
describe('value counts', () => {
|
|
167
|
+
it('should count occurrences correctly', () => {
|
|
168
|
+
component.values = values; // Eng x2, Sales x2, Mkt x1
|
|
169
|
+
component.ngOnInit();
|
|
170
|
+
|
|
171
|
+
const eng = component.allValues.find((v) => v.value === 'Engineering');
|
|
172
|
+
const mkt = component.allValues.find((v) => v.value === 'Marketing');
|
|
173
|
+
|
|
174
|
+
expect(eng?.count).toBe(2);
|
|
175
|
+
expect(mkt?.count).toBe(1);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ── valueFormatter ────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
describe('valueFormatter', () => {
|
|
182
|
+
it('should apply formatter to display values', () => {
|
|
183
|
+
component.values = ['eng', 'sales'];
|
|
184
|
+
component.valueFormatter = (v) => v.toUpperCase();
|
|
185
|
+
component.ngOnInit();
|
|
186
|
+
|
|
187
|
+
expect(component.allValues[0].displayValue).toBe('ENG');
|
|
188
|
+
expect(component.allValues[1].displayValue).toBe('SALES');
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -218,6 +218,8 @@ import {
|
|
|
218
218
|
export class SetFilterComponent<T = any> implements OnInit {
|
|
219
219
|
@Input() values: T[] = [];
|
|
220
220
|
@Input() valueFormatter?: (value: T) => string;
|
|
221
|
+
/** When provided, only these values will be pre-checked (restores existing filter state) */
|
|
222
|
+
@Input() initialSelectedValues: T[] | null = null;
|
|
221
223
|
@Output() filterChanged = new EventEmitter<T[]>();
|
|
222
224
|
|
|
223
225
|
searchText = '';
|
|
@@ -248,14 +250,16 @@ export class SetFilterComponent<T = any> implements OnInit {
|
|
|
248
250
|
});
|
|
249
251
|
|
|
250
252
|
// Build value list with counts
|
|
253
|
+
// If initialSelectedValues provided, pre-check only those; otherwise check all
|
|
254
|
+
const preSelected = this.initialSelectedValues;
|
|
251
255
|
this.allValues = Array.from(valueCounts.entries()).map(([value, count]) => ({
|
|
252
256
|
value,
|
|
253
257
|
displayValue: this.valueFormatter ? this.valueFormatter(value) : String(value),
|
|
254
258
|
count,
|
|
255
|
-
selected:
|
|
259
|
+
selected: preSelected ? preSelected.includes(value) : true,
|
|
256
260
|
}));
|
|
257
261
|
|
|
258
|
-
this.selectedValues = this.
|
|
262
|
+
this.selectedValues = this.allValues.filter((v) => v.selected).map((v) => v.value);
|
|
259
263
|
}
|
|
260
264
|
|
|
261
265
|
onSearchChanged(): void {
|
|
@@ -296,6 +300,7 @@ export class SetFilterComponent<T = any> implements OnInit {
|
|
|
296
300
|
|
|
297
301
|
resetFilter(): void {
|
|
298
302
|
this.searchText = '';
|
|
303
|
+
this.initialSelectedValues = null; // Reset to all-selected state
|
|
299
304
|
this.initializeValues();
|
|
300
305
|
this.filterChanged.emit(this.selectedValues);
|
|
301
306
|
}
|