@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,241 @@
1
+ import { Button } from 'primereact/button';
2
+ import { Toast } from 'primereact/toast';
3
+ import SpeechInput from './SpeechInput';
4
+ import { useState, RefObject } from 'react';
5
+ import { FilterSchemasAndGroups } from '../framework/filters';
6
+ import { View } from '../framework/view';
7
+ import { generateFilterWithAI, GeminiApi } from './aiAssistant';
8
+ import { FilterState } from '../framework/state';
9
+
10
+ interface AIAssistantFormProps {
11
+ filterSchema: FilterSchemasAndGroups;
12
+ filterState: FilterState;
13
+ setFilterSchema: (schema: FilterSchemasAndGroups) => void;
14
+ setFilterState: (state: FilterState) => void;
15
+ selectedView: View;
16
+ geminiApiKey: string;
17
+ toast: RefObject<Toast | null>;
18
+ // When AI applies filters, ensure the filter form becomes visible so user can inspect/edit them
19
+ setShowFilterForm: (value: boolean | ((prev: boolean) => boolean)) => void;
20
+ }
21
+
22
+ export default function AIAssistantForm({
23
+ setFilterState,
24
+ selectedView,
25
+ geminiApiKey,
26
+ toast,
27
+ setShowFilterForm
28
+ }: AIAssistantFormProps) {
29
+ const [aiPrompt, setAiPrompt] = useState('authorized payments in euro or danish krona in the first week of april 2025');
30
+ // const [aiFilterExprInput, setAiFilterExprInput] = useState('(payment method or currency) and a filter to exclude payment status');
31
+ const [aiLoading, setAiLoading] = useState(false);
32
+ return (
33
+ <div className="tw:flex tw:flex-col tw:gap-2 tw:mb-3">
34
+ <label className="tw:text-sm tw:font-semibold tw:mb-1" htmlFor="ai-prompt">AI Prompt</label>
35
+ <div className="tw:flex tw:gap-2">
36
+ <SpeechInput value={aiPrompt} onChange={setAiPrompt} />
37
+ <Button
38
+ type="button"
39
+ outlined
40
+ label="Update filters"
41
+ icon='pi pi-sparkles'
42
+ loading={aiLoading}
43
+ onClick={async () => {
44
+ setAiLoading(true);
45
+ try {
46
+ await generateFilterWithAI(selectedView.filterSchema, aiPrompt, setFilterState, GeminiApi, geminiApiKey, toast);
47
+ // Reveal filter form so user sees applied changes
48
+ setShowFilterForm(true);
49
+
50
+ toast.current?.show({
51
+ severity: 'success',
52
+ summary: 'AI Filter Generated',
53
+ detail: 'Filter values have been populated based on your prompt',
54
+ life: 3000
55
+ });
56
+ } catch (error) {
57
+ console.error('AI filter generation failed:', error);
58
+ toast.current?.show({
59
+ severity: 'error',
60
+ summary: 'AI Generation Failed',
61
+ detail: 'Failed to generate filter from AI. Please try again.',
62
+ life: 3000
63
+ });
64
+ } finally {
65
+ setAiLoading(false);
66
+ }
67
+ }}
68
+ className='p-button-secondary'
69
+ />
70
+ </div>
71
+ {/* <label className="tw:text-sm tw:font-semibold tw:mt-4" htmlFor="ai-filterexpr-input">Add a custom filter with AI</label>
72
+ <div className="tw:flex tw:items-center tw:gap-2">
73
+ <InputText
74
+ id="ai-filterexpr-input"
75
+ value={aiFilterExprInput}
76
+ onChange={e => setAiFilterExprInput(e.target.value)}
77
+ placeholder="Describe filter in natural language..."
78
+ className="tw:flex-1"
79
+ />
80
+ <Button
81
+ type="button"
82
+ outlined
83
+ label="Generate filter"
84
+ icon='pi pi-sparkles'
85
+ loading={aiLoading}
86
+ onClick={async () => {
87
+ setAiLoading(true);
88
+ try {
89
+ const filterSchemaJson = JSON.stringify(filterSchema, null, 2);
90
+ const allKeys = Array.from(
91
+ new Set(
92
+ selectedView.filterSchema.filters
93
+ .flatMap(filter => getFieldNodes(filter.expression)
94
+ .map(node => node.field)
95
+ )
96
+ )
97
+ );
98
+ const filterControlType = [
99
+ 'type FilterControl =',
100
+ ' | { type: "text"; label?: string; placeholder?: string }',
101
+ ' | { type: "number"; label?: string; placeholder?: string }',
102
+ ' | { type: "date"; label?: string; placeholder?: string }',
103
+ ' | { type: "dropdown"; label?: string; items: { label: string; value: any }[] }',
104
+ ' | { type: "multiselect"; label?: string; items: { label: string; value: any }[], filterable?: boolean }',
105
+ ' | { type: "customOperator"; label?: string; operators: { label: string; value: string }[]; valueControl: FilterControl }',
106
+ ' | { type: "custom"; component: React.ComponentType<any>; props?: Record<string, any>; label?: string };'
107
+ ].join('\n');
108
+ const filterExprType = [
109
+ 'type FilterExpr =',
110
+ ' | { type: "equals"; field: string; value: FilterControl }',
111
+ ' | { type: "notEquals"; field: string; value: FilterControl }',
112
+ ' | { type: "greaterThan"; field: string; value: FilterControl }',
113
+ ' | { type: "lessThan"; field: string; value: FilterControl }',
114
+ ' | { type: "greaterThanOrEqual"; field: string; value: FilterControl }',
115
+ ' | { type: "lessThanOrEqual"; field: string; value: FilterControl }',
116
+ ' | { type: "in"; field: string; value: FilterControl }',
117
+ ' | { type: "notIn"; field: string; value: FilterControl }',
118
+ ' | { type: "like"; field: string; value: FilterControl }',
119
+ ' | { type: "iLike"; field: string; value: FilterControl }',
120
+ ' | { type: "isNull"; field: string; value: FilterControl }',
121
+ ' | { type: "and"; filters: FilterExpr[] }',
122
+ ' | { type: "or"; filters: FilterExpr[] }',
123
+ ' | { type: "not"; filter: FilterExpr };'
124
+ ].join('\n');
125
+ const template = [
126
+ 'You are an expert TypeScript assistant.',
127
+ '',
128
+ 'Here are the type definitions for FilterControl and FilterExpr:',
129
+ '',
130
+ filterControlType,
131
+ '',
132
+ filterExprType,
133
+ '',
134
+ 'Available data keys:',
135
+ JSON.stringify(allKeys, null, 2),
136
+ '',
137
+ 'Current filter schema (including control configuration, dropdown/multiselect values, etc.):',
138
+ filterSchemaJson,
139
+ '',
140
+ 'User prompt:',
141
+ aiFilterExprInput,
142
+ '',
143
+ 'Instructions:',
144
+ '- Generate a valid FilterExpr as JSON, using only the available data keys.',
145
+ '- When generating a filter for a field, use the control configuration from the filter schema (e.g. use the same dropdown/multiselect values for matching data keys).',
146
+ '- Only use supported FilterControl types (text, number, date, dropdown, multiselect) with labels',
147
+ '- Do not use custom or transformation functions.',
148
+ '- Output only the JSON for the FilterExpr, nothing else.',
149
+ '- The JSON must be valid and parseable.',
150
+ ].join('\n');
151
+ let aiContent = '';
152
+ try {
153
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash-latest:generateContent?key=${geminiApiKey}`, {
154
+ method: 'POST',
155
+ headers: { 'Content-Type': 'application/json' },
156
+ body: JSON.stringify({
157
+ contents: [{ role: 'user', parts: [{ text: template }] }]
158
+ })
159
+ });
160
+ if (!response.ok) throw new Error('Gemini API error');
161
+ const data = await response.json();
162
+ aiContent = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
163
+ } catch (error) {
164
+ console.error('Gemini API error:', error);
165
+ toast.current?.show({
166
+ severity: 'error',
167
+ summary: 'API Error',
168
+ detail: 'Failed to get response from Gemini API.',
169
+ life: 3000
170
+ });
171
+ setAiLoading(false);
172
+ return;
173
+ }
174
+ let exprJson: any = null;
175
+ const match = aiContent.match(/\{[\s\S]*\}|\[[\s\S]*\]/);
176
+ if (match) {
177
+ try {
178
+ exprJson = JSON.parse(match[0]);
179
+ } catch (error) {
180
+ console.error('Invalid JSON from AI:', error);
181
+ toast.current?.show({
182
+ severity: 'warn',
183
+ summary: 'Invalid Response',
184
+ detail: 'AI response is not valid JSON',
185
+ life: 3000
186
+ });
187
+ setAiLoading(false);
188
+ return;
189
+ }
190
+ } else {
191
+ toast.current?.show({
192
+ severity: 'warn',
193
+ summary: 'No Filter Found',
194
+ detail: 'Could not find JSON in AI response',
195
+ life: 3000
196
+ });
197
+ setAiLoading(false);
198
+ return;
199
+ }
200
+ const filterExpr = filterExprFromJSON(exprJson);
201
+ if (!filterExpr) {
202
+ toast.current?.show({
203
+ severity: 'warn',
204
+ summary: 'Parse Error',
205
+ detail: 'Could not parse AI response as FilterExpr',
206
+ life: 3000
207
+ });
208
+ setAiLoading(false);
209
+ return;
210
+ }
211
+ // Generate a unique ID for the AI filter
212
+ const aiFilterId = `ai-filter-${Date.now()}`;
213
+ const newSchema = {
214
+ ...filterSchema,
215
+ filters: [
216
+ ...filterSchema.filters,
217
+ { id: aiFilterId, label: 'AI Filter', expression: filterExpr, group: 'default', aiGenerated: true }
218
+ ]
219
+ };
220
+ setFilterSchema(newSchema);
221
+
222
+ // Add the AI-generated filter to the existing FilterState Map
223
+ filterState.set(aiFilterId, buildInitialFormState(filterExpr));
224
+ setFilterState(new Map(filterState)); // Trigger React re-render
225
+
226
+ toast.current?.show({
227
+ severity: 'success',
228
+ summary: 'AI Filter Added',
229
+ detail: 'New filter has been created and added to the form',
230
+ life: 3000
231
+ });
232
+ } finally {
233
+ setAiLoading(false);
234
+ }
235
+ }}
236
+ className='p-button-secondary'
237
+ />
238
+ </div> */}
239
+ </div>
240
+ );
241
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { describe, it, expect } from '@jest/globals';
5
+ import { buildInitialFormState, FormStateInitMode } from '../framework/state';
6
+ import { FilterExpr } from '../framework/filters';
7
+
8
+ describe('FilterForm state builders', () => {
9
+ describe('buildInitialFormState vs buildEmptyFormState', () => {
10
+ it('buildInitialFormState uses initialValue when available', () => {
11
+ const expr: FilterExpr = {
12
+ type: 'equals',
13
+ field: 'test',
14
+ value: {
15
+ type: 'text',
16
+ initialValue: 'default-value'
17
+ }
18
+ };
19
+
20
+ const initialState = buildInitialFormState(expr);
21
+ expect(initialState).toEqual({
22
+ type: 'leaf',
23
+ value: 'default-value'
24
+ });
25
+ });
26
+
27
+ it('both functions handle expressions without initialValue the same way', () => {
28
+ const expr: FilterExpr = {
29
+ type: 'equals',
30
+ field: 'test',
31
+ value: {
32
+ type: 'text'
33
+ }
34
+ };
35
+
36
+ const initialState = buildInitialFormState(expr);
37
+ const emptyState = buildInitialFormState(expr, FormStateInitMode.Empty);
38
+
39
+ // Both should have empty string value when no initialValue is provided
40
+ expect((initialState as any).value).toBe('');
41
+ expect((emptyState as any).value).toBe('');
42
+ });
43
+
44
+ it('buildInitialFormState with mode=Empty handles complex nested expressions', () => {
45
+ const expr: FilterExpr = {
46
+ type: 'and',
47
+ filters: [
48
+ {
49
+ type: 'equals',
50
+ field: 'field1',
51
+ value: {
52
+ type: 'text',
53
+ initialValue: 'value1'
54
+ }
55
+ },
56
+ {
57
+ type: 'not',
58
+ filter: {
59
+ type: 'equals',
60
+ field: 'field2',
61
+ value: {
62
+ type: 'text',
63
+ initialValue: 'value2'
64
+ }
65
+ }
66
+ }
67
+ ]
68
+ };
69
+
70
+ const emptyState = buildInitialFormState(expr, FormStateInitMode.Empty);
71
+ expect(emptyState.type).toBe('and');
72
+ expect((emptyState as any).children).toHaveLength(2);
73
+
74
+ // First child should be empty
75
+ expect((emptyState as any).children[0].value).toBe('');
76
+
77
+ // Second child is a NOT expression, check its nested child
78
+ expect((emptyState as any).children[1].type).toBe('not');
79
+ expect((emptyState as any).children[1].child.value).toBe('');
80
+ });
81
+ });
82
+ });