@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,185 @@
1
+ import { FilterSchemasAndGroups, FilterField, FilterControl, FilterExpr, FilterTransform } from './filters';
2
+ import { FilterState, buildInitialFormState, FormStateInitMode } from './state';
3
+
4
+ // Tree-like state for FilterForm
5
+ export type FilterFormState =
6
+ | {
7
+ type: 'leaf';
8
+ value: any;
9
+ }
10
+ | { type: 'and' | 'or'; children: FilterFormState[] }
11
+ | { type: 'not'; child: FilterFormState };
12
+
13
+ /**
14
+ * Generic helper to apply a transformation function to all leaf values in a FilterFormState tree
15
+ */
16
+ export function mapFilterFormState<T>(
17
+ node: FilterFormState,
18
+ transformValue: (value: any) => T
19
+ ): FilterFormState {
20
+ if (node.type === 'leaf') {
21
+ return {
22
+ ...node,
23
+ value: transformValue(node.value)
24
+ };
25
+ } else if (node.type === 'not') {
26
+ return {
27
+ type: 'not',
28
+ child: mapFilterFormState(node.child, transformValue)
29
+ };
30
+ } else {
31
+ return {
32
+ type: node.type,
33
+ children: node.children.map(child => mapFilterFormState(child, transformValue))
34
+ };
35
+ }
36
+ }
37
+
38
+ // Type aliases for narrowed FilterExpr types
39
+ type LeafFilterExpr = FilterExpr & { field: FilterField; value: FilterControl; transform?: FilterTransform };
40
+ type AndFilterExpr = FilterExpr & { type: 'and' };
41
+ type OrFilterExpr = FilterExpr & { type: 'or' };
42
+ type NotFilterExpr = FilterExpr & { type: 'not' };
43
+
44
+ /**
45
+ * Helper function that recursively traverses both filter schema and state in parallel.
46
+ * Calls the appropriate handler for each node in the tree based on the state node type.
47
+ *
48
+ * This helper is useful for operations that need to correlate schema information with state values,
49
+ * such as:
50
+ * - Building query conditions recursively
51
+ * - Building validation result trees
52
+ * - Transforming data structures while preserving tree shape
53
+ * - Building UI components from schema + state
54
+ *
55
+ * @param schemaNode - The schema node to traverse
56
+ * @param stateNode - The state node to traverse
57
+ * @param handlers - Record of functions keyed by FilterFormState type, each handling specific node types
58
+ * @returns Single result of type T built recursively
59
+ */
60
+ export function traverseFilterSchemaAndState<T>(
61
+ schemaNode: FilterExpr,
62
+ stateNode: FilterFormState,
63
+ handlers: {
64
+ leaf: (schemaNode: LeafFilterExpr, stateNode: FilterFormState & { type: 'leaf' }) => T;
65
+ and: (schemaNode: AndFilterExpr, stateNode: FilterFormState & { type: 'and' }, childResults: T[]) => T;
66
+ or: (schemaNode: OrFilterExpr, stateNode: FilterFormState & { type: 'or' }, childResults: T[]) => T;
67
+ not: (schemaNode: NotFilterExpr, stateNode: FilterFormState & { type: 'not' }, childResult: T) => T;
68
+ }
69
+ ): T {
70
+ switch (stateNode.type) {
71
+ case 'leaf':
72
+ return handlers.leaf(schemaNode as LeafFilterExpr, stateNode as FilterFormState & { type: 'leaf' });
73
+
74
+ case 'and': {
75
+ const state = stateNode as FilterFormState & { type: 'and' };
76
+ const schema = schemaNode
77
+ if (schema.type !== 'and') {
78
+ throw new Error(`Schema type mismatch: expected 'and', got '${schema.type}'`);
79
+ }
80
+ // Recursively traverse children
81
+ const childResults = state.children.map((childState, index) => {
82
+ const childSchema = schema.filters[index];
83
+ if (!childSchema) {
84
+ throw new Error(`Missing schema for child at index ${index}`);
85
+ }
86
+ return traverseFilterSchemaAndState(childSchema, childState, handlers);
87
+ });
88
+
89
+ return handlers.and(schema, state, childResults);
90
+ }
91
+
92
+ case 'or': {
93
+ const state = stateNode as FilterFormState & { type: 'or' };
94
+ const schema = schemaNode
95
+ if (schema.type !== 'or') {
96
+ throw new Error(`Schema type mismatch: expected 'or', got '${schema.type}'`);
97
+ }
98
+ // Recursively traverse children
99
+ const childResults = state.children.map((childState, index) => {
100
+ const childSchema = schema.filters[index];
101
+ if (!childSchema) {
102
+ throw new Error(`Missing schema for child at index ${index}`);
103
+ }
104
+ return traverseFilterSchemaAndState(childSchema, childState, handlers);
105
+ });
106
+
107
+ return handlers.or(schema, state, childResults);
108
+ }
109
+
110
+ case 'not': {
111
+ const state = stateNode as FilterFormState & { type: 'not' };
112
+ const schema = schemaNode
113
+ if (schema.type !== 'not') {
114
+ throw new Error(`Schema type mismatch: expected 'not', got '${schema.type}'`);
115
+ }
116
+ // Recursively traverse the child
117
+ const childResult = traverseFilterSchemaAndState(schema.filter, state.child, handlers);
118
+ return handlers.not(schema, state, childResult);
119
+ }
120
+
121
+ default:
122
+ throw new Error(`Unknown state node type: ${(stateNode)}`);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Helper to serialize a FilterFormState node, converting Date objects to ISO strings
128
+ */
129
+ export function makeFilterFormStateSerializable(node: FilterFormState): FilterFormState {
130
+ return mapFilterFormState(node, (value) => {
131
+ if (value instanceof Date) {
132
+ return value.toISOString();
133
+ }
134
+ return value;
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Serialize a FilterState Map to JSON object for storage
140
+ */
141
+ export function serializeFilterFormStateMap(state: FilterState): Record<string, any> {
142
+ return Object.fromEntries(
143
+ Array.from(state.entries())
144
+ .map(([id, node]) => [id, makeFilterFormStateSerializable(node)])
145
+ );
146
+ }
147
+
148
+ /**
149
+ * Rehydrate a single filter's stored state using its schema expression.
150
+ * Safely returns the original node on any mismatch / error.
151
+ */
152
+ function rehydrateFilterStateForSchema(expression: FilterExpr, stored: FilterFormState): FilterFormState {
153
+ return traverseFilterSchemaAndState<FilterFormState>(expression, stored, {
154
+ leaf: (schemaLeaf, stateLeaf) => {
155
+ let value = stateLeaf.value;
156
+ if (schemaLeaf.value.type === 'date' && typeof value === 'string') {
157
+ const date = new Date(value);
158
+ if (!isNaN(date.getTime())) {
159
+ value = date;
160
+ }
161
+ }
162
+ return { type: 'leaf', value };
163
+ },
164
+ and: (_schemaAnd, _stateAnd, childResults) => ({ type: 'and', children: childResults }),
165
+ or: (_schemaOr, _stateOr, childResults) => ({ type: 'or', children: childResults }),
166
+ not: (_schemaNot, _stateNot, childResult) => ({ type: 'not', child: childResult })
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Parse serialized filter state (object keyed by filter id) back into a FilterState Map,
172
+ * converting date string values to Date objects by consulting the filter schema.
173
+ */
174
+ export function parseFilterFormState(serializedState: any, schema: FilterSchemasAndGroups): FilterState {
175
+ return new Map(
176
+ schema.filters.map(filter => {
177
+ const raw = serializedState ? serializedState[filter.id] : undefined;
178
+ if (raw && typeof raw === 'object' && 'type' in raw) {
179
+ return [filter.id, rehydrateFilterStateForSchema(filter.expression, raw as FilterFormState)] as [string, FilterFormState];
180
+ }
181
+ // If invalid/missing, fall back to an empty initialized state derived from schema
182
+ return [filter.id, buildInitialFormState(filter.expression, FormStateInitMode.Empty)] as [string, FilterFormState];
183
+ })
184
+ );
185
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import {
5
+ encodeFilterState,
6
+ decodeFilterState
7
+ } from './filter-sharing';
8
+ import { FilterState } from './state';
9
+
10
+ describe('filter-sharing', () => {
11
+ const mockFilterState: FilterState = new Map([
12
+ ['name-filter', {
13
+ type: 'leaf',
14
+ field: 'name',
15
+ value: 'test',
16
+ control: { type: 'text' },
17
+ filterType: 'equals'
18
+ }],
19
+ ['date-filter', {
20
+ type: 'leaf',
21
+ field: 'date',
22
+ value: new Date('2023-01-01'),
23
+ control: { type: 'date' },
24
+ filterType: 'greaterThanOrEqual'
25
+ }]
26
+ ]);
27
+
28
+ describe('encodeFilterState', () => {
29
+ it('should encode filter state to base64 URL-safe string', () => {
30
+ const encoded = encodeFilterState(mockFilterState);
31
+
32
+ expect(typeof encoded).toBe('string');
33
+ expect(encoded.length).toBeGreaterThan(0);
34
+ // Should not contain URL-unsafe characters
35
+ expect(encoded).not.toMatch(/[+/=]/);
36
+ });
37
+
38
+ it('should handle empty filter state', () => {
39
+ const encoded = encodeFilterState(new Map());
40
+ expect(typeof encoded).toBe('string');
41
+ });
42
+
43
+ it('should handle complex nested filters', () => {
44
+ const complexFilter: FilterState = new Map([
45
+ ['complex-filter', {
46
+ type: 'and',
47
+ children: [
48
+ {
49
+ type: 'leaf',
50
+ field: 'name',
51
+ value: 'test',
52
+ control: { type: 'text' },
53
+ filterType: 'equals'
54
+ },
55
+ {
56
+ type: 'not',
57
+ child: {
58
+ type: 'leaf',
59
+ field: 'status',
60
+ value: 'inactive',
61
+ control: { type: 'text' },
62
+ filterType: 'equals'
63
+ },
64
+ filterType: 'not'
65
+ }
66
+ ],
67
+ filterType: 'and'
68
+ }]
69
+ ]);
70
+
71
+ expect(() => encodeFilterState(complexFilter)).not.toThrow();
72
+ });
73
+ });
74
+
75
+ describe('decodeFilterState', () => {
76
+ it('should decode base64 string back to filter state', () => {
77
+ const encoded = encodeFilterState(mockFilterState);
78
+ const decoded = decodeFilterState(encoded);
79
+
80
+ expect(typeof decoded).toBe('object');
81
+ expect(decoded).not.toBeNull();
82
+ expect(Array.isArray(decoded)).toBe(false);
83
+ expect(Object.keys(decoded)).toHaveLength(2);
84
+ });
85
+
86
+ it('should handle invalid base64 strings', () => {
87
+ // Mock console.error to suppress expected error output in tests
88
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
89
+
90
+ expect(() => decodeFilterState('invalid-base64')).toThrow();
91
+
92
+ // Restore console.error
93
+ consoleSpy.mockRestore();
94
+ });
95
+
96
+ it('should handle empty string', () => {
97
+ // Mock console.error to suppress expected error output in tests
98
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
99
+
100
+ expect(() => decodeFilterState('')).toThrow();
101
+
102
+ // Restore console.error
103
+ consoleSpy.mockRestore();
104
+ });
105
+ });
106
+
107
+ describe('round-trip encoding/decoding', () => {
108
+ it('should preserve filter state through encode/decode cycle', () => {
109
+ const encoded = encodeFilterState(mockFilterState);
110
+ const decoded: any = decodeFilterState(encoded);
111
+
112
+ // The decoded state should be an object with filter ID keys
113
+ expect(typeof decoded).toBe('object');
114
+ expect(Array.isArray(decoded)).toBe(false);
115
+ expect(Object.keys(decoded)).toHaveLength(2);
116
+
117
+ // Check that both filters are present with their correct data
118
+ expect(decoded['name-filter']).toEqual({
119
+ type: 'leaf',
120
+ field: 'name',
121
+ value: 'test',
122
+ control: { type: 'text' },
123
+ filterType: 'equals'
124
+ });
125
+
126
+ expect(decoded['date-filter']).toEqual({
127
+ type: 'leaf',
128
+ field: 'date',
129
+ value: '2023-01-01T00:00:00.000Z', // Date serialized as ISO string
130
+ control: { type: 'date' },
131
+ filterType: 'greaterThanOrEqual'
132
+ });
133
+ });
134
+ });
135
+ });
@@ -0,0 +1,118 @@
1
+ import { serializeFilterFormStateMap } from './filter-form-state';
2
+ import { FilterState } from './state';
3
+
4
+ /**
5
+ * Encode filter state to a base64 URL-safe string
6
+ */
7
+ export function encodeFilterState(filterState: FilterState): string {
8
+ try {
9
+ const serializedState = serializeFilterFormStateMap(filterState);
10
+ const jsonString = JSON.stringify(serializedState);
11
+ // Convert to base64 and make it URL-safe
12
+ return btoa(jsonString)
13
+ .replace(/\+/g, '-')
14
+ .replace(/\//g, '_')
15
+ .replace(/=/g, '');
16
+ } catch (error) {
17
+ console.error('Failed to encode filter state:', error);
18
+ throw new Error('Failed to encode filter state');
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Decode filter state from a base64 URL-safe string
24
+ */
25
+ export function decodeFilterState(encodedState: string): any[] {
26
+ try {
27
+ // Restore base64 padding and convert back from URL-safe
28
+ const base64 = encodedState
29
+ .replace(/-/g, '+')
30
+ .replace(/_/g, '/')
31
+ .padEnd(encodedState.length + (4 - encodedState.length % 4) % 4, '=');
32
+
33
+ const jsonString = atob(base64);
34
+ return JSON.parse(jsonString);
35
+ } catch (error) {
36
+ console.error('Failed to decode filter state:', error);
37
+ throw new Error('Failed to decode filter state');
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Create a shareable URL with the current filter state
43
+ */
44
+ const FILTER_PARAM = 'dtv-filter-state';
45
+
46
+ export function createShareableUrl(filterState: FilterState): string {
47
+ try {
48
+ const encodedFilter = encodeFilterState(filterState);
49
+ const url = new URL(window.location.href);
50
+ url.searchParams.set(FILTER_PARAM, encodedFilter);
51
+ return url.toString();
52
+ } catch (error) {
53
+ console.error('Failed to create shareable URL:', error);
54
+ throw new Error('Failed to create shareable URL');
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Copy URL to clipboard
60
+ */
61
+ export async function copyToClipboard(text: string): Promise<void> {
62
+ try {
63
+ if (navigator.clipboard && navigator.clipboard.writeText) {
64
+ await navigator.clipboard.writeText(text);
65
+ } else {
66
+ // Fallback for older browsers
67
+ const textArea = document.createElement('textarea');
68
+ textArea.value = text;
69
+ textArea.style.position = 'fixed';
70
+ textArea.style.opacity = '0';
71
+ document.body.appendChild(textArea);
72
+ textArea.focus();
73
+ textArea.select();
74
+ document.execCommand('copy');
75
+ document.body.removeChild(textArea);
76
+ }
77
+ } catch (error) {
78
+ console.error('Failed to copy to clipboard:', error);
79
+ throw new Error('Failed to copy to clipboard');
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Get filter state from URL parameters
85
+ */
86
+ export function getFilterFromUrl(): any[] | null {
87
+ try {
88
+ const params = new URLSearchParams(window.location.search);
89
+ const encodedFilter = params.get(FILTER_PARAM);
90
+ if (!encodedFilter) return null;
91
+ return decodeFilterState(encodedFilter);
92
+ } catch (error) {
93
+ console.warn('Failed to parse filter from URL:', error);
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Remove filter parameter from URL without page reload
100
+ */
101
+ export function clearFilterFromUrl(): void {
102
+ const url = new URL(window.location.href);
103
+ url.searchParams.delete(FILTER_PARAM);
104
+ window.history.replaceState({}, '', url.toString());
105
+ }
106
+
107
+ export function setFilterInUrl(filterState: FilterState): void {
108
+ try {
109
+ const encoded = encodeFilterState(filterState);
110
+ const url = new URL(window.location.href);
111
+ if (url.searchParams.get(FILTER_PARAM) !== encoded) {
112
+ url.searchParams.set(FILTER_PARAM, encoded);
113
+ window.history.replaceState({}, '', url.toString());
114
+ }
115
+ } catch (e) {
116
+ console.warn('Failed to set filter in URL', e);
117
+ }
118
+ }
@@ -0,0 +1,194 @@
1
+ import * as React from 'react';
2
+
3
+ // Multi-field specification
4
+ export type FilterField =
5
+ | string // Single field: "name" or "user.email"
6
+ | { and: string[] } // AND multiple fields: { and: ["name", "title", "description"] }
7
+ | { or: string[] }; // OR multiple fields: { or: ["name", "title", "description"] }
8
+
9
+ // Transform result type - must return an object with optional field/value fields
10
+ export type TransformResult = { field?: string; value?: unknown };
11
+
12
+ // Transform functions for filter expressions
13
+ export type FilterTransform = {
14
+ toQuery?: (input: unknown) => TransformResult;
15
+ };
16
+
17
+ export type FilterControl =
18
+ | { type: 'text'; label?: string; placeholder?: string; initialValue?: any }
19
+ | { type: 'number'; label?: string; placeholder?: string; initialValue?: any }
20
+ | { type: 'date'; label?: string; placeholder?: string; initialValue?: any }
21
+ | { type: 'dropdown'; label?: string; items: { label: string; value: any }[]; initialValue?: any }
22
+ | { type: 'multiselect'; label?: string; items: { label: string; value: any }[], filterable?: boolean; initialValue?: any }
23
+ | { type: 'customOperator'; label?: string; operators: { label: string; value: string }[]; valueControl: FilterControl; initialValue?: any }
24
+ | { type: 'custom'; component: React.ComponentType<any>; props?: Record<string, any>; label?: string; initialValue?: any };
25
+
26
+ export type FilterExpr =
27
+ | { type: 'equals'; field: FilterField; value: FilterControl; transform?: FilterTransform }
28
+ | { type: 'notEquals'; field: FilterField; value: FilterControl; transform?: FilterTransform }
29
+ | { type: 'greaterThan'; field: FilterField; value: FilterControl; transform?: FilterTransform }
30
+ | { type: 'lessThan'; field: FilterField; value: FilterControl; transform?: FilterTransform }
31
+ | { type: 'greaterThanOrEqual'; field: FilterField; value: FilterControl; transform?: FilterTransform }
32
+ | { type: 'lessThanOrEqual'; field: FilterField; value: FilterControl; transform?: FilterTransform }
33
+ | { type: 'in'; field: FilterField; value: FilterControl; transform?: FilterTransform }
34
+ | { type: 'notIn'; field: FilterField; value: FilterControl; transform?: FilterTransform }
35
+ | { type: 'like'; field: FilterField; value: FilterControl; transform?: FilterTransform }
36
+ | { type: 'iLike'; field: FilterField; value: FilterControl; transform?: FilterTransform }
37
+ | { type: 'isNull'; field: FilterField; value: FilterControl; transform?: FilterTransform }
38
+ | { type: 'and'; filters: FilterExpr[] }
39
+ | { type: 'or'; filters: FilterExpr[] }
40
+ | { type: 'not'; filter: FilterExpr };
41
+
42
+
43
+
44
+ // Predefined list of supported operators for customOperator controls
45
+ export const SUPPORTED_OPERATORS = [
46
+ { label: 'equals', value: '_eq' },
47
+ { label: 'not equals', value: '_neq' },
48
+ { label: 'greater than', value: '_gt' },
49
+ { label: 'less than', value: '_lt' },
50
+ { label: 'greater than or equal', value: '_gte' },
51
+ { label: 'less than or equal', value: '_lte' },
52
+ { label: 'in', value: '_in' },
53
+ { label: 'not in', value: '_nin' },
54
+ { label: 'like', value: '_like' },
55
+ { label: 'ilike', value: '_ilike' },
56
+ { label: 'is null', value: '_is_null' }
57
+ ];
58
+
59
+ // Helper functions for building FilterControl values
60
+ export const filterControl = {
61
+ text: (options?: { label?: string; placeholder?: string }): FilterControl => ({ type: 'text', ...options }),
62
+ number: (options?: { label?: string; placeholder?: string; initialValue?: any }): FilterControl => ({ type: 'number', ...options }),
63
+ date: (options?: { label?: string; placeholder?: string; initialValue?: any }): FilterControl => ({ type: 'date', ...options }),
64
+ dropdown: (options: { label?: string; items: { label: string; value: any }[] }): FilterControl => ({ type: 'dropdown', ...options }),
65
+ multiselect: (options: { label?: string; items: { label: string; value: any }[], filterable?: boolean }): FilterControl => ({ type: 'multiselect', ...options }),
66
+ customOperator: (options: { label?: string; operators: { label: string; value: string }[]; valueControl: FilterControl }): FilterControl => ({ type: 'customOperator', ...options }),
67
+ custom: (component: React.ComponentType<any>, options?: { label?: string; props?: Record<string, any> }): FilterControl => ({ type: 'custom', component, ...options }),
68
+ };
69
+
70
+ // Helper functions for building FilterExpr values
71
+ export const filterExpr = {
72
+ equals: (field: FilterField, value: FilterControl, transform?: FilterTransform): FilterExpr => ({ type: 'equals', field, value, ...(transform && { transform }) }),
73
+ notEquals: (field: FilterField, value: FilterControl, transform?: FilterTransform): FilterExpr => ({ type: 'notEquals', field, value, ...(transform && { transform }) }),
74
+ greaterThan: (field: FilterField, value: FilterControl, transform?: FilterTransform): FilterExpr => ({ type: 'greaterThan', field, value, ...(transform && { transform }) }),
75
+ lessThan: (field: FilterField, value: FilterControl, transform?: FilterTransform): FilterExpr => ({ type: 'lessThan', field, value, ...(transform && { transform }) }),
76
+ greaterThanOrEqual: (field: FilterField, value: FilterControl, transform?: FilterTransform): FilterExpr => ({ type: 'greaterThanOrEqual', field, value, ...(transform && { transform }) }),
77
+ lessThanOrEqual: (field: FilterField, value: FilterControl, transform?: FilterTransform): FilterExpr => ({ type: 'lessThanOrEqual', field, value, ...(transform && { transform }) }),
78
+ in: (field: FilterField, value: FilterControl, transform?: FilterTransform): FilterExpr => ({ type: 'in', field, value, ...(transform && { transform }) }),
79
+ notIn: (field: FilterField, value: FilterControl, transform?: FilterTransform): FilterExpr => ({ type: 'notIn', field, value, ...(transform && { transform }) }),
80
+ like: (field: FilterField, value: FilterControl, transform?: FilterTransform): FilterExpr => ({ type: 'like', field, value, ...(transform && { transform }) }),
81
+ iLike: (field: FilterField, value: FilterControl, transform?: FilterTransform): FilterExpr => ({ type: 'iLike', field, value, ...(transform && { transform }) }),
82
+ isNull: (field: FilterField, value: FilterControl, transform?: FilterTransform): FilterExpr => ({ type: 'isNull', field, value, ...(transform && { transform }) }),
83
+ and: (filters: FilterExpr[]): FilterExpr => ({ type: 'and', filters }),
84
+ or: (filters: FilterExpr[]): FilterExpr => ({ type: 'or', filters }),
85
+ not: (filter: FilterExpr): FilterExpr => ({ type: 'not', filter }),
86
+ range: (field: FilterField, control: (options: any) => FilterControl, transform?: FilterTransform): FilterExpr =>
87
+ filterExpr.and([
88
+ filterExpr.greaterThanOrEqual(field, control({ placeholder: 'from' }), transform),
89
+ filterExpr.lessThanOrEqual(field, control({ placeholder: 'to' }), transform)
90
+ ]),
91
+ allOperators: SUPPORTED_OPERATORS,
92
+ };
93
+
94
+ // Helper to check if a FilterExpr is a leaf node
95
+ export function isLeaf(expr: FilterExpr): expr is Extract<FilterExpr, { field: FilterField; value: FilterControl }> {
96
+ return 'field' in expr && 'value' in expr;
97
+ }
98
+
99
+ // Recursively transform the value of every leaf node in a FilterExpr tree
100
+ export function transformFilterExprValues(expr: FilterExpr, fn: (value: FilterControl) => FilterControl): FilterExpr {
101
+ if (expr.type === 'and' || expr.type === 'or') {
102
+ return { ...expr, filters: expr.filters.map(e => transformFilterExprValues(e, fn)) };
103
+ } else if (expr.type === 'not') {
104
+ return { ...expr, filter: transformFilterExprValues(expr.filter, fn) };
105
+ } else {
106
+ return { ...expr, value: fn(expr.value) };
107
+ }
108
+ }
109
+
110
+ export type FilterExprFieldNode = Extract<FilterExpr, { field: FilterField; value: FilterControl }>;
111
+ export type FilterExprFilterListNode = Extract<FilterExpr, { filters: FilterExpr[] }>;
112
+ export type FilterExprNotNode = Extract<FilterExpr, { filter: FilterExpr }>;
113
+
114
+ // Recursively get all field nodes from a FilterExpr tree
115
+ export function getFieldNodes(expr: FilterExpr): FilterExprFieldNode[] {
116
+ const nodes: FilterExprFieldNode[] = [];
117
+ if (isLeaf(expr)) {
118
+ nodes.push(expr);
119
+ } else if (expr.type === 'and' || expr.type === 'or') {
120
+ for (const filter of expr.filters) {
121
+ nodes.push(...getFieldNodes(filter));
122
+ }
123
+ } else if (expr.type === 'not') {
124
+ nodes.push(...getFieldNodes(expr.filter));
125
+ }
126
+ return nodes;
127
+ }
128
+
129
+
130
+
131
+ export type FilterFieldGroup = {
132
+ name: string;
133
+ label: string | null;
134
+ };
135
+
136
+ export type FilterSchema = {
137
+ id: string; // unique identifier for the filter
138
+ label: string;
139
+ expression: FilterExpr;
140
+ group: string; // group name
141
+ aiGenerated: boolean;
142
+ };
143
+
144
+ export type FilterId = FilterSchema['id'];
145
+
146
+ export type FilterSchemasAndGroups = {
147
+ groups: FilterFieldGroup[];
148
+ filters: FilterSchema[];
149
+ };
150
+
151
+ /**
152
+ * Attempts to deserialize a plain JSON object into a FilterExpr.
153
+ * Does not support custom filters or transformation functions.
154
+ */
155
+ export function filterExprFromJSON(json: any): FilterExpr | null {
156
+ if (!json || typeof json !== 'object' || !json.type) return null;
157
+ switch (json.type) {
158
+ case 'equals':
159
+ case 'notEquals':
160
+ case 'greaterThan':
161
+ case 'lessThan':
162
+ case 'greaterThanOrEqual':
163
+ case 'lessThanOrEqual':
164
+ case 'in':
165
+ case 'notIn':
166
+ case 'like':
167
+ case 'iLike':
168
+ case 'isNull': {
169
+ // Only support basic FilterControl types (text, number, date, dropdown, multiselect)
170
+ if (!json.field || !json.value || typeof json.value !== 'object' || !json.value.type) return null;
171
+ const allowedTypes = ['text', 'number', 'date', 'dropdown', 'multiselect'];
172
+ if (!allowedTypes.includes(json.value.type)) return null;
173
+ return {
174
+ type: json.type,
175
+ field: json.field,
176
+ value: json.value
177
+ } as FilterExpr;
178
+ }
179
+ case 'and':
180
+ case 'or': {
181
+ if (!Array.isArray(json.filters)) return null;
182
+ const children = json.filters.map(filterExprFromJSON).filter(Boolean) as FilterExpr[];
183
+ return { type: json.type, filters: children };
184
+ }
185
+ case 'not': {
186
+ if (!json.filter) return null;
187
+ const child = filterExprFromJSON(json.filter);
188
+ if (!child) return null;
189
+ return { type: 'not', filter: child };
190
+ }
191
+ default:
192
+ return null;
193
+ }
194
+ }