@postxl/generators 1.4.2 → 1.5.0
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/dist/backend-rest-api/generators/model-controller.generator.js +109 -11
- package/dist/backend-rest-api/generators/model-controller.generator.js.map +1 -1
- package/dist/backend-rest-api/rest-api.generator.js +4 -0
- package/dist/backend-rest-api/rest-api.generator.js.map +1 -1
- package/dist/backend-rest-api/template/utils/src/fieldSelection.spec.ts +252 -0
- package/dist/backend-rest-api/template/utils/src/fieldSelection.ts +66 -0
- package/dist/backend-router-trpc/generators/model-routes.generator.js +27 -2
- package/dist/backend-router-trpc/generators/model-routes.generator.js.map +1 -1
- package/dist/backend-router-trpc/router-trpc.generator.d.ts +1 -0
- package/dist/backend-router-trpc/router-trpc.generator.js +1 -0
- package/dist/backend-router-trpc/router-trpc.generator.js.map +1 -1
- package/dist/backend-update/model-update-service.generator.js +145 -8
- package/dist/backend-update/model-update-service.generator.js.map +1 -1
- package/dist/backend-view/model-view-service.generator.js +276 -19
- package/dist/backend-view/model-view-service.generator.js.map +1 -1
- package/dist/backend-view/template/{filter.utils.test.ts → query.utils.test.ts} +101 -1
- package/dist/backend-view/template/query.utils.ts +444 -0
- package/dist/frontend-admin/generators/model-admin-page.generator.js +50 -32
- package/dist/frontend-admin/generators/model-admin-page.generator.js.map +1 -1
- package/dist/frontend-core/template/src/components/admin/column-header-state-icon.tsx +72 -0
- package/dist/frontend-core/template/src/components/admin/table-filter.tsx +195 -43
- package/dist/frontend-tables/generators/model-table.generator.js +108 -29
- package/dist/frontend-tables/generators/model-table.generator.js.map +1 -1
- package/dist/frontend-trpc-client/generators/model-hook.generator.js +130 -16
- package/dist/frontend-trpc-client/generators/model-hook.generator.js.map +1 -1
- package/dist/types/generators/model-type.generator.js +97 -31
- package/dist/types/generators/model-type.generator.js.map +1 -1
- package/dist/types/template/query.types.ts +151 -0
- package/dist/types/types.generator.d.ts +15 -0
- package/dist/types/types.generator.js +5 -3
- package/dist/types/types.generator.js.map +1 -1
- package/package.json +2 -2
- package/dist/backend-rest-api/generators/zod-exception-filter.generator.d.ts +0 -1
- package/dist/backend-rest-api/generators/zod-exception-filter.generator.js +0 -28
- package/dist/backend-rest-api/generators/zod-exception-filter.generator.js.map +0 -1
- package/dist/backend-view/template/filter.utils.ts +0 -218
- package/dist/frontend-core/template/src/components/admin/table-filter-header-icon.tsx +0 -30
- package/dist/types/template/filter.types.ts +0 -70
|
@@ -5,10 +5,50 @@ import {
|
|
|
5
5
|
formatAndSortOptions,
|
|
6
6
|
getMatches,
|
|
7
7
|
getOptions,
|
|
8
|
+
hasTextMatching,
|
|
8
9
|
matchDateFilter,
|
|
9
10
|
matchFilter,
|
|
10
11
|
matchNumberFilter,
|
|
11
|
-
|
|
12
|
+
matchStringFilter,
|
|
13
|
+
} from './query.utils'
|
|
14
|
+
|
|
15
|
+
describe('hasTextMatching', () => {
|
|
16
|
+
it('should return false for undefined', () => {
|
|
17
|
+
expect(hasTextMatching(undefined)).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('should return false for empty filter', () => {
|
|
21
|
+
expect(hasTextMatching({})).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('should return false for filter with only values', () => {
|
|
25
|
+
expect(hasTextMatching({ values: ['a', 'b'] })).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('should return true for filter with contains', () => {
|
|
29
|
+
expect(hasTextMatching({ contains: 'test' })).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('should return true for filter with startsWith', () => {
|
|
33
|
+
expect(hasTextMatching({ startsWith: 'test' })).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('should return true for filter with endsWith', () => {
|
|
37
|
+
expect(hasTextMatching({ endsWith: 'test' })).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('should return true for filter with exclude', () => {
|
|
41
|
+
expect(hasTextMatching({ exclude: 'test' })).toBe(true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('should return true for filter with multiple text matching fields', () => {
|
|
45
|
+
expect(hasTextMatching({ contains: 'foo', startsWith: 'bar' })).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('should return true when values and text matching are both set', () => {
|
|
49
|
+
expect(hasTextMatching({ values: ['a'], contains: 'test' })).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
12
52
|
|
|
13
53
|
describe('matchFilter', () => {
|
|
14
54
|
it('should return true when no filter values are provided', () => {
|
|
@@ -50,6 +90,57 @@ describe('matchFilter', () => {
|
|
|
50
90
|
})
|
|
51
91
|
})
|
|
52
92
|
|
|
93
|
+
describe('matchStringFilter', () => {
|
|
94
|
+
it('should return true when no filter is provided', () => {
|
|
95
|
+
expect(matchStringFilter('test', undefined)).toBe(true)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('should match values in the values array', () => {
|
|
99
|
+
expect(matchStringFilter('apple', { values: ['apple', 'banana'] })).toBe(true)
|
|
100
|
+
expect(matchStringFilter('cherry', { values: ['apple', 'banana'] })).toBe(false)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('should handle null/undefined item value', () => {
|
|
104
|
+
expect(matchStringFilter(null, { values: ['apple'] })).toBe(false)
|
|
105
|
+
expect(matchStringFilter(undefined, { values: ['apple'] })).toBe(false)
|
|
106
|
+
expect(matchStringFilter(null, { contains: 'test' })).toBe(false)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('should match contains (case insensitive)', () => {
|
|
110
|
+
expect(matchStringFilter('Hello World', { contains: 'world' })).toBe(true)
|
|
111
|
+
expect(matchStringFilter('Hello World', { contains: 'foo' })).toBe(false)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('should match startsWith (case insensitive)', () => {
|
|
115
|
+
expect(matchStringFilter('Hello World', { startsWith: 'hello' })).toBe(true)
|
|
116
|
+
expect(matchStringFilter('Hello World', { startsWith: 'world' })).toBe(false)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('should match endsWith (case insensitive)', () => {
|
|
120
|
+
expect(matchStringFilter('Hello World', { endsWith: 'world' })).toBe(true)
|
|
121
|
+
expect(matchStringFilter('Hello World', { endsWith: 'hello' })).toBe(false)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('should exclude values containing exclude string (case insensitive)', () => {
|
|
125
|
+
expect(matchStringFilter('Hello World', { exclude: 'world' })).toBe(false)
|
|
126
|
+
expect(matchStringFilter('Hello World', { exclude: 'foo' })).toBe(true)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('should handle null values in filter properties', () => {
|
|
130
|
+
expect(
|
|
131
|
+
matchStringFilter('test', { values: undefined, contains: null, startsWith: null, endsWith: null, exclude: null }),
|
|
132
|
+
).toBe(true)
|
|
133
|
+
expect(matchStringFilter('test', { contains: null })).toBe(true)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('should combine multiple filter conditions (AND logic)', () => {
|
|
137
|
+
expect(matchStringFilter('Hello World', { values: ['Hello World'], contains: 'world' })).toBe(true)
|
|
138
|
+
expect(matchStringFilter('Hello World', { values: ['Hello World'], contains: 'foo' })).toBe(false)
|
|
139
|
+
expect(matchStringFilter('Hello World', { startsWith: 'hello', endsWith: 'world' })).toBe(true)
|
|
140
|
+
expect(matchStringFilter('Hello World', { startsWith: 'hello', endsWith: 'foo' })).toBe(false)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
53
144
|
describe('matchNumberFilter', () => {
|
|
54
145
|
it('should return true when no filter is provided', () => {
|
|
55
146
|
expect(matchNumberFilter(50, undefined)).toBe(true)
|
|
@@ -178,6 +269,7 @@ describe('getOptions', () => {
|
|
|
178
269
|
filterKey: 'status' as any,
|
|
179
270
|
enumName: 'Status',
|
|
180
271
|
enumValues: ['active', 'inactive', 'pending'],
|
|
272
|
+
getLabel: (v: unknown) => String(v),
|
|
181
273
|
}
|
|
182
274
|
const options = getOptions(items, (item) => item.status, config)
|
|
183
275
|
expect(options).toEqual([
|
|
@@ -193,6 +285,7 @@ describe('getOptions', () => {
|
|
|
193
285
|
kind: 'scalar' as const,
|
|
194
286
|
valueType: 'boolean' as const,
|
|
195
287
|
filterKey: 'isActive' as any,
|
|
288
|
+
getLabel: (v: unknown) => String(v),
|
|
196
289
|
}
|
|
197
290
|
const options = getOptions(items, (item) => item.isActive, config)
|
|
198
291
|
expect(options).toEqual([
|
|
@@ -207,6 +300,7 @@ describe('getOptions', () => {
|
|
|
207
300
|
kind: 'scalar' as const,
|
|
208
301
|
valueType: 'string' as const,
|
|
209
302
|
filterKey: 'name' as any,
|
|
303
|
+
getLabel: (v: unknown) => String(v),
|
|
210
304
|
}
|
|
211
305
|
const options = getOptions(items, (item) => item.name, config)
|
|
212
306
|
expect(options.length).toBe(3)
|
|
@@ -228,6 +322,7 @@ describe('getOptions', () => {
|
|
|
228
322
|
referencedModelViewServiceVariableName: 'users',
|
|
229
323
|
referencedModelLabelField: 'name',
|
|
230
324
|
referencedModelIdField: 'id',
|
|
325
|
+
getLabel: (v: unknown) => String(v),
|
|
231
326
|
}
|
|
232
327
|
const options = getOptions(items, (item) => item.userId, config, referencedItems)
|
|
233
328
|
expect(options).toEqual([
|
|
@@ -246,6 +341,7 @@ describe('getOptions', () => {
|
|
|
246
341
|
referencedModelViewServiceVariableName: 'users',
|
|
247
342
|
referencedModelLabelField: 'name',
|
|
248
343
|
referencedModelIdField: 'id',
|
|
344
|
+
getLabel: (v: unknown) => String(v),
|
|
249
345
|
}
|
|
250
346
|
const options = getOptions(items, (item) => item.userId, config, referencedItems)
|
|
251
347
|
expect(options).toContainEqual({ value: '', label: 'Empty' })
|
|
@@ -259,6 +355,7 @@ describe('applyRangeFilters', () => {
|
|
|
259
355
|
kind: 'scalar' as const,
|
|
260
356
|
valueType: 'number' as const,
|
|
261
357
|
filterKey: 'age' as any,
|
|
358
|
+
getLabel: (v: unknown) => String(v),
|
|
262
359
|
}
|
|
263
360
|
const filters = { age: { min: 30, max: 60 } }
|
|
264
361
|
const result = applyRangeFilters(items, (item) => item.age, config, filters)
|
|
@@ -274,6 +371,7 @@ describe('applyRangeFilters', () => {
|
|
|
274
371
|
kind: 'scalar' as const,
|
|
275
372
|
valueType: 'date' as const,
|
|
276
373
|
filterKey: 'date' as any,
|
|
374
|
+
getLabel: (v: unknown) => String(v),
|
|
277
375
|
}
|
|
278
376
|
const filters = {
|
|
279
377
|
date: {
|
|
@@ -291,6 +389,7 @@ describe('applyRangeFilters', () => {
|
|
|
291
389
|
kind: 'scalar' as const,
|
|
292
390
|
valueType: 'number' as const,
|
|
293
391
|
filterKey: 'age' as any,
|
|
392
|
+
getLabel: (v: unknown) => String(v),
|
|
294
393
|
}
|
|
295
394
|
const filters = { age: undefined }
|
|
296
395
|
const result = applyRangeFilters(items, (item) => item.age, config, filters)
|
|
@@ -303,6 +402,7 @@ describe('applyRangeFilters', () => {
|
|
|
303
402
|
kind: 'scalar' as const,
|
|
304
403
|
valueType: 'string' as const,
|
|
305
404
|
filterKey: 'name' as any,
|
|
405
|
+
getLabel: (v: unknown) => String(v),
|
|
306
406
|
}
|
|
307
407
|
const filters = { name: { min: 0, max: 10 } as any }
|
|
308
408
|
const result = applyRangeFilters(items, (item) => item.name, config, filters)
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DateFilter,
|
|
3
|
+
FieldValueType,
|
|
4
|
+
FilterConfigItem,
|
|
5
|
+
FilterState,
|
|
6
|
+
NumberFilter,
|
|
7
|
+
PaginatedResult,
|
|
8
|
+
RelationFilterConfig,
|
|
9
|
+
StringFilter,
|
|
10
|
+
} from '@types'
|
|
11
|
+
|
|
12
|
+
export function matchFilter(itemValue: any, filterValues: any[] | undefined): boolean {
|
|
13
|
+
if (!filterValues || filterValues.length === 0) {
|
|
14
|
+
return true
|
|
15
|
+
}
|
|
16
|
+
if (itemValue === null || itemValue === undefined || itemValue === '') {
|
|
17
|
+
return filterValues.includes('')
|
|
18
|
+
}
|
|
19
|
+
const itemString = itemValue instanceof Date ? itemValue.toISOString() : String(itemValue)
|
|
20
|
+
return filterValues.some((f) => String(f) === itemString)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function matchNumberFilter(itemValue: number | null, filter: NumberFilter | undefined): boolean {
|
|
24
|
+
if (!filter) {
|
|
25
|
+
return true
|
|
26
|
+
}
|
|
27
|
+
const { values, min, max } = filter
|
|
28
|
+
|
|
29
|
+
// Check range
|
|
30
|
+
if (itemValue == null) {
|
|
31
|
+
// If entry is empty, it fails range check if min or max is set
|
|
32
|
+
if ((min !== undefined && min !== null) || (max !== undefined && max !== null)) {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
if (min !== undefined && min !== null && itemValue < min) {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
if (max !== undefined && max !== null && itemValue > max) {
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return matchFilter(itemValue, values)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function matchDateFilter(itemValue: Date | null, filter: DateFilter | undefined): boolean {
|
|
48
|
+
if (!filter) {
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
const { values, start, end } = filter
|
|
52
|
+
const itemString = itemValue?.toISOString() ?? null
|
|
53
|
+
|
|
54
|
+
// Check range
|
|
55
|
+
if (itemString) {
|
|
56
|
+
if (start && itemString < start) {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
if (end && itemString > end) {
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
} else if (start || end) {
|
|
63
|
+
// If entry is empty, it fails range check if start or end is set
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return matchFilter(itemValue, values)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Matches a string value against a StringFilter.
|
|
72
|
+
* @param itemValue - The raw value to match (used for values array matching)
|
|
73
|
+
* @param filter - The StringFilter to apply
|
|
74
|
+
* @param displayValue - Optional display value for text filter matching (contains, startsWith, endsWith, exclude).
|
|
75
|
+
* Used for relation fields where the raw value is an ID but text should match against the label.
|
|
76
|
+
*/
|
|
77
|
+
export function matchStringFilter(
|
|
78
|
+
itemValue: string | null | undefined,
|
|
79
|
+
filter: StringFilter | undefined,
|
|
80
|
+
displayValue?: string,
|
|
81
|
+
): boolean {
|
|
82
|
+
if (!filter) {
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Use displayValue for text matching if provided, otherwise use the raw value
|
|
87
|
+
const textValue = displayValue ?? itemValue ?? ''
|
|
88
|
+
const lowerTextValue = textValue.toLowerCase()
|
|
89
|
+
|
|
90
|
+
// Check startsWith (against display value)
|
|
91
|
+
if (filter.startsWith && !lowerTextValue.startsWith(filter.startsWith.toLowerCase())) {
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
// Check contains (against display value)
|
|
95
|
+
if (filter.contains && !lowerTextValue.includes(filter.contains.toLowerCase())) {
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
// Check endsWith (against display value)
|
|
99
|
+
if (filter.endsWith && !lowerTextValue.endsWith(filter.endsWith.toLowerCase())) {
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
// Check exclude (against display value) - if value contains exclude string, reject it
|
|
103
|
+
if (filter.exclude && lowerTextValue.includes(filter.exclude.toLowerCase())) {
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Check values array (exact match against raw value)
|
|
108
|
+
return matchFilter(itemValue, filter.values)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Matches an item against a global search term by checking all filterable fields.
|
|
113
|
+
* Uses OR logic - if any field matches, the item is included.
|
|
114
|
+
* @param referencedItemsMap - Optional map of field keys to their referenced items Map.
|
|
115
|
+
* Used for relation fields to resolve IDs to display labels via getLabel.
|
|
116
|
+
* @param accessors - Optional map of field keys to accessor functions.
|
|
117
|
+
* Used when filterKey doesn't map directly to item property (e.g., discriminated union fields).
|
|
118
|
+
*/
|
|
119
|
+
export function matchGlobalSearch<
|
|
120
|
+
T extends { filterKey: string; getLabel?: (value: unknown, refs?: Map<string, any>) => string },
|
|
121
|
+
>(
|
|
122
|
+
searchTerm: string,
|
|
123
|
+
item: Record<string, unknown>,
|
|
124
|
+
filterConfig: readonly T[],
|
|
125
|
+
referencedItemsMap?: Map<string, Map<string, any> | undefined>,
|
|
126
|
+
accessors?: Map<string, (item: any) => any>,
|
|
127
|
+
): boolean {
|
|
128
|
+
const term = searchTerm.toLowerCase()
|
|
129
|
+
|
|
130
|
+
for (const config of filterConfig) {
|
|
131
|
+
// Use custom accessor if provided, otherwise fall back to direct property access
|
|
132
|
+
const accessor = accessors?.get(config.filterKey)
|
|
133
|
+
const value = accessor ? accessor(item) : item[config.filterKey]
|
|
134
|
+
if (value == null) {
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Pass referenced items to getLabel for relation fields to resolve IDs to labels
|
|
139
|
+
const referencedItems = referencedItemsMap?.get(config.filterKey)
|
|
140
|
+
const displayValue = config.getLabel ? config.getLabel(value, referencedItems) : String(value)
|
|
141
|
+
|
|
142
|
+
if (displayValue.toLowerCase().includes(term)) {
|
|
143
|
+
return true // OR logic - any match includes the row
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Checks if a StringFilter has text-based matching criteria set.
|
|
152
|
+
* Used to determine if referenced items need to be loaded for label resolution.
|
|
153
|
+
*/
|
|
154
|
+
export function hasTextMatching(filter: StringFilter | undefined): boolean {
|
|
155
|
+
if (!filter) {
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
return !!(filter.contains || filter.startsWith || filter.endsWith || filter.exclude)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Applies range filters for number and date fields.
|
|
163
|
+
*/
|
|
164
|
+
export function applyRangeFilters<T, TFilters extends FilterState>(
|
|
165
|
+
items: T[],
|
|
166
|
+
accessor: (item: T) => any,
|
|
167
|
+
config: FilterConfigItem<TFilters>,
|
|
168
|
+
filters: TFilters,
|
|
169
|
+
): T[] {
|
|
170
|
+
let filteredItems = items
|
|
171
|
+
if (config.valueType === 'number') {
|
|
172
|
+
const filter = filters[config.filterKey] as NumberFilter | undefined
|
|
173
|
+
if (filter) {
|
|
174
|
+
const { min, max } = filter
|
|
175
|
+
if (min !== undefined || max !== undefined) {
|
|
176
|
+
// Apply range filter manually. We only check the range part here, ignoring 'values'
|
|
177
|
+
filteredItems = filteredItems.filter((item) => matchNumberFilter(accessor(item), { min, max }))
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
} else if (config.valueType === 'date') {
|
|
181
|
+
const filter = filters[config.filterKey] as DateFilter | undefined
|
|
182
|
+
if (filter) {
|
|
183
|
+
const { start, end } = filter
|
|
184
|
+
if (start !== undefined || end !== undefined) {
|
|
185
|
+
// Apply range filter manually. We only check the range part here, ignoring 'values'
|
|
186
|
+
filteredItems = filteredItems.filter((item) => matchDateFilter(accessor(item), { start, end }))
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return filteredItems
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Applies string filters (contains, startsWith, endsWith, exclude) for string fields.
|
|
195
|
+
* For relation fields, pass getLabel and referencedItems to match against labels instead of raw IDs.
|
|
196
|
+
*/
|
|
197
|
+
export function applyStringFilters<T>(
|
|
198
|
+
items: T[],
|
|
199
|
+
accessor: (item: T) => any,
|
|
200
|
+
filter: StringFilter | undefined,
|
|
201
|
+
getLabel?: (value: unknown, referencedItems?: Map<string, any>) => string,
|
|
202
|
+
referencedItems?: Map<string, any>,
|
|
203
|
+
): T[] {
|
|
204
|
+
if (!filter) {
|
|
205
|
+
return items
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const { contains, startsWith, endsWith, exclude } = filter
|
|
209
|
+
|
|
210
|
+
// If no text filters are set, return all items
|
|
211
|
+
if (!contains && !startsWith && !endsWith && !exclude) {
|
|
212
|
+
return items
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Apply string filters manually. We only check the text filter parts here, ignoring 'values'
|
|
216
|
+
return items.filter((item) => {
|
|
217
|
+
const value = accessor(item)
|
|
218
|
+
const displayValue = getLabel ? getLabel(value, referencedItems) : String(value)
|
|
219
|
+
return matchStringFilter(value, { contains, startsWith, endsWith, exclude }, displayValue)
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Collects matches from the filtered items.
|
|
225
|
+
*/
|
|
226
|
+
export function getMatches<T>(filteredItems: T[], accessor: (item: T) => any): Set<string> {
|
|
227
|
+
const matches = new Set<string>()
|
|
228
|
+
for (const item of filteredItems) {
|
|
229
|
+
const val = accessor(item)
|
|
230
|
+
if (val === null || val === undefined || val === '') {
|
|
231
|
+
matches.add('')
|
|
232
|
+
} else if (val instanceof Date) {
|
|
233
|
+
matches.add(val.toISOString())
|
|
234
|
+
} else {
|
|
235
|
+
matches.add(String(val))
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return matches
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Generates all possible options based on field type.
|
|
243
|
+
*/
|
|
244
|
+
export function getOptions<T>(
|
|
245
|
+
allItems: T[],
|
|
246
|
+
accessor: (item: T) => any,
|
|
247
|
+
config: FilterConfigItem<any>,
|
|
248
|
+
referencedItems?: Map<string, any>,
|
|
249
|
+
): { value: string; label: string }[] {
|
|
250
|
+
if (config.kind === 'relation') {
|
|
251
|
+
return referencedItems ? getRelationOptions(allItems, accessor, config, referencedItems) : []
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (config.kind === 'enum') {
|
|
255
|
+
return config.enumValues.map((s: string) => ({ value: s, label: s }))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (config.kind === 'discriminatedUnion') {
|
|
259
|
+
return config.duMemberTypes.map((m: { type: string; label: string }) => ({ value: m.type, label: m.label }))
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (config.valueType === 'boolean') {
|
|
263
|
+
return [
|
|
264
|
+
{ value: 'true', label: 'Yes' },
|
|
265
|
+
{ value: 'false', label: 'No' },
|
|
266
|
+
]
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return getScalarOptions(allItems, accessor)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Generates relation options from referenced items.
|
|
274
|
+
*/
|
|
275
|
+
function getRelationOptions<T>(
|
|
276
|
+
allItems: T[],
|
|
277
|
+
accessor: (item: T) => any,
|
|
278
|
+
config: RelationFilterConfig<any>,
|
|
279
|
+
referencedItems: Map<string, any>,
|
|
280
|
+
): { value: string; label: string }[] {
|
|
281
|
+
const options: { value: string; label: string }[] = []
|
|
282
|
+
const usedIds = new Set<any>()
|
|
283
|
+
|
|
284
|
+
for (const item of allItems) {
|
|
285
|
+
usedIds.add(accessor(item))
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
for (const id of usedIds) {
|
|
289
|
+
if (id === null || id === undefined) {
|
|
290
|
+
options.push({ value: '', label: 'Empty' })
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
const ref = referencedItems.get(id)
|
|
294
|
+
if (ref) {
|
|
295
|
+
const label = String(ref[config.referencedModelLabelField] ?? ref[config.referencedModelIdField])
|
|
296
|
+
options.push({ value: String(ref[config.referencedModelIdField]), label })
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return options
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Generates scalar options from item values.
|
|
305
|
+
*/
|
|
306
|
+
function getScalarOptions<T>(allItems: T[], accessor: (item: T) => any): { value: string; label: string }[] {
|
|
307
|
+
const values = new Set<string>()
|
|
308
|
+
for (const item of allItems) {
|
|
309
|
+
const raw = accessor(item)
|
|
310
|
+
const normalized = raw instanceof Date ? raw.toISOString() : String(raw ?? '')
|
|
311
|
+
values.add(normalized)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const options: { value: string; label: string }[] = []
|
|
315
|
+
for (const v of values) {
|
|
316
|
+
options.push({ value: v, label: v === '' ? 'Empty' : v })
|
|
317
|
+
}
|
|
318
|
+
return options
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Formats and sorts the filter options.
|
|
323
|
+
*/
|
|
324
|
+
export function formatAndSortOptions(
|
|
325
|
+
options: { value: string; label: string }[],
|
|
326
|
+
matches: Set<string>,
|
|
327
|
+
): { value: string; label: string; hasMatches: boolean }[] {
|
|
328
|
+
return options
|
|
329
|
+
.map((opt) => ({
|
|
330
|
+
...opt,
|
|
331
|
+
hasMatches: matches.has(opt.value),
|
|
332
|
+
}))
|
|
333
|
+
.sort((a, b) => {
|
|
334
|
+
if (a.hasMatches && !b.hasMatches) {
|
|
335
|
+
return -1
|
|
336
|
+
}
|
|
337
|
+
if (!a.hasMatches && b.hasMatches) {
|
|
338
|
+
return 1
|
|
339
|
+
}
|
|
340
|
+
if (a.value === '' && b.value !== '') {
|
|
341
|
+
return -1
|
|
342
|
+
}
|
|
343
|
+
if (a.value !== '' && b.value === '') {
|
|
344
|
+
return 1
|
|
345
|
+
}
|
|
346
|
+
return a.label.localeCompare(b.label)
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ============================================================================
|
|
351
|
+
// Sorting Utilities
|
|
352
|
+
// ============================================================================
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Compares two values for sorting purposes based on their value type.
|
|
356
|
+
* Handles null/undefined values by placing them at the end (null < non-null).
|
|
357
|
+
* @returns negative if a < b, positive if a > b, 0 if equal
|
|
358
|
+
*/
|
|
359
|
+
export function compareValues(a: unknown, b: unknown, valueType: FieldValueType): number {
|
|
360
|
+
// Handle null/undefined - nulls sort to the end
|
|
361
|
+
if (a == null && b == null) return 0
|
|
362
|
+
if (a == null) return 1
|
|
363
|
+
if (b == null) return -1
|
|
364
|
+
|
|
365
|
+
switch (valueType) {
|
|
366
|
+
case 'number':
|
|
367
|
+
return Number(a) - Number(b)
|
|
368
|
+
case 'date': {
|
|
369
|
+
const dateA = a instanceof Date ? a : new Date(a as string)
|
|
370
|
+
const dateB = b instanceof Date ? b : new Date(b as string)
|
|
371
|
+
return dateA.getTime() - dateB.getTime()
|
|
372
|
+
}
|
|
373
|
+
case 'boolean':
|
|
374
|
+
// false < true (false = 0, true = 1)
|
|
375
|
+
return (a === true ? 1 : 0) - (b === true ? 1 : 0)
|
|
376
|
+
case 'string':
|
|
377
|
+
default:
|
|
378
|
+
if (typeof a === 'object' || typeof b === 'object') return 0
|
|
379
|
+
return String(a).localeCompare(String(b))
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
type SortFieldConfig = {
|
|
384
|
+
valueType: FieldValueType
|
|
385
|
+
getLabel?: (value: unknown, refs?: Map<string, any>) => string
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Compares two items for a single sort field, handling label resolution and null values.
|
|
390
|
+
* @returns negative if a < b, positive if a > b, 0 if equal
|
|
391
|
+
*/
|
|
392
|
+
export function compareFieldValues(
|
|
393
|
+
aRaw: unknown,
|
|
394
|
+
bRaw: unknown,
|
|
395
|
+
direction: 'asc' | 'desc',
|
|
396
|
+
config: SortFieldConfig,
|
|
397
|
+
referencedItems?: Map<string, any>,
|
|
398
|
+
): number {
|
|
399
|
+
const aValue = config.getLabel ? config.getLabel(aRaw, referencedItems) : aRaw
|
|
400
|
+
const bValue = config.getLabel ? config.getLabel(bRaw, referencedItems) : bRaw
|
|
401
|
+
|
|
402
|
+
const aIsNull = aValue == null || aValue === ''
|
|
403
|
+
const bIsNull = bValue == null || bValue === ''
|
|
404
|
+
if (aIsNull && bIsNull) return 0
|
|
405
|
+
if (aIsNull) return 1
|
|
406
|
+
if (bIsNull) return -1
|
|
407
|
+
|
|
408
|
+
const comparison = compareValues(aValue, bValue, config.valueType)
|
|
409
|
+
return direction === 'asc' ? comparison : -comparison
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ============================================================================
|
|
413
|
+
// Pagination Utilities
|
|
414
|
+
// ============================================================================
|
|
415
|
+
|
|
416
|
+
const LIMIT_ALL = -1
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Paginates an array of data.
|
|
420
|
+
* @param data - The full array to paginate
|
|
421
|
+
* @param cursor - Page number (1-indexed)
|
|
422
|
+
* @param limit - Items per page. Use -1 (LIMIT_ALL) to return all items.
|
|
423
|
+
* @returns Paginated result with data slice and metadata
|
|
424
|
+
*/
|
|
425
|
+
export function paginate<T>(data: T[], cursor: number, limit: number): PaginatedResult<T> {
|
|
426
|
+
if (data.length === 0) {
|
|
427
|
+
return {
|
|
428
|
+
data: [],
|
|
429
|
+
total: 0,
|
|
430
|
+
perPage: limit,
|
|
431
|
+
currentPage: cursor,
|
|
432
|
+
cursor: null,
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const paginatedData = limit === LIMIT_ALL ? data : data.slice((cursor - 1) * limit, cursor * limit)
|
|
437
|
+
return {
|
|
438
|
+
data: paginatedData,
|
|
439
|
+
total: data.length,
|
|
440
|
+
perPage: limit,
|
|
441
|
+
currentPage: cursor,
|
|
442
|
+
cursor: cursor * limit < data.length ? cursor + 1 : null,
|
|
443
|
+
}
|
|
444
|
+
}
|