qdadm 1.13.0 → 1.19.2
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/package.json +8 -4
- package/src/chain/ActiveStack.ts +79 -98
- package/src/chain/StackHydrator.ts +3 -2
- package/src/chain/index.ts +7 -1
- package/src/components/QdadmRoot.vue +52 -0
- package/src/components/edit/FormActions.vue +9 -6
- package/src/components/edit/LookupPickerDialog.vue +6 -3
- package/src/components/index.ts +6 -0
- package/src/composables/useEntityItemFormPage.ts +1 -0
- package/src/composables/useEntityItemShowPage.ts +1 -0
- package/src/composables/useFieldManager.ts +100 -3
- package/src/composables/useListPage.ts +50 -59
- package/src/composables/useListPage.utils.ts +101 -0
- package/src/composables/useNavigation.ts +26 -3
- package/src/composables/useOptionsLookup.ts +5 -1
- package/src/gen/generateManagers.test.js +27 -0
- package/src/gen/generateManagers.ts +12 -0
- package/src/hooks/HookRegistry.ts +14 -435
- package/src/i18n/I18n.ts +344 -0
- package/src/i18n/IncrementalDomainProvider.ts +153 -0
- package/src/i18n/InlineTranslationProvider.ts +4 -0
- package/src/i18n/LazyTranslationProvider.ts +102 -0
- package/src/i18n/MessagesRegistry.ts +4 -0
- package/src/i18n/Resolver.ts +4 -0
- package/src/i18n/__tests__/I18n.test.ts +169 -0
- package/src/i18n/__tests__/IncrementalDomainProvider.test.ts +146 -0
- package/src/i18n/__tests__/LazyTranslationProvider.test.ts +100 -0
- package/src/i18n/__tests__/Resolver.test.ts +271 -0
- package/src/i18n/defaults/DefaultCoreProvider.ts +28 -0
- package/src/i18n/defaults/core.en.yml +55 -0
- package/src/i18n/defaults/core.fr.yml +55 -0
- package/src/i18n/index.ts +55 -0
- package/src/i18n/loaders/raw-modules.d.ts +15 -0
- package/src/i18n/loaders/yaml.ts +35 -0
- package/src/i18n/strategies.ts +4 -0
- package/src/i18n/types.ts +34 -0
- package/src/i18n/useI18n.ts +34 -0
- package/src/index.ts +37 -0
- package/src/kernel/EventRouter.ts +17 -300
- package/src/kernel/Kernel.i18n.ts +29 -0
- package/src/kernel/Kernel.modules.ts +6 -0
- package/src/kernel/Kernel.registries.ts +10 -2
- package/src/kernel/Kernel.routing.ts +43 -1
- package/src/kernel/Kernel.ts +43 -0
- package/src/kernel/Kernel.types.ts +52 -1
- package/src/kernel/Kernel.vue.ts +121 -15
- package/src/kernel/KernelContext.entities.ts +80 -0
- package/src/kernel/KernelContext.events.ts +57 -0
- package/src/kernel/KernelContext.i18n.ts +37 -0
- package/src/kernel/KernelContext.permissions.ts +38 -0
- package/src/kernel/KernelContext.routing.ts +280 -0
- package/src/kernel/KernelContext.ts +125 -834
- package/src/kernel/KernelContext.types.ts +173 -0
- package/src/kernel/KernelContext.zones.ts +54 -0
- package/src/kernel/SSEBridge.ts +7 -362
- package/src/kernel/SignalBus.ts +24 -148
- package/src/modules/debug/AuthCollector.ts +48 -1
- package/src/modules/debug/Collector.ts +16 -302
- package/src/modules/debug/DebugBridge.ts +10 -171
- package/src/modules/debug/DebugModule.ts +35 -5
- package/src/modules/debug/EntitiesCollector.ts +97 -1
- package/src/modules/debug/ErrorCollector.ts +2 -77
- package/src/modules/debug/I18nCollector.ts +9 -0
- package/src/modules/debug/LocalStorageAdapter.ts +3 -147
- package/src/modules/debug/RouterCollector.ts +101 -1
- package/src/modules/debug/SignalCollector.ts +2 -150
- package/src/modules/debug/ToastCollector.ts +2 -91
- package/src/modules/debug/ZonesCollector.ts +93 -1
- package/src/modules/debug/components/DebugBar.vue +19 -775
- package/src/modules/debug/components/adminPanelsConfig.ts +42 -0
- package/src/modules/debug/components/index.ts +4 -3
- package/src/modules/debug/components/panels/AuthPanel.vue +1 -1
- package/src/modules/debug/components/panels/EntitiesPanel.vue +5 -5
- package/src/modules/debug/components/panels/I18nPanel.vue +738 -0
- package/src/modules/debug/components/panels/RouterPanel.vue +1 -1
- package/src/modules/debug/components/panels/index.ts +10 -4
- package/src/modules/debug/index.ts +15 -0
- package/src/modules/debug/styles.scss +22 -18
- package/src/utils/index.ts +0 -3
- package/src/vite/qdadmDebugPlugin.ts +401 -0
- package/src/vite-env.d.ts +16 -0
- package/src/modules/debug/components/ObjectTree.vue +0 -123
- package/src/modules/debug/components/panels/EntriesPanel.vue +0 -100
- package/src/modules/debug/components/panels/SignalsPanel.vue +0 -188
- package/src/modules/debug/components/panels/ToastsPanel.vue +0 -45
- package/src/utils/debugInjector.ts +0 -306
|
@@ -34,6 +34,7 @@ import { useHooks } from './useHooks.js'
|
|
|
34
34
|
import { useEntityItemPage, type ParentConfig, type UseEntityItemPageReturn } from './useEntityItemPage.js'
|
|
35
35
|
import { useActiveStack } from '../chain/useActiveStack.js'
|
|
36
36
|
import { FilterQuery } from '../query/FilterQuery'
|
|
37
|
+
import { useI18n } from '../i18n/useI18n'
|
|
37
38
|
|
|
38
39
|
// Import types from dedicated types file
|
|
39
40
|
import type {
|
|
@@ -80,63 +81,21 @@ export type {
|
|
|
80
81
|
UseListPageReturn,
|
|
81
82
|
} from './useListPage.types'
|
|
82
83
|
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
document.cookie = `${name}=${value}; expires=${expires}; path=/; SameSite=Lax`
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function getSavedPageSize(defaultSize: number): number {
|
|
98
|
-
const saved = getCookie(COOKIE_NAME)
|
|
99
|
-
if (saved) {
|
|
100
|
-
const parsed = parseInt(saved, 10)
|
|
101
|
-
if ([10, 50, 100].includes(parsed)) return parsed
|
|
102
|
-
}
|
|
103
|
-
return defaultSize
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Default label fallback: convert snake_case to Title Case
|
|
107
|
-
function snakeToTitle(str: string): string {
|
|
108
|
-
if (!str) return 'Unknown'
|
|
109
|
-
return String(str)
|
|
110
|
-
.split('_')
|
|
111
|
-
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
112
|
-
.join(' ')
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Standard pagination options
|
|
116
|
-
export const PAGE_SIZE_OPTIONS = [10, 50, 100]
|
|
117
|
-
|
|
118
|
-
// Session storage utilities for filter persistence
|
|
119
|
-
const FILTER_SESSION_PREFIX = 'qdadm_filters_'
|
|
120
|
-
|
|
121
|
-
function getSessionFilters(key: string): Record<string, unknown> | null {
|
|
122
|
-
try {
|
|
123
|
-
const stored = sessionStorage.getItem(FILTER_SESSION_PREFIX + key)
|
|
124
|
-
return stored ? JSON.parse(stored) : null
|
|
125
|
-
} catch {
|
|
126
|
-
return null
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function setSessionFilters(key: string, filters: Record<string, unknown>): void {
|
|
131
|
-
try {
|
|
132
|
-
sessionStorage.setItem(FILTER_SESSION_PREFIX + key, JSON.stringify(filters))
|
|
133
|
-
} catch {
|
|
134
|
-
// Ignore storage errors
|
|
135
|
-
}
|
|
136
|
-
}
|
|
84
|
+
// Stateless utilities (cookies, session storage, formatters, constants).
|
|
85
|
+
import {
|
|
86
|
+
PAGE_SIZE_OPTIONS,
|
|
87
|
+
SMART_FILTER_THRESHOLD,
|
|
88
|
+
clearSessionFilters,
|
|
89
|
+
getSavedPageSize,
|
|
90
|
+
getSessionFilters,
|
|
91
|
+
persistPageSize,
|
|
92
|
+
setSessionFilters,
|
|
93
|
+
snakeToTitle,
|
|
94
|
+
} from './useListPage.utils'
|
|
137
95
|
|
|
138
|
-
//
|
|
139
|
-
|
|
96
|
+
// Re-export PAGE_SIZE_OPTIONS so existing consumers (qdadm/index.ts,
|
|
97
|
+
// composables/index.ts) keep their import path.
|
|
98
|
+
export { PAGE_SIZE_OPTIONS } from './useListPage.utils'
|
|
140
99
|
|
|
141
100
|
|
|
142
101
|
/**
|
|
@@ -168,6 +127,9 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
168
127
|
// Mobile detection (viewport width < 768px)
|
|
169
128
|
const isMobile = ref(window.innerWidth < 768)
|
|
170
129
|
|
|
130
|
+
// i18n integration — column headers resolve via entities.{entity}.fields.{field}
|
|
131
|
+
const { i18n: kernelI18n, locale: i18nLocale } = useI18n()
|
|
132
|
+
|
|
171
133
|
// Get EntityManager via orchestrator
|
|
172
134
|
const orchestrator = inject<Orchestrator | null>('qdadmOrchestrator')
|
|
173
135
|
|
|
@@ -293,15 +255,44 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
293
255
|
|
|
294
256
|
// ============ COLUMNS ============
|
|
295
257
|
const columnsMap = ref<Map<string, ColumnConfig>>(new Map())
|
|
258
|
+
// Track inline-supplied column headers so relabel() can fall back to them
|
|
259
|
+
// when the i18n bundle has no translation for the field.
|
|
260
|
+
const columnInlineHeaders = new Map<string, string | undefined>()
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Resolve a column header: i18n hit on entities.{entity}.fields.{field} wins,
|
|
264
|
+
* otherwise the inline `header` option, otherwise a capitalized field name.
|
|
265
|
+
*/
|
|
266
|
+
function resolveColumnHeader(field: string, inline?: string): string {
|
|
267
|
+
if (entity && kernelI18n) {
|
|
268
|
+
const trace = kernelI18n.resolve(`entities.${entity}.fields.${field}`)
|
|
269
|
+
if (trace.hit) return trace.result
|
|
270
|
+
}
|
|
271
|
+
return inline || field.charAt(0).toUpperCase() + field.slice(1)
|
|
272
|
+
}
|
|
296
273
|
|
|
297
274
|
function addColumn(field: string, columnConfig: Partial<ColumnConfig> = {}): void {
|
|
275
|
+
columnInlineHeaders.set(field, columnConfig.header)
|
|
298
276
|
columnsMap.value.set(field, {
|
|
299
277
|
field,
|
|
300
|
-
header: columnConfig.header
|
|
278
|
+
header: resolveColumnHeader(field, columnConfig.header),
|
|
301
279
|
...columnConfig,
|
|
280
|
+
// Re-apply resolved header after spread so it wins over a stale inline.
|
|
281
|
+
...(columnConfig.header ? {} : { header: resolveColumnHeader(field) }),
|
|
302
282
|
})
|
|
303
283
|
}
|
|
304
284
|
|
|
285
|
+
// Re-resolve column headers when locale changes.
|
|
286
|
+
watch(i18nLocale, () => {
|
|
287
|
+
if (!entity || !kernelI18n) return
|
|
288
|
+
for (const [field, current] of columnsMap.value.entries()) {
|
|
289
|
+
const newHeader = resolveColumnHeader(field, columnInlineHeaders.get(field))
|
|
290
|
+
if (current.header !== newHeader) {
|
|
291
|
+
columnsMap.value.set(field, { ...current, header: newHeader })
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
})
|
|
295
|
+
|
|
305
296
|
function removeColumn(field: string): void {
|
|
306
297
|
columnsMap.value.delete(field)
|
|
307
298
|
}
|
|
@@ -596,7 +587,7 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
596
587
|
filterValues.value = cleared
|
|
597
588
|
searchQuery.value = ''
|
|
598
589
|
if (persistFilters) {
|
|
599
|
-
|
|
590
|
+
clearSessionFilters(filterSessionKey)
|
|
600
591
|
}
|
|
601
592
|
if (syncUrlParams) {
|
|
602
593
|
const query = { ...route.query } as Record<string, string>
|
|
@@ -1003,7 +994,7 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
1003
994
|
function onPage(event: { page: number; rows: number }): void {
|
|
1004
995
|
page.value = event.page + 1
|
|
1005
996
|
pageSize.value = event.rows
|
|
1006
|
-
|
|
997
|
+
persistPageSize(event.rows)
|
|
1007
998
|
loadItems()
|
|
1008
999
|
}
|
|
1009
1000
|
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useListPage helpers — stateless utilities used by the list page composable.
|
|
3
|
+
*
|
|
4
|
+
* Kept module-local so they're trivially importable from the orchestrator
|
|
5
|
+
* file (`useListPage.ts`) and from any future split modules without dragging
|
|
6
|
+
* the full `useListPage` runtime context along.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
// Pagination cookie persistence
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const COOKIE_NAME = 'qdadm_pageSize'
|
|
14
|
+
const COOKIE_EXPIRY_DAYS = 365
|
|
15
|
+
|
|
16
|
+
export function getCookie(name: string): string | null {
|
|
17
|
+
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'))
|
|
18
|
+
return match?.[2] ?? null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function setCookie(name: string, value: string | number, days: number): void {
|
|
22
|
+
const expires = new Date(Date.now() + days * 864e5).toUTCString()
|
|
23
|
+
document.cookie = `${name}=${value}; expires=${expires}; path=/; SameSite=Lax`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read the saved page size from cookie. Returns the default when absent or
|
|
28
|
+
* not in the canonical {@link PAGE_SIZE_OPTIONS} list.
|
|
29
|
+
*/
|
|
30
|
+
export function getSavedPageSize(defaultSize: number): number {
|
|
31
|
+
const saved = getCookie(COOKIE_NAME)
|
|
32
|
+
if (saved) {
|
|
33
|
+
const parsed = parseInt(saved, 10)
|
|
34
|
+
if (PAGE_SIZE_OPTIONS.includes(parsed)) return parsed
|
|
35
|
+
}
|
|
36
|
+
return defaultSize
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function persistPageSize(size: number): void {
|
|
40
|
+
setCookie(COOKIE_NAME, size, COOKIE_EXPIRY_DAYS)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
// Default label formatter (snake_case → Title Case)
|
|
45
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export function snakeToTitle(str: string): string {
|
|
48
|
+
if (!str) return 'Unknown'
|
|
49
|
+
return String(str)
|
|
50
|
+
.split('_')
|
|
51
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
52
|
+
.join(' ')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
// Pagination options
|
|
57
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/** Canonical page size options offered to the user. */
|
|
60
|
+
export const PAGE_SIZE_OPTIONS = [10, 50, 100]
|
|
61
|
+
|
|
62
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
63
|
+
// Filter session storage
|
|
64
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
const FILTER_SESSION_PREFIX = 'qdadm_filters_'
|
|
67
|
+
|
|
68
|
+
export function getSessionFilters(key: string): Record<string, unknown> | null {
|
|
69
|
+
try {
|
|
70
|
+
const stored = sessionStorage.getItem(FILTER_SESSION_PREFIX + key)
|
|
71
|
+
return stored ? JSON.parse(stored) : null
|
|
72
|
+
} catch {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function setSessionFilters(key: string, filters: Record<string, unknown>): void {
|
|
78
|
+
try {
|
|
79
|
+
sessionStorage.setItem(FILTER_SESSION_PREFIX + key, JSON.stringify(filters))
|
|
80
|
+
} catch {
|
|
81
|
+
// Ignore storage errors (quota exceeded, disabled by user, …).
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function clearSessionFilters(key: string): void {
|
|
86
|
+
try {
|
|
87
|
+
sessionStorage.removeItem(FILTER_SESSION_PREFIX + key)
|
|
88
|
+
} catch {
|
|
89
|
+
// Ignore storage errors.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
94
|
+
// Smart filter discovery
|
|
95
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Threshold below which the list switches to client-side filtering when
|
|
99
|
+
* `filterMode: 'auto'` is in effect.
|
|
100
|
+
*/
|
|
101
|
+
export const SMART_FILTER_THRESHOLD = 50
|
|
@@ -13,6 +13,7 @@ import { computed, inject, ref, onMounted, type ComputedRef, type Ref } from 'vu
|
|
|
13
13
|
import { useRoute, useRouter } from 'vue-router'
|
|
14
14
|
import { getNavSections, alterMenuSections, isMenuAltered } from '../module/moduleRegistry'
|
|
15
15
|
import { useSemanticBreadcrumb } from './useSemanticBreadcrumb'
|
|
16
|
+
import { useI18n } from '../i18n/useI18n'
|
|
16
17
|
import type { HookRegistry } from '../hooks/HookRegistry'
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -81,6 +82,9 @@ export function useNavigation(): UseNavigationReturn {
|
|
|
81
82
|
const orchestrator = inject<Orchestrator | null>('qdadmOrchestrator', null)
|
|
82
83
|
const hooks = inject<HookRegistry | null>('qdadmHooks', null)
|
|
83
84
|
|
|
85
|
+
// i18n integration — labels resolve through nav.sections.* / nav.routes.*
|
|
86
|
+
const { i18n: kernelI18n, locale: i18nLocale } = useI18n()
|
|
87
|
+
|
|
84
88
|
// Semantic breadcrumb for entity-based active detection
|
|
85
89
|
const { breadcrumb } = useSemanticBreadcrumb()
|
|
86
90
|
|
|
@@ -112,18 +116,37 @@ export function useNavigation(): UseNavigationReturn {
|
|
|
112
116
|
}
|
|
113
117
|
})
|
|
114
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Resolve a label through i18n with the given convention key, falling back
|
|
121
|
+
* to the inline label when the bundle has no matching translation.
|
|
122
|
+
*/
|
|
123
|
+
function resolveNavLabel(key: string, fallback: string): string {
|
|
124
|
+
if (!kernelI18n) return fallback
|
|
125
|
+
const trace = kernelI18n.resolve(key)
|
|
126
|
+
return trace.hit ? trace.result : fallback
|
|
127
|
+
}
|
|
128
|
+
|
|
115
129
|
// Get nav sections from registry, filtering items based on permissions
|
|
116
|
-
// Depends on alterVersion to trigger re-computation after
|
|
130
|
+
// Depends on alterVersion + i18nLocale to trigger re-computation after
|
|
131
|
+
// alteration or locale change.
|
|
117
132
|
const navSections = computed(() => {
|
|
118
|
-
// Force
|
|
133
|
+
// Force dependencies for reactivity
|
|
119
134
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
120
135
|
alterVersion.value
|
|
136
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
137
|
+
i18nLocale.value
|
|
121
138
|
|
|
122
139
|
const sections = getNavSections()
|
|
123
140
|
return sections
|
|
124
141
|
.map((section) => ({
|
|
125
142
|
...section,
|
|
126
|
-
|
|
143
|
+
title: resolveNavLabel(`nav.sections.${section.title}`, section.title),
|
|
144
|
+
items: section.items
|
|
145
|
+
.filter((item) => canAccessNavItem(item))
|
|
146
|
+
.map((item) => ({
|
|
147
|
+
...item,
|
|
148
|
+
label: resolveNavLabel(`nav.routes.${item.route}`, item.label),
|
|
149
|
+
})),
|
|
127
150
|
}))
|
|
128
151
|
.filter((section) => section.items.length > 0) // Hide empty sections
|
|
129
152
|
})
|
|
@@ -170,7 +170,11 @@ export function useOptionsLookup(config: UseOptionsLookupConfig): UseOptionsLook
|
|
|
170
170
|
if (config.entity) {
|
|
171
171
|
try {
|
|
172
172
|
const orc = useOrchestrator()
|
|
173
|
-
|
|
173
|
+
// Generic-vs-non-generic: getManager is parameterised by an
|
|
174
|
+
// EntityRecord type variable that the local alias intentionally
|
|
175
|
+
// erases. Bridge through `unknown` so strict TS doesn't reject
|
|
176
|
+
// the cross-shape assignment.
|
|
177
|
+
getManager = orc.getManager as unknown as typeof getManager
|
|
174
178
|
} catch {
|
|
175
179
|
// Orchestrator not available
|
|
176
180
|
}
|
|
@@ -474,6 +474,15 @@ describe('fieldTypeToTsType', () => {
|
|
|
474
474
|
expect(fieldTypeToTsType('number', false)).toBe('number | null')
|
|
475
475
|
expect(fieldTypeToTsType('boolean', false)).toBe('boolean | null')
|
|
476
476
|
})
|
|
477
|
+
|
|
478
|
+
it('falls back to unknown for unrecognized types instead of emitting literal undefined', () => {
|
|
479
|
+
// Repro for v1.19.1 bug: connectors could yield types outside UnifiedFieldType
|
|
480
|
+
// (e.g. OpenAPI oneOf/discriminator). Without a default branch the IIFE
|
|
481
|
+
// returned `undefined` and template-literal interpolation wrote the string
|
|
482
|
+
// "undefined" into the generated .ts file.
|
|
483
|
+
expect(fieldTypeToTsType('mystery-type', true)).toBe('unknown')
|
|
484
|
+
expect(fieldTypeToTsType('mystery-type', false)).toBe('unknown | null')
|
|
485
|
+
})
|
|
477
486
|
})
|
|
478
487
|
|
|
479
488
|
describe('generateEntityInterface', () => {
|
|
@@ -498,4 +507,22 @@ describe('generateEntityInterface', () => {
|
|
|
498
507
|
|
|
499
508
|
expect(result).not.toContain('password')
|
|
500
509
|
})
|
|
510
|
+
|
|
511
|
+
it('skips dotted-name children of nested object fields', () => {
|
|
512
|
+
// Repro for v1.19.1 bug: OpenAPIConnector flattens one level of nested
|
|
513
|
+
// object props as dotted keys for runtime metadata. The interface emitter
|
|
514
|
+
// must not write those keys at the top level — they're invalid TS syntax,
|
|
515
|
+
// and the parent object field already carries Record<string, unknown>.
|
|
516
|
+
const result = generateEntityInterface('commands', {
|
|
517
|
+
id: { name: 'id', type: 'uuid' },
|
|
518
|
+
filter: { name: 'filter', type: 'object' },
|
|
519
|
+
'filter.botUuids': { name: 'filter.botUuids', type: 'array' },
|
|
520
|
+
'filter.tags': { name: 'filter.tags', type: 'array' }
|
|
521
|
+
}, 'id')
|
|
522
|
+
|
|
523
|
+
expect(result).toContain('filter?: Record<string, unknown> | null')
|
|
524
|
+
expect(result).not.toContain('filter.botUuids')
|
|
525
|
+
expect(result).not.toContain('filter.tags')
|
|
526
|
+
expect(result).not.toMatch(/^\s*filter\./m)
|
|
527
|
+
})
|
|
501
528
|
})
|
|
@@ -88,6 +88,12 @@ export function fieldTypeToTsType(type: UnifiedFieldType, required: boolean): st
|
|
|
88
88
|
return 'unknown[]'
|
|
89
89
|
case 'object':
|
|
90
90
|
return 'Record<string, unknown>'
|
|
91
|
+
default:
|
|
92
|
+
// Fallback for types widened beyond UnifiedFieldType at runtime
|
|
93
|
+
// (e.g. OpenAPI oneOf/discriminator that FieldMapper couldn't classify).
|
|
94
|
+
// Without this, the IIFE returns undefined and we'd emit the literal
|
|
95
|
+
// string "undefined" into the generated .ts file.
|
|
96
|
+
return 'unknown'
|
|
91
97
|
}
|
|
92
98
|
})()
|
|
93
99
|
return required ? tsType : `${tsType} | null`
|
|
@@ -107,6 +113,12 @@ export function generateEntityInterface(
|
|
|
107
113
|
|
|
108
114
|
for (const [fieldName, field] of Object.entries(fields)) {
|
|
109
115
|
if (field.hidden) continue
|
|
116
|
+
// Connectors (e.g. OpenAPIConnector) flatten one level of nested object
|
|
117
|
+
// properties as dotted keys ("filter.botUuids") for runtime form/column
|
|
118
|
+
// metadata. The parent object field is already typed as
|
|
119
|
+
// Record<string, unknown>, and dotted keys are not legal at the top level
|
|
120
|
+
// of a TS interface body — skip them here.
|
|
121
|
+
if (fieldName.includes('.')) continue
|
|
110
122
|
const isRequired = field.required || fieldName === idField
|
|
111
123
|
const tsType = fieldTypeToTsType(field.type, isRequired)
|
|
112
124
|
const optional = isRequired ? '' : '?'
|