@kronor/dtv 0.2.9

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.
Files changed (97) hide show
  1. package/.editorconfig +12 -0
  2. package/.github/copilot-instructions.md +64 -0
  3. package/.github/workflows/ci.yml +51 -0
  4. package/.husky/pre-commit +8 -0
  5. package/README.md +63 -0
  6. package/docs/api/README.md +32 -0
  7. package/docs/api/cell-renderers.md +121 -0
  8. package/docs/api/no-rows-component.md +71 -0
  9. package/docs/api/runtime.md +78 -0
  10. package/e2e/app.spec.ts +6 -0
  11. package/e2e/cell-renderer-setfilterstate.spec.ts +63 -0
  12. package/e2e/filter-sharing.spec.ts +113 -0
  13. package/e2e/filter-url-persistence.spec.ts +36 -0
  14. package/e2e/graphqlMock.ts +144 -0
  15. package/e2e/multi-field-filters.spec.ts +95 -0
  16. package/e2e/pagination.spec.ts +38 -0
  17. package/e2e/payment-request-email-filter.spec.ts +67 -0
  18. package/e2e/save-filter-splitbutton.spec.ts +68 -0
  19. package/e2e/simple-view-email-filter.spec.ts +67 -0
  20. package/e2e/simple-view-transforms.spec.ts +171 -0
  21. package/e2e/simple-view.spec.ts +104 -0
  22. package/e2e/transform-regression.spec.ts +108 -0
  23. package/eslint.config.js +30 -0
  24. package/index.html +17 -0
  25. package/jest.config.js +10 -0
  26. package/package.json +45 -0
  27. package/playwright.config.ts +54 -0
  28. package/public/vite.svg +1 -0
  29. package/src/App.externalRuntime.test.ts +190 -0
  30. package/src/App.tsx +540 -0
  31. package/src/assets/react.svg +1 -0
  32. package/src/components/AIAssistantForm.tsx +241 -0
  33. package/src/components/FilterForm.test.ts +82 -0
  34. package/src/components/FilterForm.tsx +375 -0
  35. package/src/components/PhoneNumberFilter.tsx +102 -0
  36. package/src/components/SavedFilterList.tsx +181 -0
  37. package/src/components/SpeechInput.tsx +67 -0
  38. package/src/components/Table.tsx +119 -0
  39. package/src/components/TablePagination.tsx +40 -0
  40. package/src/components/aiAssistant.test.ts +270 -0
  41. package/src/components/aiAssistant.ts +291 -0
  42. package/src/framework/cell-renderer-components/CurrencyAmount.tsx +30 -0
  43. package/src/framework/cell-renderer-components/LayoutHelpers.tsx +74 -0
  44. package/src/framework/cell-renderer-components/Link.tsx +28 -0
  45. package/src/framework/cell-renderer-components/Mapping.tsx +11 -0
  46. package/src/framework/cell-renderer-components.test.ts +353 -0
  47. package/src/framework/column-definition.tsx +85 -0
  48. package/src/framework/currency.test.ts +46 -0
  49. package/src/framework/currency.ts +62 -0
  50. package/src/framework/data.staticConditions.test.ts +46 -0
  51. package/src/framework/data.test.ts +167 -0
  52. package/src/framework/data.ts +162 -0
  53. package/src/framework/filter-form-state.test.ts +189 -0
  54. package/src/framework/filter-form-state.ts +185 -0
  55. package/src/framework/filter-sharing.test.ts +135 -0
  56. package/src/framework/filter-sharing.ts +118 -0
  57. package/src/framework/filters.ts +194 -0
  58. package/src/framework/graphql.buildHasuraConditions.test.ts +473 -0
  59. package/src/framework/graphql.paginationKey.test.ts +29 -0
  60. package/src/framework/graphql.test.ts +286 -0
  61. package/src/framework/graphql.ts +462 -0
  62. package/src/framework/native-runtime/index.tsx +33 -0
  63. package/src/framework/native-runtime/nativeComponents.test.ts +108 -0
  64. package/src/framework/runtime-reference.test.ts +172 -0
  65. package/src/framework/runtime.ts +15 -0
  66. package/src/framework/saved-filters.test.ts +422 -0
  67. package/src/framework/saved-filters.ts +293 -0
  68. package/src/framework/state.test.ts +86 -0
  69. package/src/framework/state.ts +148 -0
  70. package/src/framework/transform.test.ts +51 -0
  71. package/src/framework/view-parser-initialvalues.test.ts +228 -0
  72. package/src/framework/view-parser.ts +714 -0
  73. package/src/framework/view.test.ts +1805 -0
  74. package/src/framework/view.ts +38 -0
  75. package/src/index.css +6 -0
  76. package/src/main.tsx +99 -0
  77. package/src/views/index.ts +12 -0
  78. package/src/views/payment-requests/components/NoRowsExtendDateRange.tsx +37 -0
  79. package/src/views/payment-requests/components/PaymentMethod.tsx +184 -0
  80. package/src/views/payment-requests/components/PaymentStatusTag.tsx +61 -0
  81. package/src/views/payment-requests/index.ts +1 -0
  82. package/src/views/payment-requests/runtime.tsx +145 -0
  83. package/src/views/payment-requests/view.json +692 -0
  84. package/src/views/payment-requests-initial-values.test.ts +73 -0
  85. package/src/views/request-log/index.ts +2 -0
  86. package/src/views/request-log/runtime.tsx +47 -0
  87. package/src/views/request-log/view.json +123 -0
  88. package/src/views/simple-test-view/index.ts +3 -0
  89. package/src/views/simple-test-view/runtime.tsx +85 -0
  90. package/src/views/simple-test-view/view.json +191 -0
  91. package/src/vite-env.d.ts +1 -0
  92. package/tailwind.config.js +7 -0
  93. package/tsconfig.app.json +26 -0
  94. package/tsconfig.jest.json +6 -0
  95. package/tsconfig.json +7 -0
  96. package/tsconfig.node.json +24 -0
  97. package/vite.config.ts +11 -0
@@ -0,0 +1,172 @@
1
+ import { parseRuntimeReference, parseColumnDefinitionJson } from './view-parser';
2
+ import { Runtime } from './runtime';
3
+
4
+ describe('RuntimeReference', () => {
5
+ describe('parseRuntimeReference', () => {
6
+ it('should parse valid RuntimeReference object', () => {
7
+ const json = {
8
+ section: 'cellRenderers',
9
+ key: 'myRenderer'
10
+ };
11
+
12
+ const result = parseRuntimeReference(json);
13
+ expect(result).toEqual({
14
+ section: 'cellRenderers',
15
+ key: 'myRenderer'
16
+ });
17
+ });
18
+
19
+ it('should validate section values', () => {
20
+ const json = {
21
+ section: 'invalidSection',
22
+ key: 'myRenderer'
23
+ };
24
+
25
+ expect(() => {
26
+ parseRuntimeReference(json);
27
+ }).toThrow('Invalid RuntimeReference: "section" must be one of: cellRenderers, noRowsComponents, customFilterComponents, queryTransforms, initialValues');
28
+ });
29
+
30
+ it('should validate that section is a string', () => {
31
+ const json = {
32
+ section: 123,
33
+ key: 'myRenderer'
34
+ };
35
+
36
+ expect(() => {
37
+ parseRuntimeReference(json);
38
+ }).toThrow('Invalid RuntimeReference: "section" must be a string');
39
+ });
40
+
41
+ it('should validate that key is a string', () => {
42
+ const json = {
43
+ section: 'cellRenderers',
44
+ key: 123
45
+ };
46
+
47
+ expect(() => {
48
+ parseRuntimeReference(json);
49
+ }).toThrow('Invalid RuntimeReference: "key" must be a string');
50
+ });
51
+
52
+ it('should require section field', () => {
53
+ const json = {
54
+ key: 'myRenderer'
55
+ };
56
+
57
+ expect(() => {
58
+ parseRuntimeReference(json);
59
+ }).toThrow('Invalid RuntimeReference: "section" must be a string');
60
+ });
61
+
62
+ it('should require key field', () => {
63
+ const json = {
64
+ section: 'cellRenderers'
65
+ };
66
+
67
+ expect(() => {
68
+ parseRuntimeReference(json);
69
+ }).toThrow('Invalid RuntimeReference: "key" must be a string');
70
+ });
71
+ });
72
+
73
+ describe('ColumnDefinitionJson with RuntimeReference', () => {
74
+ const testRuntime: Runtime = {
75
+ cellRenderers: {
76
+ myRenderer: () => 'test',
77
+ otherRenderer: () => 'other'
78
+ },
79
+ queryTransforms: {},
80
+ noRowsComponents: {},
81
+ customFilterComponents: {},
82
+ initialValues: {}
83
+ };
84
+
85
+ it('should parse column with RuntimeReference format', () => {
86
+ const json = {
87
+ data: [{ type: 'field', path: 'test' }],
88
+ name: 'Test Column',
89
+ cellRenderer: {
90
+ section: 'cellRenderers',
91
+ key: 'myRenderer'
92
+ }
93
+ };
94
+
95
+ const result = parseColumnDefinitionJson(json, testRuntime, undefined);
96
+ expect(result.cellRenderer.section).toBe('cellRenderers');
97
+ expect(result.cellRenderer.key).toBe('myRenderer');
98
+ });
99
+
100
+ it('should require cellRenderer field', () => {
101
+ const json = {
102
+ data: [{ type: 'field', path: 'test' }],
103
+ name: 'Test Column'
104
+ // Missing cellRenderer
105
+ };
106
+
107
+ expect(() => {
108
+ parseColumnDefinitionJson(json, testRuntime, undefined);
109
+ }).toThrow('Invalid JSON: "cellRenderer" field is required');
110
+ });
111
+
112
+ it('should validate cellRenderer section is cellRenderers', () => {
113
+ const json = {
114
+ data: [{ type: 'field', path: 'test' }],
115
+ name: 'Test Column',
116
+ cellRenderer: {
117
+ section: 'queryTransforms',
118
+ key: 'myRenderer'
119
+ }
120
+ };
121
+
122
+ expect(() => {
123
+ parseColumnDefinitionJson(json, testRuntime, undefined);
124
+ }).toThrow('Invalid cellRenderer: section must be "cellRenderers"');
125
+ });
126
+
127
+ it('should validate cellRenderer reference key exists in runtime', () => {
128
+ const json = {
129
+ data: [{ type: 'field', path: 'test' }],
130
+ name: 'Test Column',
131
+ cellRenderer: {
132
+ section: 'cellRenderers',
133
+ key: 'nonExistentRenderer'
134
+ }
135
+ };
136
+
137
+ expect(() => {
138
+ parseColumnDefinitionJson(json, testRuntime, undefined);
139
+ }).toThrow('Invalid cellRenderer reference: "nonExistentRenderer". Valid keys are: myRenderer, otherRenderer');
140
+ });
141
+ });
142
+
143
+ describe('Custom Filter Component RuntimeReference', () => {
144
+ const testRuntime: Runtime = {
145
+ cellRenderers: {},
146
+ queryTransforms: {},
147
+ noRowsComponents: {},
148
+ customFilterComponents: {
149
+ phoneNumberFilter: () => 'PhoneNumberFilter',
150
+ emailFilter: () => 'EmailFilter'
151
+ },
152
+ initialValues: {}
153
+ };
154
+
155
+ it('should resolve custom filter component with RuntimeReference', () => {
156
+ // This would be tested as part of filter parsing, but we can test the concept
157
+ const runtimeRef = {
158
+ section: 'customFilterComponents' as const,
159
+ key: 'phoneNumberFilter'
160
+ };
161
+
162
+ const parsed = parseRuntimeReference(runtimeRef);
163
+ expect(parsed).toEqual({
164
+ section: 'customFilterComponents',
165
+ key: 'phoneNumberFilter'
166
+ });
167
+
168
+ // Verify the key exists in runtime
169
+ expect(testRuntime.customFilterComponents[parsed.key as keyof typeof testRuntime.customFilterComponents]).toBeDefined();
170
+ });
171
+ });
172
+ });
@@ -0,0 +1,15 @@
1
+ import React from "react";
2
+ import { CellRenderer } from "./column-definition";
3
+ import { NoRowsComponent } from "./view";
4
+ import { TransformResult } from "./filters";
5
+
6
+ // Runtime type definition for individual view runtimes
7
+ export type Runtime = {
8
+ cellRenderers: Record<string, CellRenderer | React.ComponentType<any>>;
9
+ queryTransforms: Record<string, {
10
+ toQuery: (input: any) => TransformResult;
11
+ }>;
12
+ noRowsComponents: Record<string, NoRowsComponent | React.ComponentType<any>>;
13
+ customFilterComponents: Record<string, React.ComponentType<any>>;
14
+ initialValues: Record<string, any>;
15
+ };
@@ -0,0 +1,422 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { createSavedFilterManager, SavedFilter, CURRENT_FORMAT_REVISION } from './saved-filters';
5
+ import { FilterSchemasAndGroups } from './filters';
6
+
7
+ // Mock crypto.randomUUID for consistent testing
8
+ const mockUUID = jest.fn();
9
+ Object.defineProperty(globalThis, 'crypto', {
10
+ value: { randomUUID: mockUUID },
11
+ writable: true
12
+ });
13
+
14
+ describe('SavedFilterManager', () => {
15
+ let manager: ReturnType<typeof createSavedFilterManager>;
16
+ let mockLocalStorage: { [key: string]: string };
17
+
18
+ // Basic test schema for simple tests
19
+ const basicSchema: FilterSchemasAndGroups = {
20
+ groups: [{ name: 'default', label: null }],
21
+ filters: [
22
+ {
23
+ id: 'email-filter',
24
+ label: 'Email Filter',
25
+ expression: {
26
+ type: 'equals',
27
+ field: 'email',
28
+ value: { type: 'text' }
29
+ },
30
+ group: 'default',
31
+ aiGenerated: false
32
+ }
33
+ ]
34
+ };
35
+
36
+ beforeEach(() => {
37
+ // Reset UUID counter
38
+ let uuidCounter = 0;
39
+ mockUUID.mockImplementation(() => `test-uuid-${++uuidCounter}`);
40
+
41
+ // Mock localStorage
42
+ mockLocalStorage = {};
43
+ Object.defineProperty(window, 'localStorage', {
44
+ value: {
45
+ getItem: jest.fn((key: string) => mockLocalStorage[key] || null),
46
+ setItem: jest.fn((key: string, value: string) => {
47
+ mockLocalStorage[key] = value;
48
+ }),
49
+ removeItem: jest.fn((key: string) => {
50
+ delete mockLocalStorage[key];
51
+ })
52
+ },
53
+ writable: true
54
+ });
55
+
56
+ manager = createSavedFilterManager();
57
+ });
58
+
59
+ afterEach(() => {
60
+ jest.clearAllMocks();
61
+ });
62
+
63
+ describe('loadFilters', () => {
64
+ it('should return empty array when no filters are saved', () => {
65
+ const filters = manager.loadFilters('test-view', basicSchema);
66
+ expect(filters).toEqual([]);
67
+ });
68
+
69
+ it('should load saved filters from localStorage', () => {
70
+ const savedData = [
71
+ {
72
+ id: 'filter-1',
73
+ name: 'Test Filter',
74
+ view: 'test-view',
75
+ state: { 'email-filter': { type: 'leaf', field: 'email', value: 'test@example.com', control: { type: 'text' } } },
76
+ createdAt: new Date().toISOString(),
77
+ formatRevision: CURRENT_FORMAT_REVISION
78
+ }
79
+ ];
80
+ mockLocalStorage['dtvSavedFilters'] = JSON.stringify(savedData);
81
+
82
+ const filters = manager.loadFilters('test-view', basicSchema);
83
+ expect(filters).toHaveLength(1);
84
+ expect(filters[0].name).toBe('Test Filter');
85
+ expect(filters[0].state).toBeInstanceOf(Map);
86
+ });
87
+
88
+ it('should filter saved filters by view', () => {
89
+ const savedData = [
90
+ {
91
+ id: 'filter-1',
92
+ name: 'Filter for View A',
93
+ view: 'view-a',
94
+ state: {},
95
+ createdAt: new Date().toISOString(),
96
+ formatRevision: CURRENT_FORMAT_REVISION
97
+ },
98
+ {
99
+ id: 'filter-2',
100
+ name: 'Filter for View B',
101
+ view: 'view-b',
102
+ state: {},
103
+ createdAt: new Date().toISOString(),
104
+ formatRevision: CURRENT_FORMAT_REVISION
105
+ }
106
+ ];
107
+ mockLocalStorage['dtvSavedFilters'] = JSON.stringify(savedData);
108
+
109
+ const filtersForViewA = manager.loadFilters('view-a', basicSchema);
110
+ expect(filtersForViewA).toHaveLength(1);
111
+ expect(filtersForViewA[0].name).toBe('Filter for View A');
112
+ });
113
+
114
+ it('should handle invalid localStorage data gracefully', () => {
115
+ // Mock console.error to suppress expected error output in tests
116
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
117
+
118
+ mockLocalStorage['dtvSavedFilters'] = 'invalid json';
119
+ const filters = manager.loadFilters('test-view', basicSchema);
120
+ expect(filters).toEqual([]);
121
+
122
+ // Verify error was logged and restore console
123
+ expect(consoleSpy).toHaveBeenCalledWith('Failed to load saved filters from localStorage:', expect.any(Error));
124
+ consoleSpy.mockRestore();
125
+ });
126
+
127
+ it('should handle non-array localStorage data gracefully', () => {
128
+ // Mock console.error to suppress expected error output in tests
129
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
130
+
131
+ mockLocalStorage['dtvSavedFilters'] = JSON.stringify({ not: 'an array' });
132
+ const filters = manager.loadFilters('test-view', basicSchema);
133
+ expect(filters).toEqual([]);
134
+
135
+ // Restore console
136
+ consoleSpy.mockRestore();
137
+ });
138
+
139
+ it('should migrate old array format filters and overwrite in localStorage', () => {
140
+ // Mock console.info to suppress expected info output in tests
141
+ const consoleSpy = jest.spyOn(console, 'info').mockImplementation(() => { });
142
+
143
+ // Create old format data with OLD_ARRAY_FORMAT_REVISION
144
+ const oldFormatData = [
145
+ {
146
+ id: 'old-filter',
147
+ name: 'Old Format Filter',
148
+ view: 'test-view',
149
+ state: [
150
+ {
151
+ type: 'leaf',
152
+ field: 'email',
153
+ value: 'old@test.com',
154
+ control: { type: 'text' },
155
+ filterType: 'equals'
156
+ }
157
+ ],
158
+ createdAt: new Date().toISOString(),
159
+ formatRevision: '2025-09-04T00:00:00.000Z' // OLD_ARRAY_FORMAT_REVISION
160
+ }
161
+ ];
162
+
163
+ mockLocalStorage['dtvSavedFilters'] = JSON.stringify(oldFormatData);
164
+
165
+ // Load filters - should trigger migration
166
+ const filters = manager.loadFilters('test-view', basicSchema);
167
+
168
+ expect(filters).toHaveLength(1);
169
+ expect(filters[0].name).toBe('Old Format Filter');
170
+ expect(filters[0].state).toBeInstanceOf(Map);
171
+ expect(filters[0].formatRevision).toBe('2025-09-19T00:00:00.000Z'); // CURRENT_FORMAT_REVISION
172
+
173
+ // Check that localStorage was updated with migrated data
174
+ expect(localStorage.setItem).toHaveBeenCalledWith(
175
+ 'dtvSavedFilters',
176
+ expect.stringContaining('"formatRevision":"2025-09-19T00:00:00.000Z"')
177
+ );
178
+
179
+ // Verify the migrated state is no longer an array
180
+ const savedData = JSON.parse(mockLocalStorage['dtvSavedFilters']);
181
+ expect(savedData[0].formatRevision).toBe('2025-09-19T00:00:00.000Z');
182
+ expect(Array.isArray(savedData[0].state)).toBe(false); // Should be object now
183
+
184
+ // Verify info was logged and restore console
185
+ expect(consoleSpy).toHaveBeenCalledWith('Migrated filters from old array format to new object format');
186
+ consoleSpy.mockRestore();
187
+ });
188
+
189
+ it('should migrate filters from legacy localStorage key', () => {
190
+ // Mock console.info to suppress expected info output in tests
191
+ const consoleSpy = jest.spyOn(console, 'info').mockImplementation(() => { });
192
+
193
+ // Create data in legacy key
194
+ const legacyData = [
195
+ {
196
+ id: 'legacy-filter',
197
+ name: 'Legacy Key Filter',
198
+ view: 'test-view',
199
+ state: [
200
+ {
201
+ type: 'leaf',
202
+ field: 'email',
203
+ value: 'legacy@test.com',
204
+ control: { type: 'text' },
205
+ filterType: 'equals'
206
+ }
207
+ ],
208
+ createdAt: new Date().toISOString(),
209
+ formatRevision: '2025-09-04T00:00:00.000Z' // OLD_ARRAY_FORMAT_REVISION
210
+ }
211
+ ];
212
+
213
+ // Put data only in legacy key
214
+ mockLocalStorage['savedFilters'] = JSON.stringify(legacyData);
215
+
216
+ // Load filters - should trigger migration from legacy key
217
+ const filters = manager.loadFilters('test-view', basicSchema);
218
+
219
+ expect(filters).toHaveLength(1);
220
+ expect(filters[0].name).toBe('Legacy Key Filter');
221
+ expect(filters[0].state).toBeInstanceOf(Map);
222
+ expect(filters[0].formatRevision).toBe('2025-09-19T00:00:00.000Z'); // CURRENT_FORMAT_REVISION
223
+
224
+ // Check that data was moved to new key
225
+ expect(mockLocalStorage['dtvSavedFilters']).toBeDefined();
226
+ expect(mockLocalStorage['savedFilters']).toBeUndefined(); // Should be removed
227
+
228
+ // Verify the migrated data
229
+ const savedData = JSON.parse(mockLocalStorage['dtvSavedFilters']);
230
+ expect(savedData[0].formatRevision).toBe('2025-09-19T00:00:00.000Z');
231
+ expect(Array.isArray(savedData[0].state)).toBe(false); // Should be object now
232
+
233
+ // Verify info messages were logged and restore console
234
+ expect(consoleSpy).toHaveBeenCalledWith('Found saved filters in legacy localStorage key, migrating...');
235
+ expect(consoleSpy).toHaveBeenCalledWith("Migrated 1 filters from legacy localStorage key 'savedFilters' to 'dtvSavedFilters'");
236
+ expect(consoleSpy).toHaveBeenCalledWith('Migrated filters from old array format to new object format');
237
+ consoleSpy.mockRestore();
238
+ });
239
+
240
+ it('should handle invalid state format during array conversion gracefully', () => {
241
+ // Mock console.warn and console.info to suppress expected output in tests
242
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
243
+ const consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation(() => { });
244
+
245
+ // Create old format data with invalid state (not an array)
246
+ const invalidFormatData = [
247
+ {
248
+ id: 'invalid-state-filter',
249
+ name: 'Invalid State Filter',
250
+ view: 'test-view',
251
+ state: 'not-an-array', // This should trigger console.warn
252
+ createdAt: new Date().toISOString(),
253
+ formatRevision: '2025-09-04T00:00:00.000Z' // OLD_ARRAY_FORMAT_REVISION
254
+ }
255
+ ];
256
+
257
+ mockLocalStorage['dtvSavedFilters'] = JSON.stringify(invalidFormatData);
258
+
259
+ // Load filters - should trigger warning about invalid state format
260
+ const filters = manager.loadFilters('test-view', basicSchema);
261
+
262
+ // Should still return the filter; state now contains default leaf entries for schema-defined filters
263
+ expect(filters).toHaveLength(1);
264
+ expect(filters[0].name).toBe('Invalid State Filter');
265
+ expect(filters[0].state).toBeInstanceOf(Map);
266
+ // basicSchema has 1 filter (email-filter)
267
+ expect(filters[0].state.size).toBe(1);
268
+
269
+ // Verify warning was logged and restore console
270
+ expect(consoleWarnSpy).toHaveBeenCalledWith('Expected array for conversion but got:', 'string');
271
+ consoleWarnSpy.mockRestore();
272
+ consoleInfoSpy.mockRestore();
273
+ });
274
+ });
275
+
276
+ describe('saveFilter', () => {
277
+ it('should save filter to localStorage', () => {
278
+ const filterState = new Map([
279
+ ['email-filter', {
280
+ type: 'leaf' as const,
281
+ field: 'email',
282
+ value: 'test@example.com',
283
+ control: { type: 'text' as const },
284
+ filterType: 'equals' as const
285
+ }]
286
+ ]);
287
+
288
+ const filterToSave = {
289
+ name: 'Test Filter',
290
+ view: 'test-view',
291
+ state: filterState
292
+ };
293
+
294
+ const savedFilter = manager.saveFilter(filterToSave);
295
+
296
+ expect(savedFilter.id).toBe('test-uuid-1');
297
+ expect(savedFilter.name).toBe('Test Filter');
298
+ expect(savedFilter.state).toBeInstanceOf(Map);
299
+ expect(savedFilter.createdAt).toBeInstanceOf(Date);
300
+ expect(savedFilter.formatRevision).toBe(CURRENT_FORMAT_REVISION);
301
+
302
+ // Check localStorage was called
303
+ expect(localStorage.setItem).toHaveBeenCalledWith(
304
+ 'dtvSavedFilters',
305
+ expect.stringContaining('Test Filter')
306
+ );
307
+ });
308
+
309
+ it('should save multiple filters', () => {
310
+ const filterState = new Map([
311
+ ['email-filter', {
312
+ type: 'leaf' as const,
313
+ field: 'email',
314
+ value: 'test@example.com',
315
+ control: { type: 'text' as const },
316
+ filterType: 'equals' as const
317
+ }]
318
+ ]);
319
+
320
+ const filter1 = { name: 'Filter 1', view: 'test-view', state: filterState };
321
+ const filter2 = { name: 'Filter 2', view: 'test-view', state: filterState };
322
+
323
+ manager.saveFilter(filter1);
324
+ manager.saveFilter(filter2);
325
+
326
+ const loadedFilters = manager.loadFilters('test-view', basicSchema);
327
+ expect(loadedFilters).toHaveLength(2);
328
+ });
329
+ });
330
+
331
+ describe('updateFilter', () => {
332
+ it('should update existing filter', () => {
333
+ const filterState = new Map([
334
+ ['email-filter', {
335
+ type: 'leaf' as const,
336
+ field: 'email',
337
+ value: 'test@example.com',
338
+ control: { type: 'text' as const },
339
+ filterType: 'equals' as const
340
+ }]
341
+ ]);
342
+
343
+ const existingFilter: SavedFilter = {
344
+ id: 'existing-id',
345
+ name: 'Old Name',
346
+ view: 'test-view',
347
+ state: filterState,
348
+ createdAt: new Date(),
349
+ formatRevision: CURRENT_FORMAT_REVISION
350
+ };
351
+
352
+ // Manually add to localStorage to simulate existing filter
353
+ mockLocalStorage['dtvSavedFilters'] = JSON.stringify([{
354
+ id: 'existing-id',
355
+ name: 'Old Name',
356
+ view: 'test-view',
357
+ state: { 'email-filter': { type: 'leaf', field: 'email', value: 'test@example.com', control: { type: 'text' } } },
358
+ createdAt: new Date().toISOString(),
359
+ formatRevision: CURRENT_FORMAT_REVISION
360
+ }]);
361
+
362
+ const updates = { name: 'Updated Name' };
363
+ const updatedFilter = manager.updateFilter(existingFilter, updates);
364
+
365
+ expect(updatedFilter).not.toBeNull();
366
+ expect(updatedFilter!.name).toBe('Updated Name');
367
+ expect(updatedFilter!.id).toBe('existing-id');
368
+ });
369
+
370
+ it('should return null for non-existent filter', () => {
371
+ const filterState = new Map([
372
+ ['email-filter', {
373
+ type: 'leaf' as const,
374
+ field: 'email',
375
+ value: 'test@example.com',
376
+ control: { type: 'text' as const },
377
+ filterType: 'equals' as const
378
+ }]
379
+ ]);
380
+
381
+ const nonExistentFilter: SavedFilter = {
382
+ id: 'non-existent',
383
+ name: 'Non-existent',
384
+ view: 'test-view',
385
+ state: filterState,
386
+ createdAt: new Date(),
387
+ formatRevision: CURRENT_FORMAT_REVISION
388
+ };
389
+
390
+ const result = manager.updateFilter(nonExistentFilter, { name: 'New Name' });
391
+ expect(result).toBeNull();
392
+ });
393
+ });
394
+
395
+ describe('deleteFilter', () => {
396
+ it('should delete existing filter', () => {
397
+ const savedData = [
398
+ {
399
+ id: 'filter-to-delete',
400
+ name: 'Filter to Delete',
401
+ view: 'test-view',
402
+ state: {},
403
+ createdAt: new Date().toISOString(),
404
+ formatRevision: CURRENT_FORMAT_REVISION
405
+ }
406
+ ];
407
+ mockLocalStorage['dtvSavedFilters'] = JSON.stringify(savedData);
408
+
409
+ const result = manager.deleteFilter('filter-to-delete');
410
+ expect(result).toBe(true);
411
+
412
+ // Verify the filter was removed
413
+ const remainingFilters = manager.loadFilters('test-view', basicSchema);
414
+ expect(remainingFilters).toHaveLength(0);
415
+ });
416
+
417
+ it('should return false for non-existent filter', () => {
418
+ const result = manager.deleteFilter('non-existent-id');
419
+ expect(result).toBe(false);
420
+ });
421
+ });
422
+ });