@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,167 @@
|
|
|
1
|
+
import { flattenFields } from './data';
|
|
2
|
+
import { ColumnDefinition, fieldAlias, field, queryConfigs } from './column-definition';
|
|
3
|
+
|
|
4
|
+
describe('flattenFields', () => {
|
|
5
|
+
it('extracts simple fields from rows', () => {
|
|
6
|
+
const rows = [
|
|
7
|
+
{ id: 1, name: 'Alice', age: 30 },
|
|
8
|
+
{ id: 2, name: 'Bob', age: 25 }
|
|
9
|
+
];
|
|
10
|
+
const columns: ColumnDefinition[] = [
|
|
11
|
+
{ data: [{ type: 'field', path: 'id' }] } as ColumnDefinition,
|
|
12
|
+
{ data: [{ type: 'field', path: 'name' }] } as ColumnDefinition
|
|
13
|
+
];
|
|
14
|
+
const result = flattenFields(rows, columns);
|
|
15
|
+
expect(result).toEqual([
|
|
16
|
+
[{ id: 1 }, { name: 'Alice' }],
|
|
17
|
+
[{ id: 2 }, { name: 'Bob' }]
|
|
18
|
+
]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('handles nested field paths', () => {
|
|
22
|
+
const rows = [
|
|
23
|
+
{ id: 1, user: { profile: { email: 'alice@example.com' } } },
|
|
24
|
+
{ id: 2, user: { profile: { email: 'bob@example.com' } } }
|
|
25
|
+
];
|
|
26
|
+
const columns: ColumnDefinition[] = [
|
|
27
|
+
{ data: [{ type: 'field', path: 'user.profile.email' }] } as ColumnDefinition
|
|
28
|
+
];
|
|
29
|
+
const result = flattenFields(rows, columns);
|
|
30
|
+
expect(result).toEqual([
|
|
31
|
+
[{ 'user.profile.email': 'alice@example.com' }],
|
|
32
|
+
[{ 'user.profile.email': 'bob@example.com' }]
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('handles multiple nested field paths with array parent', () => {
|
|
37
|
+
const rows = [
|
|
38
|
+
{
|
|
39
|
+
id: 1,
|
|
40
|
+
users: [
|
|
41
|
+
{ profile: { email: 'alice@example.com', age: 30 } },
|
|
42
|
+
{ profile: { email: 'bob@example.com', age: 25 } }
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 2,
|
|
47
|
+
users: [
|
|
48
|
+
{ profile: { email: 'carol@example.com', age: 28 } }
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
];
|
|
52
|
+
const columns: ColumnDefinition[] = [
|
|
53
|
+
{
|
|
54
|
+
data: [
|
|
55
|
+
{ type: 'field', path: 'users.profile.email' },
|
|
56
|
+
{ type: 'field', path: 'users.profile.age' }
|
|
57
|
+
]
|
|
58
|
+
} as ColumnDefinition
|
|
59
|
+
];
|
|
60
|
+
const result = flattenFields(rows, columns);
|
|
61
|
+
expect(result).toEqual([
|
|
62
|
+
[
|
|
63
|
+
{
|
|
64
|
+
'users.profile.email': ['alice@example.com', 'bob@example.com'],
|
|
65
|
+
'users.profile.age': [30, 25]
|
|
66
|
+
}
|
|
67
|
+
],
|
|
68
|
+
[
|
|
69
|
+
{
|
|
70
|
+
'users.profile.email': ['carol@example.com'],
|
|
71
|
+
'users.profile.age': [28]
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('handles field aliases correctly', () => {
|
|
78
|
+
// Simulate the response that would come from GraphQL with aliases
|
|
79
|
+
const rows = [
|
|
80
|
+
{
|
|
81
|
+
id: 1,
|
|
82
|
+
user: { name: 'Alice' },
|
|
83
|
+
userName: 'Alice' // This is what GraphQL would return for the alias
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 2,
|
|
87
|
+
user: { name: 'Bob' },
|
|
88
|
+
userName: 'Bob' // This is what GraphQL would return for the alias
|
|
89
|
+
}
|
|
90
|
+
];
|
|
91
|
+
const columns: ColumnDefinition[] = [
|
|
92
|
+
{
|
|
93
|
+
data: [fieldAlias("userName", field("user.name"))]
|
|
94
|
+
} as ColumnDefinition
|
|
95
|
+
];
|
|
96
|
+
const result = flattenFields(rows, columns);
|
|
97
|
+
|
|
98
|
+
// The flattened result should use the alias name "userName", not the original path "user.name"
|
|
99
|
+
expect(result).toEqual([
|
|
100
|
+
[{ userName: 'Alice' }],
|
|
101
|
+
[{ userName: 'Bob' }]
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('handles field aliases with queryConfigs correctly', () => {
|
|
106
|
+
// Simulate the response that would come from GraphQL with aliases for nested queryConfigs
|
|
107
|
+
const rows = [
|
|
108
|
+
{
|
|
109
|
+
id: 1,
|
|
110
|
+
posts: [
|
|
111
|
+
{ title: 'Post 1', created_at: '2023-01-01' },
|
|
112
|
+
{ title: 'Post 2', created_at: '2023-01-02' }
|
|
113
|
+
],
|
|
114
|
+
recentPostTitles: [ // This is what GraphQL would return for the alias
|
|
115
|
+
'Post 2',
|
|
116
|
+
'Post 1'
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
];
|
|
120
|
+
const columns: ColumnDefinition[] = [
|
|
121
|
+
{
|
|
122
|
+
data: [
|
|
123
|
+
fieldAlias("recentPostTitles", queryConfigs([
|
|
124
|
+
{ field: "posts" },
|
|
125
|
+
{ field: "title" }
|
|
126
|
+
]))
|
|
127
|
+
]
|
|
128
|
+
} as ColumnDefinition
|
|
129
|
+
];
|
|
130
|
+
const result = flattenFields(rows, columns);
|
|
131
|
+
|
|
132
|
+
// The flattened result should use the alias name "recentPostTitles", not the generated path "posts.title"
|
|
133
|
+
expect(result).toEqual([
|
|
134
|
+
[{
|
|
135
|
+
recentPostTitles: [
|
|
136
|
+
'Post 2',
|
|
137
|
+
'Post 1'
|
|
138
|
+
]
|
|
139
|
+
}]
|
|
140
|
+
]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('handles nested field aliases correctly', () => {
|
|
144
|
+
// Test aliasing a field alias (though this is a rare case, it should be supported)
|
|
145
|
+
const rows = [
|
|
146
|
+
{
|
|
147
|
+
id: 1,
|
|
148
|
+
user: { name: 'Alice' },
|
|
149
|
+
userName: 'Alice', // This would be the first alias from GraphQL
|
|
150
|
+
displayName: 'Alice' // This would be the second alias from GraphQL
|
|
151
|
+
}
|
|
152
|
+
];
|
|
153
|
+
const columns: ColumnDefinition[] = [
|
|
154
|
+
{
|
|
155
|
+
data: [
|
|
156
|
+
fieldAlias("displayName", fieldAlias("userName", field("user.name")))
|
|
157
|
+
]
|
|
158
|
+
} as ColumnDefinition
|
|
159
|
+
];
|
|
160
|
+
const result = flattenFields(rows, columns);
|
|
161
|
+
|
|
162
|
+
// The flattened result should use the outermost alias name "displayName"
|
|
163
|
+
expect(result).toEqual([
|
|
164
|
+
[{ displayName: 'Alice' }]
|
|
165
|
+
]);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { GraphQLClient } from 'graphql-request';
|
|
2
|
+
import { buildHasuraConditions } from '../framework/graphql';
|
|
3
|
+
import { View } from '../framework/view';
|
|
4
|
+
import { ColumnDefinition, FieldQuery, QueryConfig } from '../framework/column-definition';
|
|
5
|
+
import { FilterState } from './state';
|
|
6
|
+
|
|
7
|
+
export interface FetchDataResult {
|
|
8
|
+
rows: Record<string, unknown>[]; // Fetched rows from the query
|
|
9
|
+
flattenedRows: Record<string, unknown>[][]; // Rows flattened according to column definitions
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function hasKey<K extends string | number | symbol, T extends { [key in K]: unknown[] }>(obj: unknown, key: K): obj is T {
|
|
13
|
+
return typeof obj === 'object' && obj !== null && key in obj && Array.isArray((obj as T)[key]);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Request counter to be able to cancel handling of previous requests
|
|
17
|
+
let requestCounter = 0;
|
|
18
|
+
|
|
19
|
+
export const fetchData = async ({
|
|
20
|
+
client,
|
|
21
|
+
view,
|
|
22
|
+
query,
|
|
23
|
+
filterState,
|
|
24
|
+
rows,
|
|
25
|
+
cursor
|
|
26
|
+
}: {
|
|
27
|
+
client: GraphQLClient;
|
|
28
|
+
view: View;
|
|
29
|
+
query: string;
|
|
30
|
+
filterState: FilterState;
|
|
31
|
+
rows: number;
|
|
32
|
+
cursor: string | number | null;
|
|
33
|
+
}): Promise<FetchDataResult> => {
|
|
34
|
+
// Assign a unique ID to this request for ordering
|
|
35
|
+
const currentRequestId = ++requestCounter;
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
let conditions = buildHasuraConditions(filterState, view.filterSchema);
|
|
39
|
+
|
|
40
|
+
// Merge staticConditions (always-on) if present
|
|
41
|
+
if (view.staticConditions && view.staticConditions.length > 0) {
|
|
42
|
+
// If existing conditions object is empty (no user filters), we still wrap both sides in _and for consistency
|
|
43
|
+
conditions = { _and: [conditions, ...view.staticConditions] } as any;
|
|
44
|
+
}
|
|
45
|
+
if (cursor !== null) {
|
|
46
|
+
const pagKey = view.paginationKey;
|
|
47
|
+
const pagCond = { [pagKey]: { _lt: cursor } };
|
|
48
|
+
// Always wrap in _and for pagination
|
|
49
|
+
// If static conditions already produced an _and wrapper, append to its array to avoid nesting
|
|
50
|
+
if (conditions && '_and' in conditions && Array.isArray((conditions as any)._and)) {
|
|
51
|
+
(conditions as any)._and.push(pagCond);
|
|
52
|
+
} else {
|
|
53
|
+
conditions = { _and: [conditions, pagCond] } as any;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const variables = {
|
|
57
|
+
conditions,
|
|
58
|
+
limit: rows,
|
|
59
|
+
orderBy: [{ [view.paginationKey]: 'DESC' }],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const response = await client.request(query, variables);
|
|
63
|
+
|
|
64
|
+
// Check if this is still the most recent request
|
|
65
|
+
if (currentRequestId !== requestCounter) {
|
|
66
|
+
// A newer request has been started, discard this response
|
|
67
|
+
throw new DOMException('Request superseded by newer request', 'AbortError');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!hasKey(response, view.collectionName)) {
|
|
71
|
+
console.error('Error fetching data, unexpected response format:', response);
|
|
72
|
+
return { rows: [], flattenedRows: [] };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const rowsFetched = response[view.collectionName];
|
|
76
|
+
|
|
77
|
+
// Flatten the data before returning
|
|
78
|
+
return {
|
|
79
|
+
rows: rowsFetched as Record<string, any>[],
|
|
80
|
+
flattenedRows: flattenFields(rowsFetched as Record<string, any>[], view.columnDefinitions)
|
|
81
|
+
}
|
|
82
|
+
} catch (error) {
|
|
83
|
+
// Don't log AbortError as it's expected when cancelling requests
|
|
84
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
85
|
+
throw error; // Re-throw abort errors so fetchDataWrapper can cancel response handling
|
|
86
|
+
}
|
|
87
|
+
console.error('Error fetching data:', error);
|
|
88
|
+
return { rows: [], flattenedRows: [] };
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Applies flattenColumnFields to all rows for all columns
|
|
93
|
+
export const flattenFields = (
|
|
94
|
+
rows: Record<string, any>[],
|
|
95
|
+
columns: ColumnDefinition[]
|
|
96
|
+
): Record<string, any>[][] => {
|
|
97
|
+
return rows.map(row =>
|
|
98
|
+
columns.map(column => flattenColumnFields(row, column))
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Helper to extract field values for a column from a row
|
|
103
|
+
export const flattenColumnFields = (row: Record<string, any>, column: ColumnDefinition) => {
|
|
104
|
+
const values: Record<string, any> = {};
|
|
105
|
+
|
|
106
|
+
const extractField = (fieldQuery: FieldQuery) => {
|
|
107
|
+
if (fieldQuery.type === 'field') {
|
|
108
|
+
const path = fieldQuery.path.split('.');
|
|
109
|
+
let value: any = row;
|
|
110
|
+
for (const p of path) {
|
|
111
|
+
if (Array.isArray(value)) {
|
|
112
|
+
// If value is an array, map extraction for each item
|
|
113
|
+
value = value.map(item => {
|
|
114
|
+
let v = item;
|
|
115
|
+
for (let i = path.indexOf(p); i < path.length; i++) {
|
|
116
|
+
v = v?.[path[i]];
|
|
117
|
+
}
|
|
118
|
+
return v;
|
|
119
|
+
});
|
|
120
|
+
break;
|
|
121
|
+
} else {
|
|
122
|
+
value = value?.[p];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
values[fieldQuery.path] = value;
|
|
126
|
+
} else if (fieldQuery.type === 'queryConfigs') {
|
|
127
|
+
const pathKey = fieldQuery.configs.map(c => c.field).join('.');
|
|
128
|
+
|
|
129
|
+
const extract = (currentValue: any, configs: QueryConfig[]): any => {
|
|
130
|
+
if (configs.length === 0) {
|
|
131
|
+
return currentValue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (currentValue === undefined || currentValue === null) {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const [currentConfig, ...remainingConfigs] = configs;
|
|
139
|
+
|
|
140
|
+
if (Array.isArray(currentValue)) {
|
|
141
|
+
return currentValue.map(item => extract(item, configs));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (typeof currentValue === 'object' && currentConfig.field in currentValue) {
|
|
145
|
+
const nextValue = currentValue[currentConfig.field];
|
|
146
|
+
return extract(nextValue, remainingConfigs);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return undefined;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
values[pathKey] = extract(row, fieldQuery.configs);
|
|
153
|
+
} else if (fieldQuery.type === 'fieldAlias') {
|
|
154
|
+
// For field aliases, we look up the value using the alias name instead of the original field path
|
|
155
|
+
// The GraphQL response should contain the aliased field name
|
|
156
|
+
values[fieldQuery.alias] = row[fieldQuery.alias];
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
column.data.forEach(extractField);
|
|
161
|
+
return values;
|
|
162
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import {
|
|
2
|
+
serializeFilterFormStateMap,
|
|
3
|
+
parseFilterFormState
|
|
4
|
+
} from './filter-form-state';
|
|
5
|
+
import { FilterSchemasAndGroups } from './filters';
|
|
6
|
+
import { FilterState } from './state';
|
|
7
|
+
|
|
8
|
+
describe('filter-form-state', () => {
|
|
9
|
+
const mockFilterSchema: FilterSchemasAndGroups = {
|
|
10
|
+
groups: [
|
|
11
|
+
{ name: 'Basic', label: 'Basic' }
|
|
12
|
+
],
|
|
13
|
+
filters: [
|
|
14
|
+
{
|
|
15
|
+
id: 'email-filter',
|
|
16
|
+
label: 'Email',
|
|
17
|
+
expression: {
|
|
18
|
+
type: 'equals',
|
|
19
|
+
field: 'email',
|
|
20
|
+
value: { type: 'text', label: 'Email' }
|
|
21
|
+
},
|
|
22
|
+
group: 'Basic',
|
|
23
|
+
aiGenerated: false
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'date-filter',
|
|
27
|
+
label: 'Date',
|
|
28
|
+
expression: {
|
|
29
|
+
type: 'equals',
|
|
30
|
+
field: 'created_at',
|
|
31
|
+
value: { type: 'date', label: 'Created Date' }
|
|
32
|
+
},
|
|
33
|
+
group: 'Basic',
|
|
34
|
+
aiGenerated: false
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const mockFilterState: FilterState = new Map([
|
|
40
|
+
['email-filter', {
|
|
41
|
+
type: 'leaf',
|
|
42
|
+
field: 'email',
|
|
43
|
+
value: 'test@example.com',
|
|
44
|
+
control: { type: 'text', label: 'Email' },
|
|
45
|
+
filterType: 'equals'
|
|
46
|
+
}],
|
|
47
|
+
['date-filter', {
|
|
48
|
+
type: 'leaf',
|
|
49
|
+
field: 'created_at',
|
|
50
|
+
value: new Date('2023-01-01T00:00:00.000Z'),
|
|
51
|
+
control: { type: 'date', label: 'Created Date' },
|
|
52
|
+
filterType: 'equals'
|
|
53
|
+
}]
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
describe('serializeFilterFormStateMap', () => {
|
|
57
|
+
it('should serialize filter state Map to JSON-compatible format', () => {
|
|
58
|
+
const serialized = serializeFilterFormStateMap(mockFilterState);
|
|
59
|
+
|
|
60
|
+
expect(typeof serialized).toBe('object');
|
|
61
|
+
expect(serialized).not.toBeNull();
|
|
62
|
+
expect(Array.isArray(serialized)).toBe(false);
|
|
63
|
+
|
|
64
|
+
// Check that both filters are present as object properties
|
|
65
|
+
expect(serialized['email-filter']).toEqual({
|
|
66
|
+
type: 'leaf',
|
|
67
|
+
field: 'email',
|
|
68
|
+
value: 'test@example.com',
|
|
69
|
+
control: { type: 'text', label: 'Email' },
|
|
70
|
+
filterType: 'equals'
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(serialized['date-filter']).toEqual({
|
|
74
|
+
type: 'leaf',
|
|
75
|
+
field: 'created_at',
|
|
76
|
+
value: '2023-01-01T00:00:00.000Z', // Date should be serialized as ISO string
|
|
77
|
+
control: { type: 'date', label: 'Created Date' },
|
|
78
|
+
filterType: 'equals'
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should handle complex nested structures', () => {
|
|
83
|
+
const complexState: FilterState = new Map([
|
|
84
|
+
['complex-filter', {
|
|
85
|
+
type: 'and',
|
|
86
|
+
filterType: 'and',
|
|
87
|
+
children: [
|
|
88
|
+
{
|
|
89
|
+
type: 'leaf',
|
|
90
|
+
field: 'email',
|
|
91
|
+
value: 'test@example.com',
|
|
92
|
+
control: { type: 'text', label: 'Email' },
|
|
93
|
+
filterType: 'equals'
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
type: 'not',
|
|
97
|
+
filterType: 'not',
|
|
98
|
+
child: {
|
|
99
|
+
type: 'leaf',
|
|
100
|
+
field: 'status',
|
|
101
|
+
value: 'deleted',
|
|
102
|
+
control: { type: 'text', label: 'Status' },
|
|
103
|
+
filterType: 'equals'
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
]
|
|
107
|
+
}]
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
const serialized = serializeFilterFormStateMap(complexState);
|
|
111
|
+
expect(typeof serialized).toBe('object');
|
|
112
|
+
expect(Array.isArray(serialized)).toBe(false);
|
|
113
|
+
|
|
114
|
+
const complexFilter = serialized['complex-filter'];
|
|
115
|
+
expect(complexFilter).toBeDefined();
|
|
116
|
+
expect(complexFilter.type).toBe('and');
|
|
117
|
+
expect(complexFilter.children).toHaveLength(2);
|
|
118
|
+
expect(complexFilter.children[1].type).toBe('not');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('parseFilterFormState', () => {
|
|
123
|
+
it('should parse serialized state back to FilterState with date handling', () => {
|
|
124
|
+
const serialized = {
|
|
125
|
+
'email-filter': {
|
|
126
|
+
type: 'leaf',
|
|
127
|
+
field: 'email',
|
|
128
|
+
value: 'test@example.com',
|
|
129
|
+
control: { type: 'text', label: 'Email' },
|
|
130
|
+
filterType: 'equals'
|
|
131
|
+
},
|
|
132
|
+
'date-filter': {
|
|
133
|
+
type: 'leaf',
|
|
134
|
+
field: 'created_at',
|
|
135
|
+
value: '2023-01-01T00:00:00.000Z',
|
|
136
|
+
control: { type: 'date', label: 'Created Date' },
|
|
137
|
+
filterType: 'equals'
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const parsed = parseFilterFormState(serialized, mockFilterSchema);
|
|
142
|
+
|
|
143
|
+
expect(parsed).toBeInstanceOf(Map);
|
|
144
|
+
expect(parsed.size).toBe(2);
|
|
145
|
+
|
|
146
|
+
const emailFilter = parsed.get('email-filter');
|
|
147
|
+
expect(emailFilter).toEqual({ type: 'leaf', value: 'test@example.com' });
|
|
148
|
+
|
|
149
|
+
const dateFilter = parsed.get('date-filter');
|
|
150
|
+
expect(dateFilter?.type).toBe('leaf');
|
|
151
|
+
expect((dateFilter as any).value).toBeInstanceOf(Date);
|
|
152
|
+
expect(((dateFilter as any).value as Date).toISOString()).toBe('2023-01-01T00:00:00.000Z');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should treat invalid date strings as plain strings', () => {
|
|
156
|
+
const serialized = {
|
|
157
|
+
'date-filter': {
|
|
158
|
+
type: 'leaf',
|
|
159
|
+
field: 'created_at',
|
|
160
|
+
value: 'invalid-date',
|
|
161
|
+
control: { type: 'date', label: 'Created Date' },
|
|
162
|
+
filterType: 'equals'
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
const parsed = parseFilterFormState(serialized, mockFilterSchema);
|
|
166
|
+
const dateFilter = parsed.get('date-filter');
|
|
167
|
+
expect(dateFilter).toEqual({ type: 'leaf', value: 'invalid-date' });
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('round-trip serialization/parsing', () => {
|
|
172
|
+
it('should preserve data through serialize/parse cycle', () => {
|
|
173
|
+
// Use the new serializeFilterFormStateMap function for a true round-trip test
|
|
174
|
+
const serialized = serializeFilterFormStateMap(mockFilterState);
|
|
175
|
+
const parsed = parseFilterFormState(serialized, mockFilterSchema);
|
|
176
|
+
|
|
177
|
+
expect(parsed).toBeInstanceOf(Map);
|
|
178
|
+
expect(parsed.size).toBe(2);
|
|
179
|
+
|
|
180
|
+
const emailFilter = parsed.get('email-filter');
|
|
181
|
+
const dateFilter = parsed.get('date-filter');
|
|
182
|
+
|
|
183
|
+
expect(emailFilter).toEqual({ type: 'leaf', value: 'test@example.com' });
|
|
184
|
+
expect(dateFilter?.type).toBe('leaf');
|
|
185
|
+
expect((dateFilter as any).value).toBeInstanceOf(Date);
|
|
186
|
+
expect(((dateFilter as any).value as Date).toISOString()).toBe('2023-01-01T00:00:00.000Z');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|