@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,375 @@
1
+ import type { FilterExpr, FilterFieldGroup, FilterSchema, FilterId } from '../framework/filters';
2
+ import { FilterControl, FilterSchemasAndGroups } from '../framework/filters';
3
+ import { SavedFilter } from '../framework/saved-filters';
4
+ import { FilterFormState } from '../framework/filter-form-state';
5
+ import { InputText } from 'primereact/inputtext';
6
+ import { InputNumber } from 'primereact/inputnumber';
7
+ import { Calendar } from 'primereact/calendar';
8
+ import { Dropdown } from 'primereact/dropdown';
9
+ import { MultiSelect } from 'primereact/multiselect';
10
+ import { Button } from 'primereact/button';
11
+ import { SplitButton } from 'primereact/splitbutton';
12
+ import { ReactNode, useMemo } from 'react';
13
+ import { Panel } from 'primereact/panel';
14
+ import { createDefaultFilterState, FilterState, getFilterStateById, setFilterStateById, buildInitialFormState, FormStateInitMode } from '../framework/state';
15
+
16
+ // Re-export FilterFormState from the dedicated module
17
+ export type { FilterFormState } from '../framework/filter-form-state';
18
+
19
+ interface FilterFormProps {
20
+ filterSchemasAndGroups: FilterSchemasAndGroups;
21
+ filterState: FilterState
22
+ setFilterState: (state: FilterState) => void;
23
+ onSaveFilter: (state: FilterState) => void;
24
+ onUpdateFilter: (filter: SavedFilter, state: FilterState) => void;
25
+ onShareFilter: () => void;
26
+ savedFilters: SavedFilter[];
27
+ visibleFilterIds: FilterId[]; // indices of filters to display
28
+ onSubmit: () => void;
29
+ }
30
+
31
+ function renderInput(control: FilterControl, value: any, setValue: (v: unknown) => void): ReactNode {
32
+ switch (control.type) {
33
+ case 'text':
34
+ return (
35
+ <InputText
36
+ className="tw:w-full"
37
+ placeholder={control.placeholder}
38
+ value={value ?? ''}
39
+ onChange={e => setValue(e.target.value)}
40
+ />
41
+ )
42
+ case 'number':
43
+ return (
44
+ <InputNumber
45
+ className="tw:w-full"
46
+ placeholder={control.placeholder}
47
+ value={value ?? null}
48
+ onValueChange={e => setValue(e.value)}
49
+ />
50
+ )
51
+ case 'date':
52
+ return (
53
+ <Calendar
54
+ className='tw:w-full'
55
+ placeholder={control.placeholder}
56
+ value={value ?? null}
57
+ onChange={e => setValue(e.value)}
58
+ showIcon
59
+ showButtonBar
60
+ showTime
61
+ dateFormat='yy-mm-dd'
62
+ />
63
+ )
64
+ case 'dropdown':
65
+ return (
66
+ <Dropdown
67
+ className='tw:w-full'
68
+ value={value ?? null}
69
+ options={control.items}
70
+ onChange={e => setValue(e.value)}
71
+ optionLabel='label'
72
+ optionValue='value'
73
+ placeholder='Any'
74
+ />
75
+ );
76
+ case 'multiselect':
77
+ return (
78
+ <MultiSelect
79
+ className='tw:w-full'
80
+ value={value ?? []}
81
+ options={control.items}
82
+ onChange={e => setValue(e.value)}
83
+ optionLabel='label'
84
+ optionValue='value'
85
+ placeholder='Any'
86
+ display='chip'
87
+ filter={control.filterable}
88
+ />
89
+ );
90
+ case 'customOperator': {
91
+ const operator = value?.operator ?? control.operators[0]?.value;
92
+ const valueOrDefault = value?.value ?? '';
93
+ return (
94
+ <div className="tw:flex tw:gap-2">
95
+ <Dropdown
96
+ className="tw:min-w-[90px]"
97
+ value={operator}
98
+ options={control.operators}
99
+ onChange={e => setValue({ operator: e.value, value: valueOrDefault })}
100
+ optionLabel="label"
101
+ optionValue="value"
102
+ placeholder="operator"
103
+ />
104
+ <div className="tw:flex-1">
105
+ {renderInput(control.valueControl, valueOrDefault, v => setValue({ operator, value: v }))}
106
+ </div>
107
+ </div>
108
+ );
109
+ }
110
+ case 'custom':
111
+ return control.component ? (
112
+ <control.component {...(control.props || {})} value={value} onChange={setValue} />
113
+ ) : null;
114
+ }
115
+ }
116
+
117
+ // Recursively renders a FilterFormState tree from state
118
+ function renderFilterFormState(
119
+ state: FilterFormState,
120
+ setState: (state: FilterFormState) => void,
121
+ renderFilterType: boolean,
122
+ filterExpression: FilterExpr
123
+ ): ReactNode {
124
+ if (state.type === 'and' || state.type === 'or') {
125
+ // Schema consistency check: filter expression must match state type
126
+ if (filterExpression.type !== state.type) {
127
+ throw new Error(`Schema consistency error: FilterFormState type "${state.type}" does not match FilterExpr type "${filterExpression.type}"`);
128
+ }
129
+
130
+ const childExpressions = filterExpression.filters;
131
+
132
+ // Schema consistency check: must have same number of children
133
+ if (childExpressions.length !== state.children.length) {
134
+ throw new Error(`Schema consistency error: FilterFormState has ${state.children.length} children but FilterExpr has ${childExpressions.length} filters`);
135
+ }
136
+
137
+ return (
138
+ <div className="tw:flex tw:flex-col tw:gap-2 tw:border-l-2 tw:pl-2 tw:ml-2">
139
+ {
140
+ renderFilterType
141
+ ? (
142
+ <div className="tw:font-semibold tw:text-xs tw:mb-1 tw:uppercase">{state.type}</div>
143
+ )
144
+ : null
145
+ }
146
+ {
147
+ state.children.map((child, i) => (
148
+ <div key={i}>
149
+ {renderFilterFormState(
150
+ child,
151
+ newChild => {
152
+ const newChildren = [...state.children];
153
+ newChildren[i] = newChild;
154
+ setState({ ...state, children: newChildren });
155
+ },
156
+ renderFilterType,
157
+ childExpressions[i]
158
+ )}
159
+ </div>
160
+ ))
161
+ }
162
+ </div>
163
+ );
164
+ } else if (state.type === 'not') {
165
+ // Schema consistency check: filter expression must be 'not' type
166
+ if (filterExpression.type !== 'not') {
167
+ throw new Error(`Schema consistency error: FilterFormState type "not" does not match FilterExpr type "${filterExpression.type}"`);
168
+ }
169
+
170
+ const childExpression = filterExpression.filter;
171
+
172
+ return (
173
+ <div className="tw:flex tw:flex-col tw:gap-2 tw:border-l-2 tw:pl-2 tw:ml-2">
174
+ {
175
+ renderFilterType
176
+ ? (
177
+ <div className="tw:font-semibold tw:text-xs tw:mb-1 tw:uppercase">{state.type}</div>
178
+ )
179
+ : null
180
+ }
181
+ {
182
+ renderFilterFormState(
183
+ state.child,
184
+ newChild => setState({ ...state, child: newChild }),
185
+ renderFilterType,
186
+ childExpression
187
+ )
188
+ }
189
+ </div>
190
+ );
191
+ } else if (state.type === 'leaf') {
192
+ // Schema consistency check: filter expression must be a leaf type (not 'and', 'or', or 'not')
193
+ if (filterExpression.type === 'and' || filterExpression.type === 'or' || filterExpression.type === 'not') {
194
+ throw new Error(`Schema consistency error: FilterFormState is leaf type but FilterExpr is "${filterExpression.type}"`);
195
+ }
196
+
197
+ const handleSetValue = (value: unknown) => {
198
+ const newState = { ...state, value };
199
+ setState(newState);
200
+ };
201
+
202
+ // Use the raw state value for display - transforms are applied during query building
203
+ const displayValue = state.value;
204
+
205
+ return (
206
+ <div className="tw:flex tw:flex-col tw:min-w-[220px] tw:mb-2">
207
+ <label className="tw:text-sm tw:font-medium tw:mb-1">{filterExpression.value.label}</label>
208
+ {renderInput(filterExpression.value, displayValue, handleSetValue)}
209
+ </div>
210
+ );
211
+ }
212
+ return null;
213
+ }
214
+
215
+ // Helper to check if a filter is empty
216
+ function isFilterEmpty(state: FilterFormState): boolean {
217
+ if (state.type === 'leaf') {
218
+ return state.value === '' || state.value === null || (Array.isArray(state.value) && state.value.length === 0);
219
+ }
220
+ if (state.type === 'not') {
221
+ return isFilterEmpty(state.child);
222
+ }
223
+ return state.children.every(isFilterEmpty);
224
+ }
225
+
226
+ function FilterForm({
227
+ filterSchemasAndGroups,
228
+ filterState,
229
+ setFilterState,
230
+ onSaveFilter,
231
+ onUpdateFilter,
232
+ onShareFilter,
233
+ savedFilters,
234
+ visibleFilterIds,
235
+ onSubmit
236
+ }: FilterFormProps) {
237
+
238
+ const filterSchemaById: Map<FilterId, FilterSchema> = useMemo(() => new Map(
239
+ filterSchemasAndGroups.filters.map(filter => [filter.id, filter])
240
+ ), [filterSchemasAndGroups]);
241
+
242
+ const visibleSet = useMemo(() => new Set(visibleFilterIds), [visibleFilterIds]);
243
+
244
+ // Helper to reset a filter by its ID
245
+ function resetFilter(filterId: FilterId) {
246
+ const filterSchema = filterSchemaById.get(filterId);
247
+ if (!filterSchema) return;
248
+ const initial = buildInitialFormState(filterSchema.expression, FormStateInitMode.Empty);
249
+ setFilterState(setFilterStateById(filterState, filterId, initial));
250
+ }
251
+
252
+ // Helper to reset all filters
253
+ function resetAllFilters() {
254
+ setFilterState(
255
+ createDefaultFilterState(filterSchemasAndGroups, FormStateInitMode.Empty)
256
+ );
257
+ onSubmit();
258
+ }
259
+
260
+ // Group filters by group name
261
+ const defaultGroup: FilterFieldGroup | undefined = filterSchemasAndGroups.groups.find(group => group.name === 'default');
262
+ const defaultFilters: FilterSchema[] = filterSchemasAndGroups.filters.filter(filter => filter.group === 'default' && visibleSet.has(filter.id));
263
+ const otherGroups: FilterFieldGroup[] = filterSchemasAndGroups.groups.filter(group => group.name !== 'default');
264
+ const filtersByGroup: Array<{ group: FilterFieldGroup; filters: FilterSchema[] }> =
265
+ otherGroups
266
+ .map(group => ({
267
+ group,
268
+ filters: filterSchemasAndGroups.filters.filter(filter => filter.group === group.name && visibleSet.has(filter.id))
269
+ }))
270
+ .filter(grouping => grouping.filters.length > 0);
271
+ return (
272
+ <form className="tw:mb-4" onSubmit={e => { e.preventDefault(); onSubmit(); }}>
273
+ {/* Render default group filters above the dividers */}
274
+ {defaultGroup && defaultFilters.length > 0 && (
275
+ <div className="tw:flex tw:flex-wrap tw:gap-4 tw:items-start tw:mb-4">
276
+ {
277
+ defaultFilters.map(filterSchema => (
278
+ <div key={filterSchema.id} className="tw:flex tw:flex-col tw:min-w-[220px] tw:mb-2">
279
+ <div className="tw:flex tw:items-center tw:mb-1 tw:max-h-[20px]">
280
+ <label className="tw:text-sm tw:font-bold">{filterSchema.label}</label>
281
+ <Button
282
+ type="button"
283
+ size='small'
284
+ icon='pi pi-filter-slash'
285
+ rounded
286
+ text
287
+ title="Reset filter"
288
+ onClick={() => resetFilter(filterSchema.id)}
289
+ visible={!isFilterEmpty(getFilterStateById(filterState, filterSchema.id))}
290
+ />
291
+ </div>
292
+ {
293
+ renderFilterFormState(
294
+ getFilterStateById(filterState, filterSchema.id),
295
+ newState => {
296
+ setFilterState(setFilterStateById(filterState, filterSchema.id, newState));
297
+ },
298
+ filterSchema.aiGenerated,
299
+ filterSchema.expression
300
+ )
301
+ }
302
+ </div>
303
+ ))
304
+ }
305
+ </div>
306
+ )}
307
+ {/* Render other groups with Panel and captions */}
308
+ {
309
+ filtersByGroup.map(({ group, filters }) => (
310
+ <Panel key={group.name} header={group.label} className="tw:w-full tw:mb-4">
311
+ <div className="tw:flex tw:flex-wrap tw:gap-4 tw:items-start">
312
+ {
313
+ filters.map((filterSchema) => (
314
+ <div key={filterSchema.id} className="tw:flex tw:flex-col tw:min-w-[220px] mb-2">
315
+ <div className="tw:flex items-center tw:mb-1 tw:max-h-[20px]">
316
+ <label className="tw:text-sm tw:font-bold">{filterSchema.label}</label>
317
+ <Button
318
+ type="button"
319
+ size='small'
320
+ icon='pi pi-filter-slash'
321
+ rounded
322
+ text
323
+ title="Reset filter"
324
+ onClick={() => resetFilter(filterSchema.id)}
325
+ visible={!isFilterEmpty(getFilterStateById(filterState, filterSchema.id))}
326
+ />
327
+ </div>
328
+ {
329
+ renderFilterFormState(
330
+ getFilterStateById(filterState, filterSchema.id),
331
+ newState => {
332
+ setFilterState(setFilterStateById(filterState, filterSchema.id, newState));
333
+ },
334
+ filterSchema.aiGenerated,
335
+ filterSchema.expression
336
+ )
337
+ }
338
+ </div>
339
+ ))
340
+ }
341
+ </div>
342
+ </Panel>
343
+ ))
344
+ }
345
+ <div className="tw:flex tw:gap-2 tw:mb-3 tw:justify-end">
346
+ <Button type="submit" size='small' label="Apply filter" icon='pi pi-filter' />
347
+ <Button type="button" size='small' outlined label="Reset All" icon='pi pi-filter-slash' onClick={resetAllFilters} className='p-button-secondary' />
348
+ <SplitButton
349
+ size='small'
350
+ outlined
351
+ label="Save Filter"
352
+ icon='pi pi-bookmark'
353
+ onClick={() => onSaveFilter(filterState)}
354
+ model={savedFilters.map(filter => ({
355
+ label: `Update “${filter.name}”`,
356
+ icon: 'pi pi-file-import',
357
+ command: () => onUpdateFilter(filter, filterState)
358
+ }))}
359
+ className='p-button-secondary'
360
+ />
361
+ <Button
362
+ type="button"
363
+ size='small'
364
+ outlined
365
+ icon="pi pi-share-alt"
366
+ label="Share Filter"
367
+ onClick={onShareFilter}
368
+ className="p-button-secondary"
369
+ />
370
+ </div>
371
+ </form>
372
+ );
373
+ }
374
+
375
+ export default FilterForm;
@@ -0,0 +1,102 @@
1
+ import * as React from 'react';
2
+ import { Dropdown } from 'primereact/dropdown';
3
+ import { InputText } from 'primereact/inputtext';
4
+
5
+ const COUNTRY_CODES = [
6
+ { label: 'Sweden (+46)', value: '+46' },
7
+ { label: 'Austria (+43)', value: '+43' },
8
+ { label: 'Belgium (+32)', value: '+32' },
9
+ { label: 'Croatia (+385)', value: '+385' },
10
+ { label: 'Cyprus (+357)', value: '+357' },
11
+ { label: 'Czech Republic (+420)', value: '+420' },
12
+ { label: 'Denmark (+45)', value: '+45' },
13
+ { label: 'Estonia (+372)', value: '+372' },
14
+ { label: 'Finland (+358)', value: '+358' },
15
+ { label: 'France (+33)', value: '+33' },
16
+ { label: 'Germany (+49)', value: '+49' },
17
+ { label: 'Greece (+30)', value: '+30' },
18
+ { label: 'Hungary (+36)', value: '+36' },
19
+ { label: 'Iceland (+354)', value: '+354' },
20
+ { label: 'Ireland (+353)', value: '+353' },
21
+ { label: 'Italy (+39)', value: '+39' },
22
+ { label: 'Latvia (+371)', value: '+371' },
23
+ { label: 'Liechtenstein (+423)', value: '+423' },
24
+ { label: 'Lithuania (+370)', value: '+370' },
25
+ { label: 'Luxembourg (+352)', value: '+352' },
26
+ { label: 'Malta (+356)', value: '+356' },
27
+ { label: 'Netherlands (+31)', value: '+31' },
28
+ { label: 'Norway (+47)', value: '+47' },
29
+ { label: 'Poland (+48)', value: '+48' },
30
+ { label: 'Portugal (+351)', value: '+351' },
31
+ { label: 'Romania (+40)', value: '+40' },
32
+ { label: 'Slovakia (+421)', value: '+421' },
33
+ { label: 'Slovenia (+386)', value: '+386' },
34
+ { label: 'Spain (+34)', value: '+34' },
35
+ ];
36
+
37
+ export interface PhoneNumberFilterProps {
38
+ value?: string;
39
+ onChange?: (value: string) => void;
40
+ }
41
+
42
+ function parsePhoneNumber(value: string | undefined): { code: string | null; number: string } {
43
+ if (!value) return { code: COUNTRY_CODES[0].value, number: '' };
44
+ if (value.startsWith('+')) {
45
+ // Try to match the longest code from COUNTRY_CODES
46
+ const match = COUNTRY_CODES
47
+ .map(c => c.value)
48
+ .sort((a, b) => b.length - a.length) // longest first
49
+ .find(code => value.startsWith(code));
50
+ if (match) {
51
+ return { code: match, number: value.slice(match.length) };
52
+ }
53
+ // fallback: treat as unknown code
54
+ return { code: null, number: value };
55
+ }
56
+ return { code: COUNTRY_CODES[0].value, number: value };
57
+ }
58
+
59
+ export const PhoneNumberFilter: React.FC<PhoneNumberFilterProps> = ({ value, onChange }) => {
60
+ const parsed = React.useMemo(() => {
61
+ return parsePhoneNumber(value);
62
+ }, [value]);
63
+
64
+ const numberStartsWithPlus = parsed.number.trim().startsWith('+');
65
+
66
+ const emitChange = (code: string | null, number: string) => {
67
+ if (onChange) {
68
+ if (code === null || numberStartsWithPlus) {
69
+ onChange(number);
70
+ } else {
71
+ onChange(`${code}${number}`);
72
+ }
73
+ }
74
+ };
75
+
76
+ const handleCodeChange = (e: { value: string }) => {
77
+ emitChange(e.value, parsed.number);
78
+ };
79
+ const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
80
+ const parsedNewValue = parsePhoneNumber(e.target.value);
81
+ emitChange(parsedNewValue.code ?? parsed.code, parsedNewValue.number);
82
+ };
83
+
84
+ return (
85
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
86
+ <Dropdown
87
+ value={numberStartsWithPlus ? null : parsed.code}
88
+ options={COUNTRY_CODES}
89
+ onChange={handleCodeChange}
90
+ disabled={numberStartsWithPlus}
91
+ placeholder="Code"
92
+ filter
93
+ />
94
+ <InputText
95
+ type="text"
96
+ placeholder="Phone number"
97
+ value={parsed.number}
98
+ onChange={handleNumberChange}
99
+ />
100
+ </div>
101
+ );
102
+ };
@@ -0,0 +1,181 @@
1
+ import { Button } from 'primereact/button';
2
+ import { confirmDialog } from 'primereact/confirmdialog';
3
+ import { Tag } from 'primereact/tag';
4
+ import { SavedFilter } from '../framework/saved-filters';
5
+ import { FilterState } from '../framework/state';
6
+ import { FilterSchemasAndGroups } from '../framework/filters';
7
+
8
+ interface SavedFilterListProps {
9
+ savedFilters: SavedFilter[];
10
+ onFilterDelete: (filterId: string) => void;
11
+ onFilterLoad: (filterState: FilterState) => void;
12
+ onFilterApply: () => void;
13
+ onFilterShare: (filterState: FilterState) => void;
14
+ visible: boolean;
15
+ filterSchema: FilterSchemasAndGroups;
16
+ }
17
+
18
+ export default function SavedFilterList({ savedFilters, onFilterDelete, onFilterLoad, onFilterApply, onFilterShare, visible, filterSchema }: SavedFilterListProps) {
19
+ if (!visible) {
20
+ return null;
21
+ }
22
+
23
+ const handleDeleteFilter = (filter: SavedFilter) => {
24
+ confirmDialog({
25
+ message: `Are you sure you want to delete the filter "${filter.name}"? This action cannot be undone.`,
26
+ header: 'Confirm Filter Deletion',
27
+ icon: 'pi pi-exclamation-triangle',
28
+ defaultFocus: 'reject',
29
+ acceptClassName: 'p-button-danger',
30
+ accept: () => {
31
+ onFilterDelete(filter.id);
32
+ },
33
+ reject: () => {
34
+ // User cancelled - no action needed
35
+ }
36
+ });
37
+ };
38
+
39
+ const formatDate = (date: Date) => {
40
+ return new Intl.DateTimeFormat('en-US', {
41
+ year: 'numeric',
42
+ month: 'short',
43
+ day: 'numeric',
44
+ hour: '2-digit',
45
+ minute: '2-digit'
46
+ }).format(date);
47
+ };
48
+
49
+ function isActiveFilter(filter: unknown) {
50
+ if (!(typeof filter === 'object' && filter !== null && 'value' in filter)) {
51
+ return false
52
+ }
53
+ if (Array.isArray(filter.value)) {
54
+ return filter.value.length > 0;
55
+ }
56
+ if (typeof filter.value === 'object' && filter.value !== null && 'value' in filter.value) {
57
+ return isActiveFilter(filter.value);
58
+ }
59
+ return filter.value !== ''
60
+ }
61
+
62
+ const schemaById = new Map(filterSchema.filters.map(f => [f.id, f]));
63
+
64
+ function getFieldDisplay(filterId: string): string {
65
+ const schemaEntry = schemaById.get(filterId);
66
+ if (!schemaEntry) return '';
67
+ const expr: any = schemaEntry.expression;
68
+ if (expr && 'field' in expr) {
69
+ const fieldVal = expr.field;
70
+ if (typeof fieldVal === 'string') return fieldVal;
71
+ if (fieldVal && typeof fieldVal === 'object') {
72
+ if ('and' in fieldVal && Array.isArray(fieldVal.and)) return fieldVal.and.join(' & ');
73
+ if ('or' in fieldVal && Array.isArray(fieldVal.or)) return fieldVal.or.join(' | ');
74
+ }
75
+ }
76
+ return '';
77
+ }
78
+
79
+ const renderFilterState = (state: FilterState) => {
80
+ if (!state || state.size === 0) {
81
+ return null;
82
+ }
83
+
84
+ const activeFilters = Array.from(state.entries())
85
+ .filter(([, filter]) => isActiveFilter(filter));
86
+ if (activeFilters.length === 0) {
87
+ return null;
88
+ }
89
+
90
+ return (
91
+ <div className="tw:mt-2 tw:flex tw:flex-wrap tw:gap-1">
92
+ {
93
+ activeFilters.map(([filterId, filter], index) => {
94
+ // Handle different types of FilterFormState
95
+ let displayText = '';
96
+ if (filter.type === 'leaf') {
97
+ const valueStr = typeof filter.value === 'string' && filter.value.length > 128
98
+ ? `${filter.value.substring(0, 128)}...`
99
+ : String(filter.value);
100
+ const fieldName = getFieldDisplay(filterId);
101
+ displayText = fieldName ? `${fieldName}: ${valueStr}` : valueStr;
102
+ } else if (filter.type === 'and' || filter.type === 'or') {
103
+ displayText = `${filter.type.toUpperCase()} (${filter.children.length} filters)`;
104
+ } else if (filter.type === 'not') {
105
+ displayText = `NOT (1 filter)`;
106
+ }
107
+
108
+ return (
109
+ <Tag
110
+ key={index}
111
+ value={displayText}
112
+ className="tw:text-xs"
113
+ style={{
114
+ backgroundColor: 'transparent',
115
+ color: '#6366f1',
116
+ borderWidth: 1
117
+ }}
118
+ />
119
+ );
120
+ })
121
+ }
122
+ </div>
123
+ );
124
+ };
125
+
126
+ return (
127
+ <div className="tw:mb-4">
128
+ <div className="tw:mb-3">
129
+ <h3 className="tw:text-lg tw:font-medium tw:text-gray-900">Saved Filters</h3>
130
+ </div>
131
+ {savedFilters.length === 0 ? (
132
+ <p className="tw:text-gray-500 tw:text-center tw:py-4">No saved filters for this view</p>
133
+ ) : (
134
+ <div className="tw:space-y-3">
135
+ {savedFilters.map((filter) => (
136
+ <div key={filter.id} className="tw:flex tw:items-center tw:justify-between tw:p-3 tw:border tw:border-gray-200 tw:rounded-lg">
137
+ <div className="tw:flex-1">
138
+ <div className="tw:flex tw:items-center tw:gap-2 tw:mb-1">
139
+ <h4 className="tw:font-medium tw:text-gray-900">{filter.name}</h4>
140
+ <span className="tw:text-xs tw:text-gray-500 tw:bg-gray-100 tw:px-2 tw:py-1 tw:rounded">
141
+ Created: {formatDate(filter.createdAt)}
142
+ </span>
143
+ </div>
144
+ {renderFilterState(filter.state)}
145
+ </div>
146
+ <div className="tw:flex tw:gap-2">
147
+ <Button
148
+ size="small"
149
+ outlined
150
+ icon="pi pi-filter"
151
+ label="Use"
152
+ onClick={() => {
153
+ onFilterLoad(filter.state);
154
+ onFilterApply();
155
+ }}
156
+ className="p-button"
157
+ />
158
+ <Button
159
+ size="small"
160
+ outlined
161
+ icon="pi pi-share-alt"
162
+ label="Share"
163
+ onClick={() => onFilterShare(filter.state)}
164
+ className="p-button-secondary"
165
+ />
166
+ <Button
167
+ size="small"
168
+ outlined
169
+ icon="pi pi-trash"
170
+ label='Delete'
171
+ onClick={() => handleDeleteFilter(filter)}
172
+ className="p-button-danger"
173
+ />
174
+ </div>
175
+ </div>
176
+ ))}
177
+ </div>
178
+ )}
179
+ </div>
180
+ );
181
+ }