@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.
Files changed (38) hide show
  1. package/dist/backend-rest-api/generators/model-controller.generator.js +109 -11
  2. package/dist/backend-rest-api/generators/model-controller.generator.js.map +1 -1
  3. package/dist/backend-rest-api/rest-api.generator.js +4 -0
  4. package/dist/backend-rest-api/rest-api.generator.js.map +1 -1
  5. package/dist/backend-rest-api/template/utils/src/fieldSelection.spec.ts +252 -0
  6. package/dist/backend-rest-api/template/utils/src/fieldSelection.ts +66 -0
  7. package/dist/backend-router-trpc/generators/model-routes.generator.js +27 -2
  8. package/dist/backend-router-trpc/generators/model-routes.generator.js.map +1 -1
  9. package/dist/backend-router-trpc/router-trpc.generator.d.ts +1 -0
  10. package/dist/backend-router-trpc/router-trpc.generator.js +1 -0
  11. package/dist/backend-router-trpc/router-trpc.generator.js.map +1 -1
  12. package/dist/backend-update/model-update-service.generator.js +145 -8
  13. package/dist/backend-update/model-update-service.generator.js.map +1 -1
  14. package/dist/backend-view/model-view-service.generator.js +276 -19
  15. package/dist/backend-view/model-view-service.generator.js.map +1 -1
  16. package/dist/backend-view/template/{filter.utils.test.ts → query.utils.test.ts} +101 -1
  17. package/dist/backend-view/template/query.utils.ts +444 -0
  18. package/dist/frontend-admin/generators/model-admin-page.generator.js +50 -32
  19. package/dist/frontend-admin/generators/model-admin-page.generator.js.map +1 -1
  20. package/dist/frontend-core/template/src/components/admin/column-header-state-icon.tsx +72 -0
  21. package/dist/frontend-core/template/src/components/admin/table-filter.tsx +195 -43
  22. package/dist/frontend-tables/generators/model-table.generator.js +108 -29
  23. package/dist/frontend-tables/generators/model-table.generator.js.map +1 -1
  24. package/dist/frontend-trpc-client/generators/model-hook.generator.js +130 -16
  25. package/dist/frontend-trpc-client/generators/model-hook.generator.js.map +1 -1
  26. package/dist/types/generators/model-type.generator.js +97 -31
  27. package/dist/types/generators/model-type.generator.js.map +1 -1
  28. package/dist/types/template/query.types.ts +151 -0
  29. package/dist/types/types.generator.d.ts +15 -0
  30. package/dist/types/types.generator.js +5 -3
  31. package/dist/types/types.generator.js.map +1 -1
  32. package/package.json +2 -2
  33. package/dist/backend-rest-api/generators/zod-exception-filter.generator.d.ts +0 -1
  34. package/dist/backend-rest-api/generators/zod-exception-filter.generator.js +0 -28
  35. package/dist/backend-rest-api/generators/zod-exception-filter.generator.js.map +0 -1
  36. package/dist/backend-view/template/filter.utils.ts +0 -218
  37. package/dist/frontend-core/template/src/components/admin/table-filter-header-icon.tsx +0 -30
  38. 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
- } from './filter.utils'
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
+ }