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