@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,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
|
+
});
|