@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,67 @@
1
+ import { useState, useRef, useEffect } from 'react';
2
+ import { InputText } from 'primereact/inputtext';
3
+
4
+ interface SpeechInputProps {
5
+ value: string;
6
+ onChange: (value: string) => void;
7
+ }
8
+
9
+ function SpeechInput({ value, onChange }: SpeechInputProps) {
10
+ const [isListening, setIsListening] = useState(false);
11
+ const recognitionRef = useRef<SpeechRecognition | null>(null);
12
+
13
+ useEffect(() => {
14
+ if (!('webkitSpeechRecognition' in window || 'SpeechRecognition' in window)) return;
15
+ recognitionRef.current = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
16
+ recognitionRef.current.continuous = false;
17
+ recognitionRef.current.interimResults = false;
18
+ recognitionRef.current.lang = 'en-US';
19
+ recognitionRef.current.onresult = (event: SpeechRecognitionEvent) => {
20
+ const transcript = event.results[0][0].transcript;
21
+ onChange(transcript);
22
+ setIsListening(false);
23
+ };
24
+ recognitionRef.current.onend = () => setIsListening(false);
25
+ recognitionRef.current.onerror = () => setIsListening(false);
26
+ }, [onChange]);
27
+
28
+ const handleMicClick = () => {
29
+ if (!recognitionRef.current) return;
30
+ if (isListening) {
31
+ recognitionRef.current.stop();
32
+ setIsListening(false);
33
+ } else {
34
+ onChange('');
35
+ setIsListening(true);
36
+ recognitionRef.current.start();
37
+ }
38
+ };
39
+
40
+ return (
41
+ <div className="tw:flex tw:flex-1 tw:items-center tw:gap-2">
42
+ <InputText
43
+ value={value}
44
+ onChange={e => onChange(e.target.value)}
45
+ placeholder="Speak or type here..."
46
+ className="tw:flex-1"
47
+ />
48
+ <button
49
+ type="button"
50
+ onClick={handleMicClick}
51
+ className={`tw:rounded-full tw:p-2 tw:border ${isListening ? 'tw:bg-red-500 tw:text-white' : 'tw:bg-gray-200 tw:text-black'} tw:transition`}
52
+ aria-label={isListening ? 'Stop listening' : 'Start listening'}
53
+ >
54
+ {/* Mic icon: classic microphone shape */}
55
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="tw:h-5">
56
+ <rect x="9" y="2" width="6" height="12" rx="3" />
57
+ <path d="M5 10v2a7 7 0 0 0 14 0v-2" />
58
+ <line x1="12" y1="19" x2="12" y2="22" />
59
+ <line x1="8" y1="22" x2="16" y2="22" />
60
+ </svg>
61
+ </button>
62
+ <span className="tw:text-s tw:ml-2">{isListening ? 'Listening...' : ''}</span>
63
+ </div>
64
+ );
65
+ }
66
+
67
+ export default SpeechInput;
@@ -0,0 +1,119 @@
1
+ import React from 'react';
2
+ import { DataTable, DataTableExportFunctionEvent } from 'primereact/datatable';
3
+ import { Column } from 'primereact/column';
4
+ import { Tag } from 'primereact/tag';
5
+ import { ColumnDefinition } from '../framework/column-definition';
6
+ import { NoRowsComponent } from '../framework/view';
7
+ import { FlexRow, FlexColumn, DateTime } from '../framework/cell-renderer-components/LayoutHelpers';
8
+ import { CurrencyAmount } from '../framework/cell-renderer-components/CurrencyAmount';
9
+ import { majorToMinor, minorToMajor } from '../framework/currency';
10
+ import { Mapping } from '../framework/cell-renderer-components/Mapping';
11
+ import { Link } from '../framework/cell-renderer-components/Link';
12
+ import { FilterState, getFilterStateById, setFilterStateById } from '../framework/state';
13
+ import { FilterFormState } from '../framework/filter-form-state';
14
+
15
+ type TableProps = {
16
+ viewId: string;
17
+ columns: ColumnDefinition[];
18
+ data: Record<string, unknown>[][]; // Array of rows, each row is an array of values for the columns
19
+ noRowsComponent?: NoRowsComponent; // The noRowsComponent function
20
+ setFilterState: (filterState: FilterState) => void; // Function to update filter state
21
+ filterState: FilterState; // Current filter state
22
+ triggerRefetch: () => void; // Function to trigger data refetch
23
+ ref?: React.Ref<DataTable<any>>; // An outside ref to the DataTable instance
24
+ };
25
+
26
+ function Table({
27
+ viewId,
28
+ columns,
29
+ data,
30
+ noRowsComponent,
31
+ setFilterState,
32
+ filterState,
33
+ triggerRefetch,
34
+ ref
35
+ }: TableProps) {
36
+ // Create wrapped setFilterState that provides current state to updater function
37
+ const wrappedSetFilterState = (updater: (currentState: FilterState) => FilterState) => {
38
+ const newState = updater(filterState);
39
+ setFilterState(newState);
40
+ };
41
+
42
+ // Instantiate the noRowsComponent if it exists
43
+ const noDataRowsComponent = noRowsComponent
44
+ ? noRowsComponent({
45
+ filterState,
46
+ setFilterState: wrappedSetFilterState,
47
+ applyFilters: triggerRefetch,
48
+ updateFilterById: (filterId: string, updater: (currentValue: FilterFormState) => FilterFormState) => {
49
+ wrappedSetFilterState(currentState => {
50
+ try {
51
+ const currentFilter = getFilterStateById(currentState, filterId);
52
+ const updatedFilter = updater(currentFilter);
53
+ if (updatedFilter === currentFilter) return currentState;
54
+ return setFilterStateById(currentState, filterId, updatedFilter);
55
+ } catch {
56
+ return currentState; // filter missing -> no change
57
+ }
58
+ });
59
+ }
60
+ })
61
+ : null;
62
+
63
+ const exportFunction = (event: DataTableExportFunctionEvent<any>) => {
64
+ const data = (event.rowData as [])[Number(event.field)]
65
+ return data[Object.keys(data)[0]]
66
+ }
67
+
68
+ return (
69
+ <DataTable
70
+ ref={ref}
71
+ value={data}
72
+ tableStyle={{ minWidth: '50rem' }}
73
+ showGridlines
74
+ stripedRows
75
+ size='small'
76
+ emptyMessage={noDataRowsComponent}
77
+ exportFunction={exportFunction}
78
+ exportFilename={viewId}
79
+ >
80
+ {columns.map((column, columnIndex) => (
81
+ <Column
82
+ key={columnIndex}
83
+ field={columnIndex.toString()}
84
+ header={column.name}
85
+ body={rowData => column.cellRenderer({
86
+ data: rowData[columnIndex],
87
+ setFilterState: wrappedSetFilterState,
88
+ applyFilters: triggerRefetch,
89
+ updateFilterById: (filterId: string, updater: (currentValue: FilterFormState) => FilterFormState) => {
90
+ wrappedSetFilterState(currentState => {
91
+ try {
92
+ const currentFilter = getFilterStateById(currentState, filterId);
93
+ const updatedFilter = updater(currentFilter);
94
+ if (updatedFilter === currentFilter) return currentState;
95
+ return setFilterStateById(currentState, filterId, updatedFilter);
96
+ } catch {
97
+ return currentState;
98
+ }
99
+ });
100
+ },
101
+ createElement: React.createElement,
102
+ components: {
103
+ Badge: Tag,
104
+ FlexRow,
105
+ FlexColumn,
106
+ Mapping,
107
+ DateTime,
108
+ CurrencyAmount,
109
+ Link
110
+ },
111
+ currency: { minorToMajor, majorToMinor }
112
+ })}
113
+ />
114
+ ))}
115
+ </DataTable>
116
+ );
117
+ }
118
+
119
+ export default Table;
@@ -0,0 +1,40 @@
1
+ import React from 'react';
2
+ import { Button } from 'primereact/button';
3
+
4
+ interface TablePaginationProps {
5
+ onPageChange: () => void;
6
+ onPrevPage: () => void;
7
+ hasNextPage: boolean;
8
+ hasPrevPage: boolean;
9
+ currentPage: number; // 0-based
10
+ rowsPerPage: number;
11
+ actualRows: number; // number of rows in the current page
12
+ }
13
+
14
+ const TablePagination: React.FC<TablePaginationProps> = ({ onPageChange, onPrevPage, hasNextPage, hasPrevPage, currentPage, rowsPerPage, actualRows }) => {
15
+ const start = currentPage * rowsPerPage + 1;
16
+ const end = start + actualRows - 1;
17
+ return (
18
+ <div className="tw:flex tw:justify-center tw:items-center tw:mt-4 tw:gap-2">
19
+ <Button
20
+ rounded
21
+ icon="pi pi-angle-left"
22
+ onClick={onPrevPage}
23
+ disabled={!hasPrevPage}
24
+ className="p-button-sm"
25
+ data-testid="pagination-prev"
26
+ />
27
+ <span className="tw:text-s tw:text-gray-600 tw:mx-3" data-testid="pagination-page">{start}-{end}</span>
28
+ <Button
29
+ rounded
30
+ icon="pi pi-angle-right"
31
+ onClick={onPageChange}
32
+ disabled={!hasNextPage}
33
+ className="p-button-sm"
34
+ data-testid="pagination-next"
35
+ />
36
+ </div>
37
+ );
38
+ };
39
+
40
+ export default TablePagination;
@@ -0,0 +1,270 @@
1
+ // src/components/aiAssistant.test.ts
2
+ import { FilterSchemasAndGroups } from '../framework/filters';
3
+ import { buildInitialFormState } from '../framework/state';
4
+ import { mergeFilterFormState } from './aiAssistant';
5
+
6
+
7
+ describe('aiAssistant customOperator patching', () => {
8
+ it('should patch customOperator values when AI returns a plain string', () => {
9
+ // Create a filter schema with a customOperator
10
+ const filterSchema: FilterSchemasAndGroups = {
11
+ groups: [{ name: 'test', label: 'Test Group' }],
12
+ filters: [
13
+ {
14
+ id: 'test-filter-1',
15
+ label: 'Test Filter',
16
+ group: 'test',
17
+ expression: {
18
+ type: 'equals',
19
+ field: 'test',
20
+ value: {
21
+ type: 'customOperator',
22
+ operators: [
23
+ { label: 'Equals', value: '_eq' },
24
+ { label: 'Not Equals', value: '_neq' }
25
+ ],
26
+ valueControl: { type: 'text' }
27
+ }
28
+ },
29
+ aiGenerated: false
30
+ }
31
+ ]
32
+ };
33
+
34
+ // Build empty state
35
+ const emptyState = buildInitialFormState(filterSchema.filters[0].expression);
36
+
37
+ // AI returns a plain string instead of an object
38
+ const aiState = {
39
+ type: 'leaf',
40
+ field: 'test',
41
+ value: 'test_value'
42
+ };
43
+
44
+ // Apply our merge function
45
+ const result = mergeFilterFormState(filterSchema.filters[0].expression, emptyState, aiState);
46
+
47
+ // The result should have the string wrapped in an object with the default operator
48
+ expect(result.type).toBe('leaf');
49
+ if (result.type === 'leaf') {
50
+ expect(result.value).toEqual({
51
+ operator: '_eq',
52
+ value: 'test_value'
53
+ });
54
+ }
55
+ });
56
+
57
+ it('should not modify values when AI returns proper objects', () => {
58
+ // Create a filter schema with a text field
59
+ const filterSchema: FilterSchemasAndGroups = {
60
+ groups: [{ name: 'test', label: 'Test Group' }],
61
+ filters: [
62
+ {
63
+ id: 'test-filter-2',
64
+ label: 'Test Filter',
65
+ group: 'test',
66
+ expression: {
67
+ type: 'equals',
68
+ field: 'test_field',
69
+ value: {
70
+ type: 'customOperator',
71
+ operators: [
72
+ { label: 'equals', value: '_eq' },
73
+ { label: 'not equals', value: '_neq' }
74
+ ],
75
+ valueControl: { type: 'text' }
76
+ }
77
+ },
78
+ aiGenerated: false
79
+ }
80
+ ]
81
+ };
82
+
83
+ // Build initial empty state
84
+ const emptyState = buildInitialFormState(filterSchema.filters[0].expression);
85
+
86
+ // Simulate AI returning a proper object
87
+ const aiState = {
88
+ type: 'leaf',
89
+ field: 'test_field',
90
+ value: {
91
+ operator: '_neq',
92
+ value: 'test_value'
93
+ }
94
+ };
95
+
96
+ // Apply our merge function
97
+ const result = mergeFilterFormState(filterSchema.filters[0].expression, emptyState, aiState);
98
+
99
+ // The result should preserve the original object
100
+ expect(result.type).toBe('leaf');
101
+ if (result.type === 'leaf') {
102
+ expect(result.value).toEqual({
103
+ operator: '_neq',
104
+ value: 'test_value'
105
+ });
106
+ }
107
+ });
108
+
109
+ it('should not modify non-customOperator values', () => {
110
+ // Create a filter schema with a regular text filter
111
+ const filterSchema: FilterSchemasAndGroups = {
112
+ groups: [{ name: 'test', label: 'Test Group' }],
113
+ filters: [
114
+ {
115
+ id: 'test-filter-3',
116
+ label: 'Test Filter',
117
+ group: 'test',
118
+ expression: {
119
+ type: 'equals',
120
+ field: 'test_field',
121
+ value: {
122
+ type: 'text'
123
+ }
124
+ },
125
+ aiGenerated: false
126
+ }
127
+ ]
128
+ };
129
+
130
+ // Build initial empty state
131
+ const emptyState = buildInitialFormState(filterSchema.filters[0].expression);
132
+
133
+ // Simulate AI returning a string value for a text filter
134
+ const aiState = {
135
+ type: 'leaf',
136
+ field: 'test_field',
137
+ value: 'test_value'
138
+ };
139
+
140
+ // Apply our merge function
141
+ const result = mergeFilterFormState(filterSchema.filters[0].expression, emptyState, aiState);
142
+
143
+ // The result should preserve the original string value
144
+ expect(result.type).toBe('leaf');
145
+ if (result.type === 'leaf') {
146
+ expect(result.value).toBe('test_value');
147
+ }
148
+ });
149
+
150
+ it('should map NOT wrapped customOperator to not-equals operator', () => {
151
+ // Create a filter schema with a customOperator that has not-equals
152
+ const filterSchema: FilterSchemasAndGroups = {
153
+ groups: [{ name: 'test', label: 'Test Group' }],
154
+ filters: [
155
+ {
156
+ id: 'test-filter-not',
157
+ label: 'Test Filter',
158
+ group: 'test',
159
+ expression: {
160
+ type: 'equals',
161
+ field: 'test',
162
+ value: {
163
+ type: 'customOperator',
164
+ operators: [
165
+ { label: 'Equals', value: '_eq' },
166
+ { label: 'Not Equals', value: '_neq' }
167
+ ],
168
+ valueControl: { type: 'text' }
169
+ }
170
+ },
171
+ aiGenerated: false
172
+ }
173
+ ]
174
+ };
175
+
176
+ // Build empty state
177
+ const emptyState = buildInitialFormState(filterSchema.filters[0].expression);
178
+
179
+ // AI returns a NOT wrapped around a leaf with string value
180
+ const aiState = {
181
+ type: 'not',
182
+ child: {
183
+ type: 'leaf',
184
+ field: 'test',
185
+ value: 'test_value'
186
+ }
187
+ };
188
+
189
+ // Apply our merge function
190
+ const result = mergeFilterFormState(filterSchema.filters[0].expression, emptyState, aiState);
191
+
192
+ // The result should map to not-equals operator
193
+ expect(result.type).toBe('leaf');
194
+ if (result.type === 'leaf') {
195
+ expect(result.value).toEqual({
196
+ operator: '_neq',
197
+ value: 'test_value'
198
+ });
199
+ }
200
+ });
201
+
202
+ it('should collapse OR children into array for in filter', () => {
203
+ const filterSchema: FilterSchemasAndGroups = {
204
+ groups: [{ name: 'test', label: 'Test Group' }],
205
+ filters: [
206
+ {
207
+ id: 'in-filter',
208
+ label: 'In Filter',
209
+ group: 'test',
210
+ expression: {
211
+ type: 'in',
212
+ field: 'category',
213
+ value: { type: 'text' }
214
+ },
215
+ aiGenerated: false
216
+ }
217
+ ]
218
+ };
219
+
220
+ const emptyState = buildInitialFormState(filterSchema.filters[0].expression);
221
+ const aiState = {
222
+ type: 'or',
223
+ children: [
224
+ { type: 'leaf', field: 'category', value: 'A' },
225
+ { type: 'leaf', field: 'category', value: 'B' },
226
+ { type: 'leaf', field: 'category', value: 'A' } // duplicate to test uniqueness
227
+ ]
228
+ };
229
+
230
+ const result = mergeFilterFormState(filterSchema.filters[0].expression, emptyState, aiState);
231
+ expect(result.type).toBe('leaf');
232
+ if (result.type === 'leaf') {
233
+ expect(result.value).toEqual(['A', 'B']);
234
+ }
235
+ });
236
+
237
+ it('should collapse OR children into array for notIn filter', () => {
238
+ const filterSchema: FilterSchemasAndGroups = {
239
+ groups: [{ name: 'test', label: 'Test Group' }],
240
+ filters: [
241
+ {
242
+ id: 'nin-filter',
243
+ label: 'Not In Filter',
244
+ group: 'test',
245
+ expression: {
246
+ type: 'notIn',
247
+ field: 'status',
248
+ value: { type: 'text' }
249
+ },
250
+ aiGenerated: false
251
+ }
252
+ ]
253
+ };
254
+
255
+ const emptyState = buildInitialFormState(filterSchema.filters[0].expression);
256
+ const aiState = {
257
+ type: 'or',
258
+ children: [
259
+ { type: 'leaf', field: 'status', value: 'NEW' },
260
+ { type: 'leaf', field: 'status', value: 'ARCHIVED' }
261
+ ]
262
+ };
263
+
264
+ const result = mergeFilterFormState(filterSchema.filters[0].expression, emptyState, aiState);
265
+ expect(result.type).toBe('leaf');
266
+ if (result.type === 'leaf') {
267
+ expect(result.value).toEqual(['NEW', 'ARCHIVED']);
268
+ }
269
+ });
270
+ });