@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.
- package/.editorconfig +12 -0
- package/.github/copilot-instructions.md +64 -0
- package/.github/workflows/ci.yml +51 -0
- package/.husky/pre-commit +8 -0
- package/README.md +63 -0
- package/docs/api/README.md +32 -0
- package/docs/api/cell-renderers.md +121 -0
- package/docs/api/no-rows-component.md +71 -0
- package/docs/api/runtime.md +78 -0
- package/e2e/app.spec.ts +6 -0
- package/e2e/cell-renderer-setfilterstate.spec.ts +63 -0
- package/e2e/filter-sharing.spec.ts +113 -0
- package/e2e/filter-url-persistence.spec.ts +36 -0
- package/e2e/graphqlMock.ts +144 -0
- package/e2e/multi-field-filters.spec.ts +95 -0
- package/e2e/pagination.spec.ts +38 -0
- package/e2e/payment-request-email-filter.spec.ts +67 -0
- package/e2e/save-filter-splitbutton.spec.ts +68 -0
- package/e2e/simple-view-email-filter.spec.ts +67 -0
- package/e2e/simple-view-transforms.spec.ts +171 -0
- package/e2e/simple-view.spec.ts +104 -0
- package/e2e/transform-regression.spec.ts +108 -0
- package/eslint.config.js +30 -0
- package/index.html +17 -0
- package/jest.config.js +10 -0
- package/package.json +45 -0
- package/playwright.config.ts +54 -0
- package/public/vite.svg +1 -0
- package/src/App.externalRuntime.test.ts +190 -0
- package/src/App.tsx +540 -0
- package/src/assets/react.svg +1 -0
- package/src/components/AIAssistantForm.tsx +241 -0
- package/src/components/FilterForm.test.ts +82 -0
- package/src/components/FilterForm.tsx +375 -0
- package/src/components/PhoneNumberFilter.tsx +102 -0
- package/src/components/SavedFilterList.tsx +181 -0
- package/src/components/SpeechInput.tsx +67 -0
- package/src/components/Table.tsx +119 -0
- package/src/components/TablePagination.tsx +40 -0
- package/src/components/aiAssistant.test.ts +270 -0
- package/src/components/aiAssistant.ts +291 -0
- package/src/framework/cell-renderer-components/CurrencyAmount.tsx +30 -0
- package/src/framework/cell-renderer-components/LayoutHelpers.tsx +74 -0
- package/src/framework/cell-renderer-components/Link.tsx +28 -0
- package/src/framework/cell-renderer-components/Mapping.tsx +11 -0
- package/src/framework/cell-renderer-components.test.ts +353 -0
- package/src/framework/column-definition.tsx +85 -0
- package/src/framework/currency.test.ts +46 -0
- package/src/framework/currency.ts +62 -0
- package/src/framework/data.staticConditions.test.ts +46 -0
- package/src/framework/data.test.ts +167 -0
- package/src/framework/data.ts +162 -0
- package/src/framework/filter-form-state.test.ts +189 -0
- package/src/framework/filter-form-state.ts +185 -0
- package/src/framework/filter-sharing.test.ts +135 -0
- package/src/framework/filter-sharing.ts +118 -0
- package/src/framework/filters.ts +194 -0
- package/src/framework/graphql.buildHasuraConditions.test.ts +473 -0
- package/src/framework/graphql.paginationKey.test.ts +29 -0
- package/src/framework/graphql.test.ts +286 -0
- package/src/framework/graphql.ts +462 -0
- package/src/framework/native-runtime/index.tsx +33 -0
- package/src/framework/native-runtime/nativeComponents.test.ts +108 -0
- package/src/framework/runtime-reference.test.ts +172 -0
- package/src/framework/runtime.ts +15 -0
- package/src/framework/saved-filters.test.ts +422 -0
- package/src/framework/saved-filters.ts +293 -0
- package/src/framework/state.test.ts +86 -0
- package/src/framework/state.ts +148 -0
- package/src/framework/transform.test.ts +51 -0
- package/src/framework/view-parser-initialvalues.test.ts +228 -0
- package/src/framework/view-parser.ts +714 -0
- package/src/framework/view.test.ts +1805 -0
- package/src/framework/view.ts +38 -0
- package/src/index.css +6 -0
- package/src/main.tsx +99 -0
- package/src/views/index.ts +12 -0
- package/src/views/payment-requests/components/NoRowsExtendDateRange.tsx +37 -0
- package/src/views/payment-requests/components/PaymentMethod.tsx +184 -0
- package/src/views/payment-requests/components/PaymentStatusTag.tsx +61 -0
- package/src/views/payment-requests/index.ts +1 -0
- package/src/views/payment-requests/runtime.tsx +145 -0
- package/src/views/payment-requests/view.json +692 -0
- package/src/views/payment-requests-initial-values.test.ts +73 -0
- package/src/views/request-log/index.ts +2 -0
- package/src/views/request-log/runtime.tsx +47 -0
- package/src/views/request-log/view.json +123 -0
- package/src/views/simple-test-view/index.ts +3 -0
- package/src/views/simple-test-view/runtime.tsx +85 -0
- package/src/views/simple-test-view/view.json +191 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +7 -0
- package/tsconfig.app.json +26 -0
- package/tsconfig.jest.json +6 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +24 -0
- 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
|
+
});
|