@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,291 @@
1
+ // src/components/aiAssistant.ts
2
+ import { FilterExpr, FilterSchemasAndGroups, FilterExprFieldNode } from '../framework/filters';
3
+ import { FilterFormState } from '../framework/filter-form-state';
4
+ import { buildInitialFormState, createDefaultFilterState, FormStateInitMode } from '../framework/state';
5
+ import { RefObject } from 'react';
6
+ import { Toast } from 'primereact/toast';
7
+ import { FilterState } from '../framework/state';
8
+
9
+ // --- Shared prompt and serialization helpers ---
10
+ export interface AIApi {
11
+ sendPrompt(
12
+ filterSchema: FilterSchemasAndGroups,
13
+ userPrompt: string,
14
+ setFormState: (state: FilterState) => void,
15
+ apiKey: string,
16
+ toast?: RefObject<Toast | null>
17
+ ): Promise<void>;
18
+ }
19
+
20
+ function sanitizeFilterExpr(expr: FilterExpr): object {
21
+ if (expr.type === 'and' || expr.type === 'or') {
22
+ return {
23
+ type: expr.type,
24
+ filters: expr.filters.map(sanitizeFilterExpr)
25
+ };
26
+ } else if (expr.type === 'not') {
27
+ return {
28
+ type: expr.type,
29
+ child: sanitizeFilterExpr(expr.filter)
30
+ };
31
+ } else {
32
+ // For dropdown and multiselect, include items
33
+ if ((expr.value.type === 'dropdown' || expr.value.type === 'multiselect')) {
34
+ return {
35
+ type: expr.type,
36
+ field: expr.field,
37
+ items: expr.value.items
38
+ };
39
+ }
40
+ return {
41
+ type: expr.type,
42
+ field: expr.field
43
+ };
44
+ }
45
+ }
46
+
47
+ function sanitizeFilterSchemaForAI(filterSchema: FilterSchemasAndGroups): object[] {
48
+ // Adapt to new schema shape
49
+ return filterSchema.filters.map((field) => ({
50
+ id: field.id,
51
+ expression: sanitizeFilterExpr(field.expression),
52
+ }));
53
+ }
54
+
55
+ function buildAiPrompt(filterSchema: FilterSchemasAndGroups, userPrompt: string): string {
56
+ const filterFormStateType = `type FilterFormState =
57
+ | { type: 'leaf'; value: any; }
58
+ | { type: 'and' | 'or'; children: FilterFormState[]; }
59
+ | { type: 'not'; child: FilterFormState; };`;
60
+ const sanitizedSchema = sanitizeFilterSchemaForAI(filterSchema);
61
+ const schemaStr = JSON.stringify(sanitizedSchema, null, 2);
62
+ const currentDate = new Date().toString();
63
+ return [
64
+ `Given the following filter schema (in JSON):`,
65
+ schemaStr,
66
+ '',
67
+ `And the following type definition for FilterFormState:`,
68
+ filterFormStateType,
69
+ '',
70
+ `The current date is: ${currentDate}`,
71
+ '',
72
+ `Generate a valid JSON object with filter IDs as keys and values containing filter state according to the filter expression in the schema, that matches a user request.`,
73
+ `For filter trees, preserve the structure of and/or/not according to the schema and the FilterFormState type.`,
74
+ `User request: ${userPrompt}`,
75
+ '',
76
+ `For date filters, always send the value as a plain string in standard date-time string format. Skip filters that are not relevant to the user request.`,
77
+ `Output only the object mapping filter IDs to FilterFormState, like: {"filter-id": {...filterFormState...}}`
78
+ ].join('\n');
79
+ }
80
+
81
+ // Helper to merge AI-generated filter object with current state
82
+ function mergeAiStateWithCurrent(currentState: FilterState, aiStateObject: Record<string, any>, filterSchema: FilterSchemasAndGroups): FilterState {
83
+ const newState = new Map(currentState);
84
+
85
+ // For each filter in the AI response, merge it with the corresponding current state
86
+ Object.entries(aiStateObject).forEach(([filterId, aiFilterState]) => {
87
+ const filterDef = filterSchema.filters.find(f => f.id === filterId);
88
+ if (filterDef) {
89
+ const currentFilterState = currentState.get(filterId) || buildInitialFormState(filterDef.expression);
90
+ const mergedFilterState = mergeFilterFormState(filterDef.expression, currentFilterState, aiFilterState);
91
+ newState.set(filterId, mergedFilterState);
92
+ }
93
+ });
94
+
95
+ return newState;
96
+ }
97
+
98
+ // Recursively merge AI state into existing FilterFormState using schema-guided traversal
99
+ export function mergeFilterFormState(schema: FilterExpr, currentState: FilterFormState, aiState: any): FilterFormState {
100
+ if (!aiState) return currentState;
101
+
102
+ // Patch: If schema expects an 'in' or 'notIn' array but AI produced an OR list of single values, collapse to array
103
+ // Example AI output (FilterFormState shape): { type: 'or', children: [ { type: 'leaf', value: 'a' }, { type: 'leaf', value: 'b' } ] }
104
+ // We convert it to a single leaf with value ['a','b'] so downstream logic treats it as an IN list.
105
+ if ((schema.type === 'in' || schema.type === 'notIn') && aiState.type === 'or' && Array.isArray(aiState.children)) {
106
+ const collectValues = (node: any, acc: any[]) => {
107
+ if (!node) return acc;
108
+ if (node.type === 'leaf') {
109
+ if (node.value !== undefined && node.value !== '') {
110
+ acc.push(node.value);
111
+ }
112
+ } else if (node.type === 'or' && Array.isArray(node.children)) {
113
+ node.children.forEach((c: any) => collectValues(c, acc));
114
+ }
115
+ return acc;
116
+ };
117
+ const values = Array.from(new Set(collectValues(aiState, [])));
118
+ return {
119
+ ...currentState,
120
+ type: 'leaf',
121
+ value: values
122
+ };
123
+ }
124
+
125
+ // Special case: AI returns NOT wrapped around a leaf for a customOperator field - convert to not-equals
126
+ if (currentState.type === 'leaf' && aiState.type === 'not' && aiState.child?.type === 'leaf') {
127
+ const schemaField = schema as FilterExprFieldNode;
128
+ const control = schemaField.value;
129
+
130
+ if (control.type === 'customOperator') {
131
+ const childValue = aiState.child.value;
132
+ const notEqualsOperator = control.operators?.find((op: any) =>
133
+ op.value.includes('neq') || op.value.includes('not_equals') || op.label.toLowerCase().includes('not equals')
134
+ );
135
+
136
+ if (notEqualsOperator) {
137
+ const value = typeof childValue === 'string' ?
138
+ { operator: notEqualsOperator.value, value: childValue } :
139
+ childValue;
140
+
141
+ return {
142
+ ...currentState,
143
+ value: value
144
+ };
145
+ }
146
+ }
147
+ }
148
+
149
+ if (currentState.type === 'leaf' && aiState.type === 'leaf') {
150
+ let value = aiState.value;
151
+
152
+ // Use control info from schema for customOperator
153
+ const schemaField = schema as FilterExprFieldNode;
154
+ const control = schemaField.value;
155
+
156
+ // Patch for 'in' and 'notIn' to ensure value is an array
157
+ if (schemaField.type === 'in' || schemaField.type === 'notIn') {
158
+ if (!Array.isArray(value)) {
159
+ value = [value];
160
+ }
161
+ }
162
+
163
+ // Patch customOperator if AI returned a plain string
164
+ if (control.type === 'customOperator' && typeof value === 'string') {
165
+ const defaultOperator = control.operators?.[0]?.value;
166
+ value = { operator: defaultOperator, value: value };
167
+ }
168
+
169
+ // Create Date objects from ISO strings for date fields
170
+ if (control.type === 'date' && typeof value === 'string') {
171
+ const date = new Date(value);
172
+ if (!isNaN(date.getTime())) {
173
+ value = date;
174
+ } else {
175
+ console.warn(`Failed to parse date for field ${schemaField.field}:`, value);
176
+ }
177
+ }
178
+
179
+ return {
180
+ ...currentState,
181
+ value: value
182
+ };
183
+ }
184
+
185
+ if (currentState.type === 'and' && schema.type === 'and' &&
186
+ aiState.type === 'and' && Array.isArray(aiState.children)) {
187
+ return {
188
+ ...currentState,
189
+ children: currentState.children.map((child, i) => {
190
+ const childSchema = schema.filters[i];
191
+ const childAiState = aiState.children[i];
192
+ return childSchema ?
193
+ mergeFilterFormState(childSchema, child, childAiState) :
194
+ child;
195
+ })
196
+ };
197
+ }
198
+
199
+ if (currentState.type === 'or' && schema.type === 'or' &&
200
+ aiState.type === 'or' && Array.isArray(aiState.children)) {
201
+ return {
202
+ ...currentState,
203
+ children: currentState.children.map((child, i) => {
204
+ const childSchema = schema.filters[i];
205
+ const childAiState = aiState.children[i];
206
+ return childSchema ?
207
+ mergeFilterFormState(childSchema, child, childAiState) :
208
+ child;
209
+ })
210
+ };
211
+ }
212
+
213
+ if (currentState.type === 'not' && schema.type === 'not' && aiState.type === 'not') {
214
+ return {
215
+ ...currentState,
216
+ child: mergeFilterFormState(schema.filter, currentState.child, aiState.child)
217
+ };
218
+ }
219
+
220
+ return currentState;
221
+ }
222
+
223
+ // --- Gemini Flash-Lite implementation ---
224
+ export const GeminiApi: AIApi = {
225
+ async sendPrompt(filterSchema, userPrompt, setFormState, geminiApiKey, toast) {
226
+ const prompt = buildAiPrompt(filterSchema, userPrompt);
227
+ try {
228
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent?key=${geminiApiKey}`,
229
+ {
230
+ method: 'POST',
231
+ headers: {
232
+ 'Content-Type': 'application/json',
233
+ },
234
+ body: JSON.stringify({
235
+ contents: [{ role: 'user', parts: [{ text: prompt }] }]
236
+ })
237
+ }
238
+ );
239
+ if (!response.ok) throw new Error('Gemini API error');
240
+ const data = await response.json();
241
+ const aiContent = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
242
+ // Use [\s\S] instead of dot-all flag for compatibility
243
+ const match = aiContent.match(/\{[\s\S]*\}/);
244
+ if (match) {
245
+ const parsed = JSON.parse(match[0]);
246
+
247
+ // Make an empty state and merge with AI response
248
+ const currentState = createDefaultFilterState(filterSchema, FormStateInitMode.Empty);
249
+ const mergedState = mergeAiStateWithCurrent(currentState, parsed, filterSchema);
250
+
251
+ setFormState(mergedState);
252
+ } else {
253
+ const errorMessage = 'Could not parse FilterFormState from Gemini response. Check the console.';
254
+ if (toast?.current) {
255
+ toast.current.show({
256
+ severity: 'warn',
257
+ summary: 'Parse Error',
258
+ detail: errorMessage,
259
+ life: 3000
260
+ });
261
+ } else {
262
+ alert(errorMessage);
263
+ }
264
+ }
265
+ } catch (err) {
266
+ console.error(err);
267
+ const errorMessage = 'Failed to get response from Gemini API.';
268
+ if (toast?.current) {
269
+ toast.current.show({
270
+ severity: 'error',
271
+ summary: 'API Error',
272
+ detail: errorMessage,
273
+ life: 3000
274
+ });
275
+ } else {
276
+ alert(errorMessage);
277
+ }
278
+ }
279
+ }
280
+ };
281
+
282
+ export function generateFilterWithAI(
283
+ filterSchema: FilterSchemasAndGroups,
284
+ userPrompt: string,
285
+ setFormState: (state: FilterState) => void,
286
+ apiImpl: AIApi,
287
+ geminiApiKey: string,
288
+ toast?: RefObject<Toast | null>
289
+ ): Promise<void> {
290
+ return apiImpl.sendPrompt(filterSchema, userPrompt, setFormState, geminiApiKey, toast);
291
+ }
@@ -0,0 +1,30 @@
1
+ // React import not required; file uses JSX via TSX but React 17+ JSX transform auto-injects.
2
+
3
+ import { resolveLocale, getCurrencyFractionDigits } from '../currency';
4
+
5
+ export interface CurrencyAmountProps {
6
+ amount: number | string;
7
+ currency: string;
8
+ locale?: string;
9
+ options?: Intl.NumberFormatOptions;
10
+ className?: string;
11
+ fractionDigitsOverride?: number;
12
+ }
13
+
14
+ export function CurrencyAmount({ amount, currency, locale, options = {}, className = '', fractionDigitsOverride }: CurrencyAmountProps) {
15
+ const parsedAmount = Number(amount);
16
+ if (isNaN(parsedAmount)) return null;
17
+ const resolvedLocale = resolveLocale(locale);
18
+ const fractionDigits = typeof fractionDigitsOverride === 'number' ? fractionDigitsOverride : getCurrencyFractionDigits(currency, resolvedLocale);
19
+ const formatOptions: Intl.NumberFormatOptions = {
20
+ style: 'currency',
21
+ currency,
22
+ minimumFractionDigits: fractionDigits,
23
+ maximumFractionDigits: fractionDigits,
24
+ ...options
25
+ };
26
+ const formatter = new Intl.NumberFormat(resolvedLocale, formatOptions);
27
+ return <span className={className}>{formatter.format(parsedAmount)}</span>;
28
+ }
29
+
30
+ export default CurrencyAmount;
@@ -0,0 +1,74 @@
1
+ import * as React from "react";
2
+
3
+ function wrapStringChildren(children: React.ReactNode) {
4
+ if (typeof children === "string") {
5
+ return <span>{children}</span>;
6
+ }
7
+ if (Array.isArray(children)) {
8
+ return children.map((child, i) =>
9
+ typeof child === "string" ? <span key={i}>{child}</span> : child
10
+ );
11
+ }
12
+ return children;
13
+ }
14
+
15
+ function getAlignClass(align?: string) {
16
+ switch (align) {
17
+ case 'start': return 'tw:items-start';
18
+ case 'center': return 'tw:items-center';
19
+ case 'end': return 'tw:items-end';
20
+ case 'stretch': return 'tw:items-stretch';
21
+ case 'baseline': return 'tw:items-baseline';
22
+ default: return '';
23
+ }
24
+ }
25
+
26
+ function getJustifyClass(justify?: string) {
27
+ switch (justify) {
28
+ case 'start': return 'tw:justify-start';
29
+ case 'center': return 'tw:justify-center';
30
+ case 'end': return 'tw:justify-end';
31
+ case 'between': return 'tw:justify-between';
32
+ case 'around': return 'tw:justify-around';
33
+ case 'evenly': return 'tw:justify-evenly';
34
+ default: return '';
35
+ }
36
+ }
37
+
38
+ function getWrapClass(wrap?: string | boolean) {
39
+ if (wrap === true || wrap === 'wrap') return 'tw:flex-wrap';
40
+ if (wrap === 'nowrap') return 'tw:flex-nowrap';
41
+ if (wrap === 'wrap-reverse') return 'tw:flex-wrap-reverse';
42
+ return '';
43
+ }
44
+
45
+ // Horizontal stack (row) with gap
46
+ export function FlexRow({ gap = "tw:gap-2", className = "", align, justify, wrap, children }: { gap?: string; className?: string; align?: string; justify?: string; wrap?: string | boolean; children: React.ReactNode }) {
47
+ return (
48
+ <div className={`tw:flex tw:flex-row ${gap} ${getAlignClass(align)} ${getJustifyClass(justify)} ${getWrapClass(wrap)} ${className}`.trim()}>
49
+ {wrapStringChildren(children)}
50
+ </div>
51
+ );
52
+ }
53
+
54
+ // Vertical stack (column) with gap
55
+ export function FlexColumn({ gap = "tw:gap-2", className = "", align, justify, children }: { gap?: string; className?: string; align?: string; justify?: string; children: React.ReactNode }) {
56
+ return (
57
+ <div className={`tw:flex tw:flex-col ${gap} ${getAlignClass(align)} ${getJustifyClass(justify)} ${className}`.trim()}>
58
+ {wrapStringChildren(children)}
59
+ </div>
60
+ );
61
+ }
62
+
63
+ // Spacer for use in flex layouts
64
+ export function Spacer() {
65
+ return <div className="tw:flex-1" />;
66
+ }
67
+
68
+ // FormattedDate: formats a date string using toLocaleString
69
+ export function DateTime({ date, locale = undefined, options = undefined, className = "" }: { date: string; locale?: string; options?: Intl.DateTimeFormatOptions; className?: string }) {
70
+ if (!date) return null;
71
+ const d = new Date(date);
72
+ if (isNaN(d.getTime())) return <span className={className}>{date}</span>;
73
+ return <span className={className}>{d.toLocaleString(locale, options)}</span>;
74
+ }
@@ -0,0 +1,28 @@
1
+ import * as React from 'react';
2
+
3
+ export interface LinkProps {
4
+ text: string;
5
+ href: string;
6
+ className?: string;
7
+ }
8
+
9
+ /**
10
+ * A reusable Link component for use throughout the application.
11
+ * Provides consistent styling and behavior for links.
12
+ */
13
+ export const Link: React.FC<LinkProps> = ({
14
+ text,
15
+ href,
16
+ className = "tw:text-blue-500 tw:underline hover:tw:text-blue-700 tw:cursor-pointer",
17
+ }) => {
18
+ return (
19
+ <a
20
+ href={href}
21
+ className={className}
22
+ >
23
+ {text}
24
+ </a>
25
+ );
26
+ };
27
+
28
+ export default Link;
@@ -0,0 +1,11 @@
1
+ import * as React from "react";
2
+
3
+ /**
4
+ * Generic mapping component for displaying mapped values.
5
+ * @param value The key to map.
6
+ * @param map The mapping object.
7
+ * @param fallback Optional fallback if value is not found.
8
+ */
9
+ export function Mapping<T extends string | number, U>({ value, map, fallback }: { value: T; map: Record<T, U>; fallback?: React.ReactNode }) {
10
+ return <>{map[value] ?? fallback ?? value}</>;
11
+ }