qdadm 0.17.0 → 0.25.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/package.json +1 -1
- package/src/composables/useListPageBuilder.js +285 -46
- package/src/entity/EntityManager.js +338 -24
- package/src/entity/auth/AuthAdapter.js +59 -0
- package/src/entity/auth/RoleHierarchy.js +153 -0
- package/src/entity/auth/SecurityChecker.js +167 -0
- package/src/entity/auth/index.js +7 -0
- package/src/entity/storage/ApiStorage.js +19 -2
- package/src/entity/storage/LocalStorage.js +25 -2
- package/src/entity/storage/MemoryStorage.js +28 -0
- package/src/entity/storage/MockApiStorage.js +25 -2
- package/src/entity/storage/SdkStorage.js +17 -2
- package/src/entity/storage/index.js +105 -0
- package/src/index.js +7 -0
- package/src/kernel/Kernel.js +73 -4
- package/src/module/moduleRegistry.js +31 -22
- package/src/query/FilterQuery.js +277 -0
- package/src/query/QueryExecutor.js +332 -0
- package/src/query/index.js +8 -0
- package/src/zones/ZoneRegistry.js +10 -3
package/src/kernel/Kernel.js
CHANGED
|
@@ -51,6 +51,7 @@ import { createSignalBus } from './SignalBus.js'
|
|
|
51
51
|
import { createZoneRegistry } from '../zones/ZoneRegistry.js'
|
|
52
52
|
import { registerStandardZones } from '../zones/zones.js'
|
|
53
53
|
import { createHookRegistry } from '../hooks/HookRegistry.js'
|
|
54
|
+
import { createSecurityChecker } from '../entity/auth/SecurityChecker.js'
|
|
54
55
|
|
|
55
56
|
export class Kernel {
|
|
56
57
|
/**
|
|
@@ -71,6 +72,7 @@ export class Kernel {
|
|
|
71
72
|
* @param {object} options.features - Feature toggles { auth, poweredBy }
|
|
72
73
|
* @param {object} options.primevue - PrimeVue config { plugin, theme, options }
|
|
73
74
|
* @param {object} options.layouts - Layout components { list, form, dashboard, base }
|
|
75
|
+
* @param {object} options.security - Security config { role_hierarchy, role_permissions, entity_permissions }
|
|
74
76
|
*/
|
|
75
77
|
constructor(options) {
|
|
76
78
|
this.options = options
|
|
@@ -81,6 +83,7 @@ export class Kernel {
|
|
|
81
83
|
this.zoneRegistry = null
|
|
82
84
|
this.hookRegistry = null
|
|
83
85
|
this.layoutComponents = null
|
|
86
|
+
this.securityChecker = null
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
/**
|
|
@@ -88,12 +91,17 @@ export class Kernel {
|
|
|
88
91
|
* @returns {App} Vue app instance ready to mount
|
|
89
92
|
*/
|
|
90
93
|
createApp() {
|
|
91
|
-
|
|
92
|
-
this._createRouter()
|
|
94
|
+
// 1. Create services first (modules need them)
|
|
93
95
|
this._createSignalBus()
|
|
94
96
|
this._createHookRegistry()
|
|
95
|
-
this._createOrchestrator()
|
|
96
97
|
this._createZoneRegistry()
|
|
98
|
+
// 2. Initialize modules (can use all services, registers routes)
|
|
99
|
+
this._initModules()
|
|
100
|
+
// 3. Create router (needs routes from modules)
|
|
101
|
+
this._createRouter()
|
|
102
|
+
// 4. Create orchestrator and remaining components
|
|
103
|
+
this._createOrchestrator()
|
|
104
|
+
this._setupSecurity()
|
|
97
105
|
this._createLayoutComponents()
|
|
98
106
|
this._createVueApp()
|
|
99
107
|
this._installPlugins()
|
|
@@ -102,13 +110,19 @@ export class Kernel {
|
|
|
102
110
|
|
|
103
111
|
/**
|
|
104
112
|
* Initialize modules from glob import
|
|
113
|
+
* Passes services to modules for zone/signal/hook registration
|
|
105
114
|
*/
|
|
106
115
|
_initModules() {
|
|
107
116
|
if (this.options.sectionOrder) {
|
|
108
117
|
setSectionOrder(this.options.sectionOrder)
|
|
109
118
|
}
|
|
110
119
|
if (this.options.modules) {
|
|
111
|
-
initModules(this.options.modules,
|
|
120
|
+
initModules(this.options.modules, {
|
|
121
|
+
...this.options.modulesOptions,
|
|
122
|
+
zones: this.zoneRegistry,
|
|
123
|
+
signals: this.signals,
|
|
124
|
+
hooks: this.hookRegistry
|
|
125
|
+
})
|
|
112
126
|
}
|
|
113
127
|
}
|
|
114
128
|
|
|
@@ -211,6 +225,44 @@ export class Kernel {
|
|
|
211
225
|
})
|
|
212
226
|
}
|
|
213
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Setup security layer (role hierarchy, permissions)
|
|
230
|
+
*
|
|
231
|
+
* If security config is provided, creates a SecurityChecker and wires it
|
|
232
|
+
* into the entityAuthAdapter for isGranted() support.
|
|
233
|
+
*
|
|
234
|
+
* Security config:
|
|
235
|
+
* ```js
|
|
236
|
+
* security: {
|
|
237
|
+
* role_hierarchy: { ROLE_ADMIN: ['ROLE_USER'] },
|
|
238
|
+
* role_permissions: {
|
|
239
|
+
* ROLE_USER: ['entity:read', 'entity:list'],
|
|
240
|
+
* ROLE_ADMIN: ['entity:create', 'entity:update', 'entity:delete'],
|
|
241
|
+
* },
|
|
242
|
+
* entity_permissions: false // false | true | ['books', 'loans']
|
|
243
|
+
* }
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
_setupSecurity() {
|
|
247
|
+
const { security, entityAuthAdapter } = this.options
|
|
248
|
+
if (!security) return
|
|
249
|
+
|
|
250
|
+
// Create SecurityChecker with role hierarchy and permissions
|
|
251
|
+
this.securityChecker = createSecurityChecker({
|
|
252
|
+
role_hierarchy: security.role_hierarchy || {},
|
|
253
|
+
role_permissions: security.role_permissions || {},
|
|
254
|
+
getCurrentUser: () => entityAuthAdapter?.getCurrentUser?.() || null
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// Store entity_permissions config for EntityManager to use
|
|
258
|
+
this.securityChecker.entityPermissions = security.entity_permissions ?? false
|
|
259
|
+
|
|
260
|
+
// Wire SecurityChecker into entityAuthAdapter
|
|
261
|
+
if (entityAuthAdapter?.setSecurityChecker) {
|
|
262
|
+
entityAuthAdapter.setSecurityChecker(this.securityChecker)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
214
266
|
/**
|
|
215
267
|
* Create zone registry for extensible UI composition
|
|
216
268
|
* Registers standard zones during bootstrap.
|
|
@@ -386,4 +438,21 @@ export class Kernel {
|
|
|
386
438
|
get layouts() {
|
|
387
439
|
return this.layoutComponents
|
|
388
440
|
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Get the SecurityChecker instance
|
|
444
|
+
* @returns {import('../entity/auth/SecurityChecker.js').SecurityChecker|null}
|
|
445
|
+
*/
|
|
446
|
+
getSecurityChecker() {
|
|
447
|
+
return this.securityChecker
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Shorthand accessor for security checker
|
|
452
|
+
* Allows `kernel.security.isGranted(...)` syntax
|
|
453
|
+
* @returns {import('../entity/auth/SecurityChecker.js').SecurityChecker|null}
|
|
454
|
+
*/
|
|
455
|
+
get security() {
|
|
456
|
+
return this.securityChecker
|
|
457
|
+
}
|
|
389
458
|
}
|
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Module Registry - Auto-discovery and registration system
|
|
3
3
|
*
|
|
4
|
-
* Each module
|
|
5
|
-
* - Routes
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
4
|
+
* Each module provides an init.js that registers:
|
|
5
|
+
* - Routes & Navigation (via registry)
|
|
6
|
+
* - Zone blocks (via zones)
|
|
7
|
+
* - Signal handlers (via signals)
|
|
8
|
+
* - Hooks (via hooks)
|
|
8
9
|
*
|
|
9
10
|
* Usage in module (modules/agents/init.js):
|
|
10
11
|
*
|
|
11
|
-
* export function init(registry) {
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* ])
|
|
12
|
+
* export function init({ registry, zones, signals, hooks }) {
|
|
13
|
+
* // Routes & Navigation
|
|
14
|
+
* registry.addRoutes('agents', [...])
|
|
15
|
+
* registry.addNavItem({ section: 'Simulation', route: 'agents', ... })
|
|
16
|
+
* registry.addRouteFamily('agents', ['agent-'])
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* route: 'agents',
|
|
21
|
-
* icon: 'pi pi-user',
|
|
22
|
-
* label: 'Agents'
|
|
23
|
-
* })
|
|
18
|
+
* // Zone blocks
|
|
19
|
+
* zones.registerBlock('agents-list-header', { id: 'agents-header', component: Header })
|
|
24
20
|
*
|
|
25
|
-
*
|
|
21
|
+
* // Signal handlers
|
|
22
|
+
* signals.on('agents:created', handleCreated)
|
|
23
|
+
*
|
|
24
|
+
* // Hooks
|
|
25
|
+
* hooks.register('agents:presave', validateAgent)
|
|
26
26
|
* }
|
|
27
27
|
*/
|
|
28
28
|
|
|
@@ -134,24 +134,33 @@ export function setSectionOrder(order) {
|
|
|
134
134
|
*
|
|
135
135
|
* Usage in app:
|
|
136
136
|
* const moduleInits = import.meta.glob('./modules/* /init.js', { eager: true })
|
|
137
|
-
* initModules(moduleInits)
|
|
137
|
+
* initModules(moduleInits, { zones, signals, hooks })
|
|
138
138
|
*
|
|
139
139
|
* @param {object} moduleInits - Result of import.meta.glob
|
|
140
|
-
* @param {object} options - { coreNavItems
|
|
140
|
+
* @param {object} options - { coreNavItems, zones, signals, hooks }
|
|
141
|
+
* @param {Array} options.coreNavItems - Core nav items not in modules
|
|
142
|
+
* @param {ZoneRegistry} options.zones - Zone registry for block registration
|
|
143
|
+
* @param {SignalBus} options.signals - Signal bus for event handlers
|
|
144
|
+
* @param {HookRegistry} options.hooks - Hook registry for lifecycle hooks
|
|
141
145
|
*/
|
|
142
146
|
export function initModules(moduleInits, options = {}) {
|
|
147
|
+
const { coreNavItems, zones, signals, hooks } = options
|
|
148
|
+
|
|
143
149
|
// Add core nav items (pages that aren't in modules)
|
|
144
|
-
if (
|
|
145
|
-
for (const item of
|
|
150
|
+
if (coreNavItems) {
|
|
151
|
+
for (const item of coreNavItems) {
|
|
146
152
|
registry.addNavItem(item)
|
|
147
153
|
}
|
|
148
154
|
}
|
|
149
155
|
|
|
156
|
+
// Context passed to module init functions
|
|
157
|
+
const context = { registry, zones, signals, hooks }
|
|
158
|
+
|
|
150
159
|
// Initialize all discovered modules
|
|
151
160
|
for (const path in moduleInits) {
|
|
152
161
|
const module = moduleInits[path]
|
|
153
162
|
if (typeof module.init === 'function') {
|
|
154
|
-
module.init(
|
|
163
|
+
module.init(context)
|
|
155
164
|
}
|
|
156
165
|
}
|
|
157
166
|
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilterQuery - Resolves dropdown options for filters
|
|
3
|
+
*
|
|
4
|
+
* Abstracts option resolution from two sources:
|
|
5
|
+
* - 'entity': fetch options from a related EntityManager
|
|
6
|
+
* - 'field': extract distinct values from parent manager's cached items
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* ```js
|
|
10
|
+
* // Entity source - fetch from genres EntityManager
|
|
11
|
+
* const query = new FilterQuery({
|
|
12
|
+
* source: 'entity',
|
|
13
|
+
* entity: 'genres',
|
|
14
|
+
* label: 'name',
|
|
15
|
+
* value: 'id'
|
|
16
|
+
* })
|
|
17
|
+
*
|
|
18
|
+
* // Field source - extract unique status values
|
|
19
|
+
* const query = new FilterQuery({
|
|
20
|
+
* source: 'field',
|
|
21
|
+
* field: 'status'
|
|
22
|
+
* })
|
|
23
|
+
*
|
|
24
|
+
* // Get options (async)
|
|
25
|
+
* const options = await query.getOptions(orchestrator)
|
|
26
|
+
* // → [{ label: 'Rock', value: 1 }, { label: 'Jazz', value: 2 }]
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
import { getNestedValue } from './QueryExecutor.js'
|
|
30
|
+
|
|
31
|
+
const VALID_SOURCES = ['entity', 'field']
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve a value from an item using a path or function
|
|
35
|
+
*
|
|
36
|
+
* @param {object} item - The item to extract value from
|
|
37
|
+
* @param {string|Function} resolver - Path string ('name', 'author.country') or function (item => value)
|
|
38
|
+
* @returns {*} The resolved value
|
|
39
|
+
*/
|
|
40
|
+
function resolveValue(item, resolver) {
|
|
41
|
+
if (typeof resolver === 'function') {
|
|
42
|
+
return resolver(item)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Use shared getNestedValue for path resolution
|
|
46
|
+
return getNestedValue(resolver, item)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class FilterQuery {
|
|
50
|
+
/**
|
|
51
|
+
* @param {object} options
|
|
52
|
+
* @param {'entity'|'field'} options.source - Source type for options
|
|
53
|
+
* @param {string} [options.entity] - Entity name (required if source='entity')
|
|
54
|
+
* @param {string} [options.field] - Field name to extract values from (required if source='field')
|
|
55
|
+
* @param {object} [options.parentManager] - Parent EntityManager (for field source, set by builder)
|
|
56
|
+
* @param {string|Function} [options.label='name'] - Label resolver (path or function)
|
|
57
|
+
* @param {string|Function} [options.value='id'] - Value resolver (path or function)
|
|
58
|
+
* @param {Function} [options.transform] - Post-processing function for options
|
|
59
|
+
* @param {Function} [options.toQuery] - Transform filter value to query object
|
|
60
|
+
*/
|
|
61
|
+
constructor(options = {}) {
|
|
62
|
+
const {
|
|
63
|
+
source,
|
|
64
|
+
entity = null,
|
|
65
|
+
field = null,
|
|
66
|
+
parentManager = null,
|
|
67
|
+
label = 'name',
|
|
68
|
+
value = 'id',
|
|
69
|
+
transform = null,
|
|
70
|
+
toQuery = null
|
|
71
|
+
} = options
|
|
72
|
+
|
|
73
|
+
// Validate source type
|
|
74
|
+
if (!source || !VALID_SOURCES.includes(source)) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`FilterQuery: source must be one of: ${VALID_SOURCES.join(', ')}. Got: ${source}`
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Validate source-specific requirements
|
|
81
|
+
if (source === 'entity' && !entity) {
|
|
82
|
+
throw new Error('FilterQuery: entity is required when source is "entity"')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (source === 'field' && !field) {
|
|
86
|
+
throw new Error('FilterQuery: field is required when source is "field"')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.source = source
|
|
90
|
+
this.entity = entity
|
|
91
|
+
this.field = field
|
|
92
|
+
this.parentManager = parentManager
|
|
93
|
+
this.label = label
|
|
94
|
+
this.value = value
|
|
95
|
+
this.transform = transform
|
|
96
|
+
this.toQuery = toQuery
|
|
97
|
+
|
|
98
|
+
// Internal cache
|
|
99
|
+
this._options = null
|
|
100
|
+
this._signals = null
|
|
101
|
+
this._subscriptions = [] // Signal unsubscribe functions for cleanup
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Set the parent manager (called by useListPageBuilder for field source)
|
|
106
|
+
*
|
|
107
|
+
* @param {EntityManager} manager
|
|
108
|
+
* @returns {FilterQuery} this for chaining
|
|
109
|
+
*/
|
|
110
|
+
setParentManager(manager) {
|
|
111
|
+
this.parentManager = manager
|
|
112
|
+
return this
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Set the SignalBus for cache invalidation
|
|
117
|
+
*
|
|
118
|
+
* For entity sources, subscribes to CRUD signals ({entity}:created, {entity}:updated,
|
|
119
|
+
* {entity}:deleted) to automatically invalidate cached options when the source
|
|
120
|
+
* entity changes.
|
|
121
|
+
*
|
|
122
|
+
* @param {SignalBus} signals
|
|
123
|
+
* @returns {FilterQuery} this for chaining
|
|
124
|
+
*/
|
|
125
|
+
setSignals(signals) {
|
|
126
|
+
// Clean up existing subscriptions if any
|
|
127
|
+
this._cleanupSubscriptions()
|
|
128
|
+
|
|
129
|
+
this._signals = signals
|
|
130
|
+
|
|
131
|
+
// Subscribe to entity CRUD signals for cache invalidation
|
|
132
|
+
if (this.source === 'entity' && this.entity && signals) {
|
|
133
|
+
const actions = ['created', 'updated', 'deleted']
|
|
134
|
+
for (const action of actions) {
|
|
135
|
+
const signalName = `${this.entity}:${action}`
|
|
136
|
+
const unbind = signals.on(signalName, () => this.invalidate())
|
|
137
|
+
this._subscriptions.push(unbind)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return this
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Cleanup signal subscriptions
|
|
146
|
+
* @private
|
|
147
|
+
*/
|
|
148
|
+
_cleanupSubscriptions() {
|
|
149
|
+
for (const unbind of this._subscriptions) {
|
|
150
|
+
if (typeof unbind === 'function') {
|
|
151
|
+
unbind()
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
this._subscriptions = []
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Dispose the FilterQuery, cleaning up signal subscriptions
|
|
159
|
+
*
|
|
160
|
+
* Call this when the FilterQuery is no longer needed to prevent memory leaks.
|
|
161
|
+
*/
|
|
162
|
+
dispose() {
|
|
163
|
+
this._cleanupSubscriptions()
|
|
164
|
+
this._signals = null
|
|
165
|
+
this._options = null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Invalidate the cached options (forces refresh on next getOptions)
|
|
170
|
+
*/
|
|
171
|
+
invalidate() {
|
|
172
|
+
this._options = null
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Get options for dropdown
|
|
177
|
+
*
|
|
178
|
+
* @param {Orchestrator} [orchestrator] - Required for entity source
|
|
179
|
+
* @returns {Promise<Array<{label: string, value: *}>>}
|
|
180
|
+
*/
|
|
181
|
+
async getOptions(orchestrator = null) {
|
|
182
|
+
// Return cached options if available
|
|
183
|
+
if (this._options !== null) {
|
|
184
|
+
return this._options
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let items = []
|
|
188
|
+
|
|
189
|
+
if (this.source === 'entity') {
|
|
190
|
+
items = await this._fetchFromEntity(orchestrator)
|
|
191
|
+
} else if (this.source === 'field') {
|
|
192
|
+
items = this._extractFromField()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Map items to { label, value } format
|
|
196
|
+
let options = items.map(item => ({
|
|
197
|
+
label: resolveValue(item, this.label),
|
|
198
|
+
value: resolveValue(item, this.value)
|
|
199
|
+
}))
|
|
200
|
+
|
|
201
|
+
// Apply transform if provided
|
|
202
|
+
if (typeof this.transform === 'function') {
|
|
203
|
+
options = this.transform(options)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Cache the result
|
|
207
|
+
this._options = options
|
|
208
|
+
|
|
209
|
+
return options
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Fetch items from entity manager
|
|
214
|
+
*
|
|
215
|
+
* @param {Orchestrator} orchestrator
|
|
216
|
+
* @returns {Promise<Array>}
|
|
217
|
+
* @private
|
|
218
|
+
*/
|
|
219
|
+
async _fetchFromEntity(orchestrator) {
|
|
220
|
+
if (!orchestrator) {
|
|
221
|
+
throw new Error('FilterQuery: orchestrator is required for entity source')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const manager = orchestrator.get(this.entity)
|
|
225
|
+
if (!manager) {
|
|
226
|
+
throw new Error(`FilterQuery: entity "${this.entity}" not found in orchestrator`)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Fetch all items (large page_size to get everything)
|
|
230
|
+
const result = await manager.list({ page_size: 1000 })
|
|
231
|
+
return result.items || []
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Extract unique values from parent manager's cached items
|
|
236
|
+
*
|
|
237
|
+
* @returns {Array}
|
|
238
|
+
* @private
|
|
239
|
+
*/
|
|
240
|
+
_extractFromField() {
|
|
241
|
+
if (!this.parentManager) {
|
|
242
|
+
throw new Error('FilterQuery: parentManager is required for field source')
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Get cached items from parent manager
|
|
246
|
+
const cachedItems = this.parentManager._cache || []
|
|
247
|
+
|
|
248
|
+
if (cachedItems.length === 0) {
|
|
249
|
+
return []
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Extract unique values from the field
|
|
253
|
+
const seen = new Set()
|
|
254
|
+
const items = []
|
|
255
|
+
|
|
256
|
+
for (const item of cachedItems) {
|
|
257
|
+
const fieldValue = resolveValue(item, this.field)
|
|
258
|
+
if (fieldValue == null) continue
|
|
259
|
+
|
|
260
|
+
// Create a unique key for deduplication
|
|
261
|
+
const key = typeof fieldValue === 'object' ? JSON.stringify(fieldValue) : fieldValue
|
|
262
|
+
|
|
263
|
+
if (!seen.has(key)) {
|
|
264
|
+
seen.add(key)
|
|
265
|
+
// For field source, the item IS the value (create a simple object)
|
|
266
|
+
items.push({
|
|
267
|
+
[this.field]: fieldValue,
|
|
268
|
+
// Also set 'name' and 'id' defaults for simple resolution
|
|
269
|
+
name: fieldValue,
|
|
270
|
+
id: fieldValue
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return items
|
|
276
|
+
}
|
|
277
|
+
}
|