@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,293 @@
|
|
|
1
|
+
import { serializeFilterFormStateMap, parseFilterFormState } from './filter-form-state';
|
|
2
|
+
import { FilterSchemasAndGroups } from './filters';
|
|
3
|
+
import { FilterState } from './state';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Format revisions for saved filters
|
|
7
|
+
*/
|
|
8
|
+
export const OLD_ARRAY_FORMAT_REVISION = '2025-09-04T00:00:00.000Z';
|
|
9
|
+
export const CURRENT_FORMAT_REVISION = '2025-09-19T00:00:00.000Z';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Raw saved filter data as stored in localStorage - using unknown for type safety
|
|
13
|
+
*/
|
|
14
|
+
export interface RawSavedFilter {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
view: string;
|
|
18
|
+
state: unknown; // Serialized FilterState - could be object format or legacy array format
|
|
19
|
+
createdAt: string | Date; // Could be string from JSON or Date object
|
|
20
|
+
formatRevision?: string; // Optional for backwards compatibility
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parsed saved filter with properly typed state
|
|
25
|
+
*/
|
|
26
|
+
export interface SavedFilter {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
view: string;
|
|
30
|
+
state: FilterState; // Parsed FilterState as a Map
|
|
31
|
+
createdAt: Date;
|
|
32
|
+
formatRevision: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Interface for the saved filter manager
|
|
37
|
+
*/
|
|
38
|
+
export interface SavedFilterManager {
|
|
39
|
+
loadFilters(viewName: string, schema: FilterSchemasAndGroups): SavedFilter[];
|
|
40
|
+
saveFilter(filter: Omit<SavedFilter, 'id' | 'createdAt' | 'formatRevision'>): SavedFilter;
|
|
41
|
+
updateFilter(filter: SavedFilter, updates: Partial<Pick<SavedFilter, 'name' | 'state'>>): SavedFilter | null;
|
|
42
|
+
deleteFilter(id: string): boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const SAVED_FILTERS_KEY = 'dtvSavedFilters';
|
|
46
|
+
const LEGACY_SAVED_FILTERS_KEY = 'savedFilters';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Convert old array format to object format using schema order
|
|
50
|
+
*/
|
|
51
|
+
function convertArrayToObject(state: unknown, schema: FilterSchemasAndGroups): Record<string, unknown> {
|
|
52
|
+
if (!Array.isArray(state)) {
|
|
53
|
+
console.warn('Expected array for conversion but got:', typeof state);
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const objectState: Record<string, unknown> = {};
|
|
58
|
+
|
|
59
|
+
// Map array positions to filter IDs using schema order
|
|
60
|
+
state.forEach((filterState: unknown, index: number) => {
|
|
61
|
+
if (index < schema.filters.length) {
|
|
62
|
+
const filterId = schema.filters[index].id;
|
|
63
|
+
objectState[filterId] = filterState;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return objectState;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Create and return a SavedFilterManager instance
|
|
72
|
+
*/
|
|
73
|
+
export function createSavedFilterManager(): SavedFilterManager {
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Migrate data from legacy localStorage key to current key if needed
|
|
77
|
+
* Returns true if migration occurred, false otherwise
|
|
78
|
+
*/
|
|
79
|
+
function migrateLegacyStorageKey(): boolean {
|
|
80
|
+
try {
|
|
81
|
+
// Check if current key already has data
|
|
82
|
+
if (localStorage.getItem(SAVED_FILTERS_KEY)) {
|
|
83
|
+
return false; // No migration needed
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check if legacy key has data
|
|
87
|
+
const legacyRaw = localStorage.getItem(LEGACY_SAVED_FILTERS_KEY);
|
|
88
|
+
if (!legacyRaw) {
|
|
89
|
+
return false; // No legacy data to migrate
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.info('Found saved filters in legacy localStorage key, migrating...');
|
|
93
|
+
|
|
94
|
+
// Move data from legacy key to current key
|
|
95
|
+
localStorage.setItem(SAVED_FILTERS_KEY, legacyRaw);
|
|
96
|
+
localStorage.removeItem(LEGACY_SAVED_FILTERS_KEY);
|
|
97
|
+
|
|
98
|
+
// Parse to get count for logging
|
|
99
|
+
const parsed: unknown = JSON.parse(legacyRaw);
|
|
100
|
+
const count = Array.isArray(parsed) ? parsed.length : 0;
|
|
101
|
+
console.info(`Migrated ${count} filters from legacy localStorage key '${LEGACY_SAVED_FILTERS_KEY}' to '${SAVED_FILTERS_KEY}'`);
|
|
102
|
+
|
|
103
|
+
return true;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
console.error('Failed to migrate legacy localStorage key:', error);
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Load raw saved filters from localStorage with proper type safety
|
|
112
|
+
*/
|
|
113
|
+
function loadRawSavedFilters(): RawSavedFilter[] {
|
|
114
|
+
try {
|
|
115
|
+
// First, handle legacy key migration
|
|
116
|
+
migrateLegacyStorageKey();
|
|
117
|
+
|
|
118
|
+
// Now load from the current key
|
|
119
|
+
const raw = localStorage.getItem(SAVED_FILTERS_KEY);
|
|
120
|
+
if (!raw) {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const parsed: unknown = JSON.parse(raw);
|
|
125
|
+
if (!Array.isArray(parsed)) {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return parsed.map((item: unknown) => {
|
|
130
|
+
// Type guard for item structure
|
|
131
|
+
if (typeof item !== 'object' || item === null) {
|
|
132
|
+
throw new Error('Invalid saved filter structure');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const rawItem = item as Record<string, unknown>;
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
id: typeof rawItem.id === 'string' ? rawItem.id : crypto.randomUUID(),
|
|
139
|
+
name: typeof rawItem.name === 'string' ? rawItem.name : 'Unnamed Filter',
|
|
140
|
+
view: typeof rawItem.view === 'string' ? rawItem.view : '',
|
|
141
|
+
state: rawItem.state, // Keep as unknown for later parsing
|
|
142
|
+
createdAt: typeof rawItem.createdAt === 'string'
|
|
143
|
+
? rawItem.createdAt
|
|
144
|
+
: new Date().toISOString(),
|
|
145
|
+
formatRevision: typeof rawItem.formatRevision === 'string'
|
|
146
|
+
? rawItem.formatRevision
|
|
147
|
+
: OLD_ARRAY_FORMAT_REVISION // Default to old format for items without revision
|
|
148
|
+
} satisfies RawSavedFilter;
|
|
149
|
+
});
|
|
150
|
+
} catch (error) {
|
|
151
|
+
console.error('Failed to load saved filters from localStorage:', error);
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Load and parse saved filters for a specific view
|
|
158
|
+
*/
|
|
159
|
+
function loadFilters(viewName: string, schema: FilterSchemasAndGroups): SavedFilter[] {
|
|
160
|
+
const allRawFilters = loadRawSavedFilters();
|
|
161
|
+
let hasMigrations = false;
|
|
162
|
+
|
|
163
|
+
// Process all raw filters for migration
|
|
164
|
+
const updatedAllRawFilters = allRawFilters.map((rawFilter) => {
|
|
165
|
+
// If this is old array format, convert to object format
|
|
166
|
+
if (rawFilter.formatRevision === OLD_ARRAY_FORMAT_REVISION) {
|
|
167
|
+
hasMigrations = true;
|
|
168
|
+
const objectState = convertArrayToObject(rawFilter.state, schema);
|
|
169
|
+
return {
|
|
170
|
+
...rawFilter,
|
|
171
|
+
state: objectState,
|
|
172
|
+
formatRevision: CURRENT_FORMAT_REVISION
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Always ensure current revision
|
|
177
|
+
return {
|
|
178
|
+
...rawFilter,
|
|
179
|
+
formatRevision: CURRENT_FORMAT_REVISION
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Filter for the specific view from the updated filters
|
|
184
|
+
const viewRawFilters = updatedAllRawFilters.filter(filter => filter.view === viewName);
|
|
185
|
+
|
|
186
|
+
// Parse the view-specific filters into SavedFilter format
|
|
187
|
+
const parsedFilters = viewRawFilters.map((rawFilter): SavedFilter => {
|
|
188
|
+
// Parse the object state into a Map
|
|
189
|
+
const parsedState = parseFilterFormState(rawFilter.state as Record<string, unknown>, schema);
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
id: rawFilter.id,
|
|
193
|
+
name: rawFilter.name,
|
|
194
|
+
view: rawFilter.view,
|
|
195
|
+
state: parsedState,
|
|
196
|
+
createdAt: typeof rawFilter.createdAt === 'string'
|
|
197
|
+
? new Date(rawFilter.createdAt)
|
|
198
|
+
: rawFilter.createdAt,
|
|
199
|
+
formatRevision: CURRENT_FORMAT_REVISION
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// If we migrated anything, save all updated filters
|
|
204
|
+
if (hasMigrations) {
|
|
205
|
+
try {
|
|
206
|
+
localStorage.setItem(SAVED_FILTERS_KEY, JSON.stringify(updatedAllRawFilters));
|
|
207
|
+
console.info(`Migrated filters from old array format to new object format`);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
console.error('Failed to save migrated filters to localStorage:', error);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return parsedFilters;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Save a filter to localStorage
|
|
218
|
+
*/
|
|
219
|
+
function saveFilter(filter: Omit<SavedFilter, 'id' | 'createdAt' | 'formatRevision'>): SavedFilter {
|
|
220
|
+
const savedFilter: SavedFilter = {
|
|
221
|
+
id: crypto.randomUUID(),
|
|
222
|
+
createdAt: new Date(),
|
|
223
|
+
formatRevision: CURRENT_FORMAT_REVISION,
|
|
224
|
+
...filter
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const existingFilters = loadRawSavedFilters();
|
|
228
|
+
const newRawFilter: RawSavedFilter = {
|
|
229
|
+
...savedFilter,
|
|
230
|
+
createdAt: savedFilter.createdAt.toISOString(),
|
|
231
|
+
state: serializeFilterFormStateMap(savedFilter.state)
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
existingFilters.push(newRawFilter);
|
|
235
|
+
localStorage.setItem(SAVED_FILTERS_KEY, JSON.stringify(existingFilters));
|
|
236
|
+
return savedFilter;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Update an existing filter
|
|
241
|
+
*/
|
|
242
|
+
function updateFilter(filter: SavedFilter, updates: Partial<Pick<SavedFilter, 'name' | 'state'>>): SavedFilter | null {
|
|
243
|
+
const allFilters = loadRawSavedFilters();
|
|
244
|
+
const filterIndex = allFilters.findIndex((existingFilter: RawSavedFilter) => existingFilter.id === filter.id);
|
|
245
|
+
|
|
246
|
+
if (filterIndex === -1) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const updatedFilter: SavedFilter = {
|
|
251
|
+
...filter,
|
|
252
|
+
...updates
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const updatedRawFilter: RawSavedFilter = {
|
|
256
|
+
...allFilters[filterIndex],
|
|
257
|
+
name: updatedFilter.name,
|
|
258
|
+
state: updates.state ? serializeFilterFormStateMap(updates.state) : allFilters[filterIndex].state
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
allFilters[filterIndex] = updatedRawFilter;
|
|
262
|
+
localStorage.setItem(SAVED_FILTERS_KEY, JSON.stringify(allFilters));
|
|
263
|
+
return updatedFilter;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Delete a filter by ID
|
|
268
|
+
*/
|
|
269
|
+
function deleteFilter(id: string): boolean {
|
|
270
|
+
const allFilters = loadRawSavedFilters();
|
|
271
|
+
const originalLength = allFilters.length;
|
|
272
|
+
const filteredFilters = allFilters.filter((filter: RawSavedFilter) => filter.id !== id);
|
|
273
|
+
|
|
274
|
+
if (filteredFilters.length === originalLength) {
|
|
275
|
+
return false; // Filter not found
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
localStorage.setItem(SAVED_FILTERS_KEY, JSON.stringify(filteredFilters));
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
loadFilters,
|
|
284
|
+
saveFilter,
|
|
285
|
+
updateFilter,
|
|
286
|
+
deleteFilter
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Default exported instance of the saved filter manager
|
|
292
|
+
*/
|
|
293
|
+
export const savedFilterManager = createSavedFilterManager();
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from '@jest/globals';
|
|
5
|
+
import { createDefaultAppState, setSelectedViewId, setDataRows, setFilterSchema, setFilterState, FilterState } from './state';
|
|
6
|
+
import { buildInitialFormState } from './state';
|
|
7
|
+
import { View } from './view';
|
|
8
|
+
|
|
9
|
+
// Mock view definitions
|
|
10
|
+
const mockViews: View[] = [
|
|
11
|
+
{
|
|
12
|
+
id: 'foo',
|
|
13
|
+
title: 'Foo',
|
|
14
|
+
filterSchema: {
|
|
15
|
+
groups: [{ name: 'default', label: 'Default' }],
|
|
16
|
+
filters: [
|
|
17
|
+
{ id: 'filter-a', label: 'A', expression: { type: 'isNull', key: 'a', value: {} }, group: 'default' }
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
columnDefinitions: [],
|
|
21
|
+
paginationKey: 'id',
|
|
22
|
+
} as any,
|
|
23
|
+
{
|
|
24
|
+
id: 'bar',
|
|
25
|
+
title: 'Bar',
|
|
26
|
+
filterSchema: {
|
|
27
|
+
groups: [{ name: 'default', label: 'Default' }],
|
|
28
|
+
filters: [
|
|
29
|
+
{ id: 'filter-b', label: 'B', expression: { type: 'isNull', key: 'b', value: {} }, group: 'default' }
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
columnDefinitions: [],
|
|
33
|
+
paginationKey: 'id',
|
|
34
|
+
} as any
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
describe('AppState', () => {
|
|
38
|
+
it('creates default state with correct initial view and filter state', () => {
|
|
39
|
+
const state = createDefaultAppState(mockViews);
|
|
40
|
+
expect(state.selectedViewId).toBe('foo');
|
|
41
|
+
expect(state.views).toBe(mockViews);
|
|
42
|
+
expect(state.filterSchemasAndGroups).toEqual(mockViews[0].filterSchema);
|
|
43
|
+
expect(state.filterState).toEqual(
|
|
44
|
+
new Map(mockViews[0].filterSchema.filters.map(f => [f.id, buildInitialFormState(f.expression)]))
|
|
45
|
+
);
|
|
46
|
+
expect(state.data).toEqual({ rows: [], flattenedRows: [] });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('setSelectedViewId updates selectedViewIndex, filterSchema, and filterState', () => {
|
|
50
|
+
let state = createDefaultAppState(mockViews);
|
|
51
|
+
state = setSelectedViewId(state, mockViews[1].id);
|
|
52
|
+
expect(state.selectedViewId).toBe('bar');
|
|
53
|
+
expect(state.filterSchemasAndGroups).toEqual(mockViews[1].filterSchema);
|
|
54
|
+
expect(state.filterState).toEqual(
|
|
55
|
+
new Map(mockViews[1].filterSchema.filters.map(f => [f.id, buildInitialFormState(f.expression)]))
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('setDataRows updates data and pagination', () => {
|
|
60
|
+
let state = createDefaultAppState(mockViews);
|
|
61
|
+
const data = { rows: [{ id: 1 }, { id: 2 }], flattenedRows: [[{ id: 1 }], [{ id: 2 }]] };
|
|
62
|
+
const pagination = { page: 2, cursors: ['a', 'b'] };
|
|
63
|
+
state = setDataRows(state, data, pagination);
|
|
64
|
+
expect(state.data).toBe(data);
|
|
65
|
+
expect(state.pagination).toEqual(pagination);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('setFilterSchema updates filterSchema', () => {
|
|
69
|
+
let state = createDefaultAppState(mockViews);
|
|
70
|
+
const newSchema = {
|
|
71
|
+
groups: [{ name: 'default', label: 'Default' }],
|
|
72
|
+
filters: [
|
|
73
|
+
{ id: 'filter-c', label: 'C', expression: { type: 'isNull', key: 'c', value: null }, group: 'default' }
|
|
74
|
+
]
|
|
75
|
+
};
|
|
76
|
+
state = setFilterSchema(state, newSchema as any);
|
|
77
|
+
expect(state.filterSchemasAndGroups).toBe(newSchema);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('setFilterState updates filterState', () => {
|
|
81
|
+
let state = createDefaultAppState(mockViews);
|
|
82
|
+
const newFilterState: FilterState = new Map([['filter1', { key: 'x', value: 42 } as any]]);
|
|
83
|
+
state = setFilterState(state, newFilterState);
|
|
84
|
+
expect(state.filterState).toBe(newFilterState);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { FilterFormState } from "../components/FilterForm";
|
|
3
|
+
import { FilterSchemasAndGroups, FilterId, FilterExpr } from "./filters";
|
|
4
|
+
import { View, ViewId } from "./view";
|
|
5
|
+
import { FetchDataResult } from "./data";
|
|
6
|
+
|
|
7
|
+
export type FilterState = Map<FilterId, FilterFormState>;
|
|
8
|
+
|
|
9
|
+
export enum FormStateInitMode {
|
|
10
|
+
WithInitialValues = 'withInitialValues',
|
|
11
|
+
Empty = 'empty'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Helper to build form state from FilterExpr
|
|
15
|
+
export function buildInitialFormState(expr: FilterExpr, mode: FormStateInitMode = FormStateInitMode.WithInitialValues): FilterFormState {
|
|
16
|
+
if (expr.type === 'and' || expr.type === 'or') {
|
|
17
|
+
return {
|
|
18
|
+
type: expr.type,
|
|
19
|
+
children: expr.filters.map(child => buildInitialFormState(child, mode))
|
|
20
|
+
};
|
|
21
|
+
} else if (expr.type === 'not') {
|
|
22
|
+
return {
|
|
23
|
+
type: 'not',
|
|
24
|
+
child: buildInitialFormState(expr.filter, mode)
|
|
25
|
+
};
|
|
26
|
+
} else {
|
|
27
|
+
return {
|
|
28
|
+
type: 'leaf',
|
|
29
|
+
value: mode === FormStateInitMode.Empty ? '' : ('initialValue' in expr.value && expr.value.initialValue !== undefined ? expr.value.initialValue : '')
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getFilterStateById(state: FilterState, id: FilterId): FilterFormState {
|
|
35
|
+
const filter = state.get(id);
|
|
36
|
+
if (!filter) {
|
|
37
|
+
throw new Error(`Inconsistent state: Filter with id ${id} not found`);
|
|
38
|
+
}
|
|
39
|
+
return filter;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function setFilterStateById(state: FilterState, id: FilterId, newFilterState: FilterFormState): FilterState {
|
|
43
|
+
if (!state.has(id)) {
|
|
44
|
+
throw new Error(`Inconsistent state: Filter with id ${id} not found`);
|
|
45
|
+
}
|
|
46
|
+
const newState = new Map(state);
|
|
47
|
+
newState.set(id, newFilterState);
|
|
48
|
+
return newState;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// AppState data structure for app state
|
|
52
|
+
export interface AppState {
|
|
53
|
+
selectedViewId: ViewId
|
|
54
|
+
views: View[]
|
|
55
|
+
filterSchemasAndGroups: FilterSchemasAndGroups
|
|
56
|
+
data: FetchDataResult
|
|
57
|
+
filterState: FilterState
|
|
58
|
+
pagination: PaginationState
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface PaginationState {
|
|
62
|
+
page: number;
|
|
63
|
+
cursors: (string | number | null)[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const defaultPagination: PaginationState = {
|
|
67
|
+
page: 0,
|
|
68
|
+
cursors: []
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export function createDefaultFilterState(filterSchema: FilterSchemasAndGroups, mode: FormStateInitMode = FormStateInitMode.WithInitialValues): FilterState {
|
|
72
|
+
return new Map(filterSchema.filters.map(filter => [filter.id, buildInitialFormState(filter.expression, mode)]));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createDefaultAppState(views: View[]): AppState {
|
|
76
|
+
const selectedViewId = views[0]?.id;
|
|
77
|
+
const view = views.find(v => v.id === selectedViewId) as View;
|
|
78
|
+
const filterSchema: FilterSchemasAndGroups = view.filterSchema;
|
|
79
|
+
const initialFilterState = createDefaultFilterState(filterSchema);
|
|
80
|
+
return {
|
|
81
|
+
views,
|
|
82
|
+
selectedViewId,
|
|
83
|
+
filterSchemasAndGroups: filterSchema,
|
|
84
|
+
data: { flattenedRows: [], rows: [] },
|
|
85
|
+
filterState: initialFilterState,
|
|
86
|
+
pagination: defaultPagination
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Update selectedViewId
|
|
91
|
+
function setSelectedViewId(state: AppState, newId: ViewId): AppState {
|
|
92
|
+
const view = state.views.find(v => v.id === newId);
|
|
93
|
+
const filterSchema = view?.filterSchema || { groups: [], filters: [] };
|
|
94
|
+
return {
|
|
95
|
+
...state,
|
|
96
|
+
selectedViewId: newId,
|
|
97
|
+
filterSchemasAndGroups: filterSchema,
|
|
98
|
+
filterState: createDefaultFilterState(filterSchema),
|
|
99
|
+
pagination: defaultPagination
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getSelectedView(state: AppState): View {
|
|
104
|
+
return state.views.find(v => v.id === state.selectedViewId) as View;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function setDataRows(state: AppState, newRows: FetchDataResult, pagination: PaginationState = defaultPagination): AppState {
|
|
108
|
+
return {
|
|
109
|
+
...state,
|
|
110
|
+
data: newRows,
|
|
111
|
+
pagination
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function setFilterSchema(state: AppState, newSchema: FilterSchemasAndGroups): AppState {
|
|
116
|
+
return {
|
|
117
|
+
...state,
|
|
118
|
+
filterSchemasAndGroups: newSchema
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function setFilterState(state: AppState, newFilterState: FilterState): AppState {
|
|
123
|
+
return {
|
|
124
|
+
...state,
|
|
125
|
+
filterState: newFilterState,
|
|
126
|
+
pagination: defaultPagination
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const useAppState = (views: View[], initialFilterStateOverride?: FilterState) => {
|
|
131
|
+
const [appState, setAppState] = useState<AppState>(() => {
|
|
132
|
+
const base = createDefaultAppState(views);
|
|
133
|
+
if (initialFilterStateOverride) {
|
|
134
|
+
return { ...base, filterState: initialFilterStateOverride };
|
|
135
|
+
}
|
|
136
|
+
return base;
|
|
137
|
+
});
|
|
138
|
+
return {
|
|
139
|
+
state: appState,
|
|
140
|
+
selectedView: getSelectedView(appState),
|
|
141
|
+
setSelectedViewId: (id: ViewId) => setAppState(prev => setSelectedViewId(prev, id)),
|
|
142
|
+
setDataRows: (rows: FetchDataResult, pagination?: PaginationState) => setAppState(prev => setDataRows(prev, rows, pagination)),
|
|
143
|
+
setFilterSchema: (schema: FilterSchemasAndGroups) => setAppState(prev => setFilterSchema(prev, schema)),
|
|
144
|
+
setFilterState: (filterState: FilterState) => setAppState(prev => setFilterState(prev, filterState))
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export { setSelectedViewId, setDataRows, setFilterSchema, setFilterState };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { TransformResult } from './filters';
|
|
2
|
+
|
|
3
|
+
describe('TransformResult functionality', () => {
|
|
4
|
+
|
|
5
|
+
describe('new object-based transform behavior', () => {
|
|
6
|
+
it('should handle object returns with value only', () => {
|
|
7
|
+
const objectTransform = (input: any): TransformResult => ({ value: input?.toString() || "" });
|
|
8
|
+
|
|
9
|
+
const result = objectTransform(42);
|
|
10
|
+
expect(result).toEqual({ value: "42" });
|
|
11
|
+
|
|
12
|
+
const emptyResult = objectTransform(null);
|
|
13
|
+
expect(emptyResult).toEqual({ value: "" });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should handle object returns with both field and value', () => {
|
|
17
|
+
const keyValueTransform = (input: any): TransformResult => ({
|
|
18
|
+
field: "transformedField",
|
|
19
|
+
value: `prefix_${input}`
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const result = keyValueTransform("test");
|
|
23
|
+
expect(result).toEqual({
|
|
24
|
+
field: "transformedField",
|
|
25
|
+
value: "prefix_test"
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should handle conditional transform returns', () => {
|
|
30
|
+
const conditionalTransform = (input: any): TransformResult => {
|
|
31
|
+
if (!input || input === '') {
|
|
32
|
+
return input; // Return simple value for empty input
|
|
33
|
+
}
|
|
34
|
+
return { field: "transformedField", value: `prefix_${input}` };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Empty input returns simple value
|
|
38
|
+
expect(conditionalTransform("")).toBe("");
|
|
39
|
+
expect(conditionalTransform(null)).toBe(null);
|
|
40
|
+
|
|
41
|
+
// Non-empty input returns object
|
|
42
|
+
expect(conditionalTransform("test")).toEqual({
|
|
43
|
+
field: "transformedField",
|
|
44
|
+
value: "prefix_test"
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
});
|