neo.mjs 10.0.0-beta.4 → 10.0.0-beta.5
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/.github/RELEASE_NOTES/v10.0.0-beta.4.md +2 -2
- package/ServiceWorker.mjs +2 -2
- package/apps/portal/index.html +1 -1
- package/apps/portal/view/home/FooterContainer.mjs +1 -1
- package/examples/button/effect/MainContainer.mjs +207 -0
- package/examples/button/effect/app.mjs +6 -0
- package/examples/button/effect/index.html +11 -0
- package/examples/button/effect/neo-config.json +6 -0
- package/learn/guides/datahandling/StateProviders.md +1 -0
- package/learn/guides/fundamentals/DeclarativeVDOMWithEffects.md +166 -0
- package/learn/tree.json +1 -0
- package/package.json +2 -2
- package/src/DefaultConfig.mjs +2 -2
- package/src/Neo.mjs +226 -78
- package/src/button/Effect.mjs +435 -0
- package/src/collection/Base.mjs +7 -2
- package/src/component/Base.mjs +67 -46
- package/src/container/Base.mjs +28 -24
- package/src/core/Base.mjs +138 -19
- package/src/core/Config.mjs +123 -32
- package/src/core/Effect.mjs +127 -0
- package/src/core/EffectBatchManager.mjs +68 -0
- package/src/core/EffectManager.mjs +38 -0
- package/src/grid/Container.mjs +8 -4
- package/src/grid/column/Component.mjs +1 -1
- package/src/state/Provider.mjs +343 -452
- package/src/state/createHierarchicalDataProxy.mjs +124 -0
- package/src/tab/header/EffectButton.mjs +75 -0
- package/src/vdom/Helper.mjs +9 -10
- package/src/vdom/VNode.mjs +1 -1
- package/src/worker/App.mjs +0 -5
- package/test/siesta/siesta.js +32 -0
- package/test/siesta/tests/CollectionBase.mjs +10 -10
- package/test/siesta/tests/VdomHelper.mjs +22 -59
- package/test/siesta/tests/config/AfterSetConfig.mjs +100 -0
- package/test/siesta/tests/{ReactiveConfigs.mjs → config/Basic.mjs} +58 -21
- package/test/siesta/tests/config/CircularDependencies.mjs +166 -0
- package/test/siesta/tests/config/CustomFunctions.mjs +69 -0
- package/test/siesta/tests/config/Hierarchy.mjs +94 -0
- package/test/siesta/tests/config/MemoryLeak.mjs +92 -0
- package/test/siesta/tests/config/MultiLevelHierarchy.mjs +85 -0
- package/test/siesta/tests/core/Effect.mjs +131 -0
- package/test/siesta/tests/core/EffectBatching.mjs +322 -0
- package/test/siesta/tests/neo/MixinStaticConfig.mjs +138 -0
- package/test/siesta/tests/state/Provider.mjs +537 -0
- package/test/siesta/tests/state/createHierarchicalDataProxy.mjs +217 -0
package/src/state/Provider.mjs
CHANGED
@@ -1,11 +1,12 @@
|
|
1
|
-
import Base
|
2
|
-
import ClassSystemUtil
|
3
|
-
import
|
4
|
-
import
|
1
|
+
import Base from '../core/Base.mjs';
|
2
|
+
import ClassSystemUtil from '../util/ClassSystem.mjs';
|
3
|
+
import Config from '../core/Config.mjs';
|
4
|
+
import Effect from '../core/Effect.mjs';
|
5
|
+
import Observable from '../core/Observable.mjs';
|
6
|
+
import {createHierarchicalDataProxy} from './createHierarchicalDataProxy.mjs';
|
7
|
+
import {isDescriptor} from '../core/ConfigSymbols.mjs';
|
5
8
|
|
6
|
-
const
|
7
|
-
twoWayBindingSymbol = Symbol.for('twoWayBinding'),
|
8
|
-
variableNameRegex = /^\w*/;
|
9
|
+
const twoWayBindingSymbol = Symbol.for('twoWayBinding');
|
9
10
|
|
10
11
|
/**
|
11
12
|
* An optional component state provider for adding bindings to configs
|
@@ -31,38 +32,52 @@ class Provider extends Base {
|
|
31
32
|
* @protected
|
32
33
|
*/
|
33
34
|
ntype: 'state-provider',
|
34
|
-
/**
|
35
|
-
* @member {Object|null} bindings_=null
|
36
|
-
* @protected
|
37
|
-
*/
|
38
|
-
bindings_: null,
|
39
35
|
/**
|
40
36
|
* @member {Neo.component.Base|null} component=null
|
41
37
|
* @protected
|
42
38
|
*/
|
43
39
|
component: null,
|
44
40
|
/**
|
41
|
+
/**
|
42
|
+
* The core data object managed by this StateProvider.
|
43
|
+
* This object holds the reactive state that can be accessed and modified
|
44
|
+
* by components and formulas within the provider's hierarchy.
|
45
|
+
* Changes to properties within this data object will trigger reactivity.
|
46
|
+
* When new data is assigned, it will be deeply merged with existing data.
|
45
47
|
* @member {Object|null} data_=null
|
48
|
+
* @example
|
49
|
+
* data: {
|
50
|
+
* user: {
|
51
|
+
* firstName: 'John',
|
52
|
+
* lastName : 'Doe'
|
53
|
+
* },
|
54
|
+
* settings: {
|
55
|
+
* theme: 'dark'
|
56
|
+
* }
|
57
|
+
* }
|
46
58
|
*/
|
47
|
-
data_:
|
59
|
+
data_: {
|
60
|
+
[isDescriptor]: true,
|
61
|
+
merge : 'deep',
|
62
|
+
value : {}
|
63
|
+
},
|
48
64
|
/**
|
65
|
+
* Defines computed properties based on other data properties within the StateProvider hierarchy.
|
66
|
+
* Each formula is a function that receives a `data` argument, which is a hierarchical proxy
|
67
|
+
* allowing access to data from the current provider and all its parent providers.
|
68
|
+
* Changes to dependencies (accessed via `data.propertyName`) will automatically re-run the formula.
|
49
69
|
* @member {Object|null} formulas_=null
|
50
|
-
*
|
51
70
|
* @example
|
52
71
|
* data: {
|
53
|
-
* a: 1,
|
54
|
-
* b: 2
|
72
|
+
* a : 1,
|
73
|
+
* b : 2,
|
74
|
+
* total: 50
|
55
75
|
* }
|
56
76
|
* formulas: {
|
57
|
-
* aPlusB:
|
58
|
-
*
|
59
|
-
*
|
60
|
-
*
|
61
|
-
* },
|
62
|
-
* get(data) {
|
63
|
-
* return data.foo + data.bar
|
64
|
-
* }
|
65
|
-
* }
|
77
|
+
* aPlusB : (data) => data.a + data.b,
|
78
|
+
* aTimesB: (data) => data.a * data.b,
|
79
|
+
* // Accessing parent data (assuming a parent provider has a 'taxRate' property)
|
80
|
+
* totalWithTax: (data) => data.total * (1 + data.taxRate)
|
66
81
|
* }
|
67
82
|
*/
|
68
83
|
formulas_: null,
|
@@ -71,60 +86,101 @@ class Provider extends Base {
|
|
71
86
|
*/
|
72
87
|
parent_: null,
|
73
88
|
/**
|
89
|
+
/**
|
90
|
+
* A collection of Neo.data.Store instances managed by this StateProvider.
|
91
|
+
* Stores are defined as config objects with a `module` property pointing
|
92
|
+
* to the store class, which will then be instantiated by the framework.
|
74
93
|
* @member {Object|null} stores_=null
|
94
|
+
* @example
|
95
|
+
* stores: {
|
96
|
+
* myUsers: {
|
97
|
+
* module: Neo.data.Store,
|
98
|
+
* model : 'MyApp.model.User',
|
99
|
+
* data : [{id: 1, name: 'John'}, {id: 2, name: 'Doe'}]
|
100
|
+
* },
|
101
|
+
* myCustomStore1: MyCustomStoreClass,
|
102
|
+
* myCustomStore2: {
|
103
|
+
* module : MyCustomStoreClass,
|
104
|
+
* autoLoad: true
|
105
|
+
* }
|
106
|
+
* }
|
75
107
|
*/
|
76
108
|
stores_: null
|
77
109
|
}
|
78
110
|
|
79
111
|
/**
|
80
|
-
* @
|
112
|
+
* @member {Map} #bindingEffects=new Map()
|
113
|
+
* @private
|
81
114
|
*/
|
82
|
-
|
83
|
-
Neo.currentWorker.isUsingStateProviders = true;
|
84
|
-
super.construct(config);
|
85
|
-
this.bindings = {}
|
86
|
-
}
|
87
|
-
|
115
|
+
#bindingEffects = new Map()
|
88
116
|
/**
|
89
|
-
*
|
90
|
-
* The method is used by setData() & setDataAtSameLevel()
|
91
|
-
* in case the data property does not exist yet.
|
92
|
-
* @param {String} key
|
93
|
-
* @param {*} value
|
117
|
+
* @member {Object} #dataConfigs={}
|
94
118
|
* @private
|
95
119
|
*/
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
data = me.getDataScope(key);
|
103
|
-
scope = data.scope;
|
104
|
-
|
105
|
-
scope[data.key] = value;
|
120
|
+
#dataConfigs = {}
|
121
|
+
/**
|
122
|
+
* @member {Map} #formulaEffects=new Map()
|
123
|
+
* @private
|
124
|
+
*/
|
125
|
+
#formulaEffects = new Map()
|
106
126
|
|
107
|
-
|
127
|
+
/**
|
128
|
+
* @param {Object} config
|
129
|
+
*/
|
130
|
+
construct(config) {
|
131
|
+
Neo.isUsingStateProviders = true;
|
132
|
+
super.construct(config)
|
108
133
|
}
|
109
134
|
|
110
135
|
/**
|
111
|
-
* Triggered after the data config got changed
|
136
|
+
* Triggered after the data config got changed.
|
137
|
+
* This method initializes the internal #dataConfigs map, converting each
|
138
|
+
* plain data property into a reactive Neo.core.Config instance.
|
112
139
|
* @param {Object|null} value
|
113
140
|
* @param {Object|null} oldValue
|
114
141
|
* @protected
|
115
142
|
*/
|
116
143
|
afterSetData(value, oldValue) {
|
117
|
-
value && this.
|
144
|
+
value && this.processDataObject(value)
|
118
145
|
}
|
119
146
|
|
120
147
|
/**
|
121
|
-
* Triggered after the formulas config got changed
|
122
|
-
*
|
123
|
-
*
|
148
|
+
* Triggered after the formulas config got changed.
|
149
|
+
* This method sets up reactive effects for each defined formula.
|
150
|
+
* Each formula function receives the hierarchical data proxy, allowing implicit dependency tracking.
|
151
|
+
* @param {Object|null} value The new formulas configuration.
|
152
|
+
* @param {Object|null} oldValue The old formulas configuration.
|
124
153
|
* @protected
|
125
154
|
*/
|
126
155
|
afterSetFormulas(value, oldValue) {
|
127
|
-
|
156
|
+
const me = this;
|
157
|
+
|
158
|
+
// Destroy old formula effects to prevent memory leaks and stale calculations.
|
159
|
+
me.#formulaEffects.forEach(effect => effect.destroy());
|
160
|
+
me.#formulaEffects.clear();
|
161
|
+
|
162
|
+
if (value) {
|
163
|
+
Object.entries(value).forEach(([formulaKey, formulaFn]) => {
|
164
|
+
// Create a new Effect for each formula. The Effect's fn will re-run whenever its dependencies change.
|
165
|
+
const effect = new Effect({
|
166
|
+
fn: () => {
|
167
|
+
const
|
168
|
+
hierarchicalData = me.getHierarchyData(), // Get the reactive data proxy
|
169
|
+
result = formulaFn(hierarchicalData); // Execute the formula with the data
|
170
|
+
|
171
|
+
// Assign the result back to the state provider's data.
|
172
|
+
// This makes the formula's output available as a data property.
|
173
|
+
if (isNaN(result)) {
|
174
|
+
me.setData(formulaKey, null)
|
175
|
+
} else {
|
176
|
+
me.setData(formulaKey, result)
|
177
|
+
}
|
178
|
+
}
|
179
|
+
});
|
180
|
+
|
181
|
+
me.#formulaEffects.set(formulaKey, effect)
|
182
|
+
})
|
183
|
+
}
|
128
184
|
}
|
129
185
|
|
130
186
|
/**
|
@@ -133,17 +189,7 @@ class Provider extends Base {
|
|
133
189
|
* @protected
|
134
190
|
*/
|
135
191
|
beforeGetData(value) {
|
136
|
-
return
|
137
|
-
}
|
138
|
-
|
139
|
-
/**
|
140
|
-
* Triggered before the parent config gets changed
|
141
|
-
* @param {Neo.state.Provider|null} value
|
142
|
-
* @param {Neo.state.Provider|null} oldValue
|
143
|
-
* @protected
|
144
|
-
*/
|
145
|
-
beforeSetParent(value, oldValue) {
|
146
|
-
return value ? value : this.getParent()
|
192
|
+
return this.getHierarchyData()
|
147
193
|
}
|
148
194
|
|
149
195
|
/**
|
@@ -171,159 +217,93 @@ class Provider extends Base {
|
|
171
217
|
}
|
172
218
|
|
173
219
|
/**
|
174
|
-
*
|
175
|
-
*
|
176
|
-
* @returns {String}
|
177
|
-
*/
|
178
|
-
callFormatter(formatter, data=null) {
|
179
|
-
if (!data) {
|
180
|
-
data = this.getHierarchyData()
|
181
|
-
}
|
182
|
-
|
183
|
-
return formatter.call(this, data)
|
184
|
-
}
|
185
|
-
|
186
|
-
/**
|
187
|
-
* Registers a new binding in case a matching data property does exist.
|
188
|
-
* Otherwise, it will use the closest stateProvider with a match.
|
220
|
+
* Creates a new binding for a component's config to a data property.
|
221
|
+
* This now uses the Effect-based reactivity system.
|
189
222
|
* @param {String} componentId
|
190
|
-
* @param {String}
|
191
|
-
* @param {String} value
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
} else {
|
210
|
-
console.error('No state.Provider found with the specified data property', componentId, keyLeaf, value)
|
223
|
+
* @param {String} configKey The component config to bind (e.g., 'text').
|
224
|
+
* @param {String|Function} formatter The function that computes the value.
|
225
|
+
*/
|
226
|
+
createBinding(componentId, configKey, key, isTwoWay) {
|
227
|
+
const
|
228
|
+
me = this,
|
229
|
+
effect = new Effect({
|
230
|
+
fn: () => {
|
231
|
+
const component = Neo.get(componentId);
|
232
|
+
|
233
|
+
if (component && !component.isDestroyed) {
|
234
|
+
const
|
235
|
+
hierarchicalData = me.getHierarchyData(),
|
236
|
+
newValue = Neo.isFunction(key) ? key.call(me, hierarchicalData) : hierarchicalData[key];
|
237
|
+
|
238
|
+
component._skipTwoWayPush = configKey;
|
239
|
+
component[configKey] = newValue;
|
240
|
+
delete component._skipTwoWayPush
|
241
|
+
}
|
211
242
|
}
|
212
|
-
}
|
213
|
-
}
|
243
|
+
});
|
214
244
|
|
215
|
-
|
216
|
-
* Registers a new binding in case a matching data property does exist.
|
217
|
-
* Otherwise, it will use the closest stateProvider with a match.
|
218
|
-
* @param {String} componentId
|
219
|
-
* @param {String} formatter
|
220
|
-
* @param {String} value
|
221
|
-
* @returns {String[]}
|
222
|
-
*/
|
223
|
-
createBindingByFormatter(componentId, formatter, value) {
|
224
|
-
let me = this,
|
225
|
-
formatterVars = me.getFormatterVariables(formatter);
|
245
|
+
me.#bindingEffects.set(componentId, effect);
|
226
246
|
|
227
|
-
|
228
|
-
|
247
|
+
// The effect observes the component's destruction to clean itself up.
|
248
|
+
me.observeConfig(componentId, 'isDestroying', (value) => {
|
249
|
+
if (value) {
|
250
|
+
effect.destroy();
|
251
|
+
me.#bindingEffects.delete(componentId)
|
252
|
+
}
|
229
253
|
});
|
230
254
|
|
231
|
-
|
255
|
+
// The effect is returned to be managed by the component.
|
256
|
+
return effect
|
232
257
|
}
|
233
258
|
|
234
259
|
/**
|
235
|
-
*
|
260
|
+
* Processes a component's `bind` configuration to create reactive bindings.
|
261
|
+
* It differentiates between store bindings and data bindings, and sets up two-way binding if specified.
|
262
|
+
* @param {Neo.component.Base} component The component instance whose bindings are to be created.
|
236
263
|
*/
|
237
264
|
createBindings(component) {
|
238
|
-
|
239
|
-
let twoWayBinding = false,
|
240
|
-
formatterVars;
|
241
|
-
|
242
|
-
if (Neo.isObject(value)) {
|
243
|
-
twoWayBinding = true;
|
244
|
-
value = value.value
|
245
|
-
}
|
265
|
+
let hasTwoWayBinding = false;
|
246
266
|
|
247
|
-
|
248
|
-
|
267
|
+
Object.entries(component.bind || {}).forEach(([configKey, value]) => {
|
268
|
+
let key = value;
|
249
269
|
|
250
|
-
|
251
|
-
|
252
|
-
|
270
|
+
// If the binding value is an object, it might contain `twoWay` or a specific `key`.
|
271
|
+
if (Neo.isObject(value)) {
|
272
|
+
if (value.twoWay) {
|
273
|
+
hasTwoWayBinding = true
|
253
274
|
}
|
275
|
+
key = value.key
|
254
276
|
}
|
255
|
-
})
|
256
|
-
}
|
257
|
-
|
258
|
-
/**
|
259
|
-
* @param {Object} config
|
260
|
-
* @param {String} path
|
261
|
-
*/
|
262
|
-
createDataProperties(config, path) {
|
263
|
-
let me = this,
|
264
|
-
root = Neo.ns(path, false, me),
|
265
|
-
descriptor, keyValue, newPath;
|
266
|
-
|
267
|
-
Object.entries(config).forEach(([key, value]) => {
|
268
|
-
if (!key.startsWith('_')) {
|
269
|
-
descriptor = Object.getOwnPropertyDescriptor(root, key);
|
270
|
-
newPath = `${path}.${key}`
|
271
|
-
|
272
|
-
if (!(typeof descriptor === 'object' && typeof descriptor.set === 'function')) {
|
273
|
-
keyValue = config[key];
|
274
|
-
me.createDataProperty(key, newPath, root);
|
275
|
-
root[key] = keyValue
|
276
|
-
}
|
277
277
|
|
278
|
-
|
279
|
-
|
280
|
-
|
278
|
+
// Determine if it's a store binding or a data binding.
|
279
|
+
if (this.isStoreValue(key)) {
|
280
|
+
// For store bindings, resolve the store and assign it to the component config.
|
281
|
+
this.resolveStore(component, configKey, key.substring(7)) // remove the "stores." prefix
|
282
|
+
} else {
|
283
|
+
// For data bindings, create an Effect to keep the component config in sync with the data.
|
284
|
+
this.createBinding(component.id, configKey, key, value.twoWay)
|
281
285
|
}
|
282
|
-
})
|
286
|
+
});
|
287
|
+
|
288
|
+
// Mark the component if it has any two-way bindings, for internal tracking.
|
289
|
+
if (hasTwoWayBinding) {
|
290
|
+
component[twoWayBindingSymbol] = true
|
291
|
+
}
|
283
292
|
}
|
284
293
|
|
285
294
|
/**
|
286
|
-
*
|
287
|
-
* @param {String} path
|
288
|
-
* @param {Object} root=this.data
|
295
|
+
* Destroys the state provider and cleans up all associated effects.
|
289
296
|
*/
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
if (path?.startsWith('data.')) {
|
294
|
-
path = path.substring(5)
|
295
|
-
}
|
297
|
+
destroy() {
|
298
|
+
const me = this;
|
296
299
|
|
297
|
-
|
298
|
-
|
299
|
-
let value = root['_' + key];
|
300
|
+
me.#formulaEffects.forEach(effect => effect.destroy());
|
301
|
+
me.#formulaEffects.clear();
|
300
302
|
|
301
|
-
|
302
|
-
|
303
|
-
}
|
303
|
+
me.#bindingEffects.forEach(effect => effect.destroy());
|
304
|
+
me.#bindingEffects.clear();
|
304
305
|
|
305
|
-
|
306
|
-
},
|
307
|
-
|
308
|
-
set(value) {
|
309
|
-
let _key = `_${key}`,
|
310
|
-
oldValue = root[_key];
|
311
|
-
|
312
|
-
if (!root[_key]) {
|
313
|
-
Object.defineProperty(root, _key, {
|
314
|
-
enumerable: false,
|
315
|
-
value,
|
316
|
-
writable : true
|
317
|
-
})
|
318
|
-
} else {
|
319
|
-
root[_key] = value
|
320
|
-
}
|
321
|
-
|
322
|
-
if (!Neo.isEqual(value, oldValue)) {
|
323
|
-
me.onDataPropertyChange(path ? path : key, value, oldValue)
|
324
|
-
}
|
325
|
-
}
|
326
|
-
})
|
306
|
+
super.destroy()
|
327
307
|
}
|
328
308
|
|
329
309
|
/**
|
@@ -338,133 +318,52 @@ class Provider extends Base {
|
|
338
318
|
/**
|
339
319
|
* Access the closest data property inside the parent chain.
|
340
320
|
* @param {String} key
|
341
|
-
* @param {Neo.state.Provider} originStateProvider=this for internal usage only
|
342
321
|
* @returns {*} value
|
343
322
|
*/
|
344
|
-
getData(key
|
345
|
-
|
346
|
-
data = me.getDataScope(key),
|
347
|
-
{scope} = data,
|
348
|
-
keyLeaf = data.key,
|
349
|
-
parentStateProvider;
|
323
|
+
getData(key) {
|
324
|
+
const ownerDetails = this.getOwnerOfDataProperty(key);
|
350
325
|
|
351
|
-
if (
|
352
|
-
return
|
326
|
+
if (ownerDetails) {
|
327
|
+
return ownerDetails.owner.getDataConfig(ownerDetails.propertyName).get()
|
353
328
|
}
|
354
|
-
|
355
|
-
parentStateProvider = me.getParent();
|
356
|
-
|
357
|
-
if (!parentStateProvider) {
|
358
|
-
console.error(`data property '${key}' does not exist.`, originStateProvider)
|
359
|
-
}
|
360
|
-
|
361
|
-
return parentStateProvider.getData(key, originStateProvider)
|
362
329
|
}
|
363
330
|
|
364
331
|
/**
|
365
|
-
*
|
366
|
-
*
|
367
|
-
*
|
368
|
-
* and 'baz' as the key.
|
369
|
-
* @param key
|
370
|
-
* @returns {Object}
|
332
|
+
* Retrieves the underlying core.Config instance for a given data property path.
|
333
|
+
* @param {String} path The full path of the data property (e.g., 'user.firstname').
|
334
|
+
* @returns {Neo.core.Config|null}
|
371
335
|
*/
|
372
|
-
|
373
|
-
|
374
|
-
keyLeaf = key,
|
375
|
-
{data} = me;
|
376
|
-
|
377
|
-
if (key.includes('.')) {
|
378
|
-
key = key.split('.');
|
379
|
-
keyLeaf = key.pop();
|
380
|
-
data = Neo.ns(key.join('.'), false, data)
|
381
|
-
}
|
382
|
-
|
383
|
-
return {
|
384
|
-
key : keyLeaf,
|
385
|
-
scope: data
|
386
|
-
}
|
336
|
+
getDataConfig(path) {
|
337
|
+
return this.#dataConfigs[path] || null
|
387
338
|
}
|
388
339
|
|
389
340
|
/**
|
390
|
-
*
|
391
|
-
* @
|
341
|
+
* Returns the merged, hierarchical data object as a reactive Proxy.
|
342
|
+
* @returns {Proxy}
|
392
343
|
*/
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
if (Neo.isFunction(value)) {
|
397
|
-
value = value.toString()
|
398
|
-
}
|
399
|
-
|
400
|
-
if (environment === 'dist/esm' || environment === 'dist/production') {
|
401
|
-
// See: https://github.com/neomjs/neo/issues/2371
|
402
|
-
// Inside dist/esm & dist/prod the formatter:
|
403
|
-
// data => DateUtil.convertToyyyymmdd(data.currentDate)
|
404
|
-
// will get minified to:
|
405
|
-
// e=>s.Z.convertToyyyymmdd(e.currentDate)
|
406
|
-
// The new strategy: find the first variable name => "e"
|
407
|
-
// Replace it with "data":
|
408
|
-
// data=>s.Z.convertToyyyymmdd(data.currentDate)
|
409
|
-
// From there we can use the dev mode regex again.
|
410
|
-
|
411
|
-
let dataName = value.match(variableNameRegex)[0],
|
412
|
-
variableRegExp = new RegExp(`(^|[^\\w.])(${dataName})(?!\\w)`, 'g');
|
413
|
-
|
414
|
-
value = value.replace(variableRegExp, '$1data')
|
415
|
-
}
|
416
|
-
|
417
|
-
let dataVars = value.match(dataVariableRegex) || [],
|
418
|
-
result = [];
|
419
|
-
|
420
|
-
dataVars.forEach(variable => {
|
421
|
-
// remove the "data." at the start
|
422
|
-
variable = variable.substr(5);
|
423
|
-
NeoArray.add(result, variable)
|
424
|
-
});
|
425
|
-
|
426
|
-
result.sort();
|
427
|
-
|
428
|
-
return result
|
344
|
+
getHierarchyData() {
|
345
|
+
return createHierarchicalDataProxy(this)
|
429
346
|
}
|
430
347
|
|
431
348
|
/**
|
432
|
-
*
|
433
|
-
* @param {
|
434
|
-
* @returns {
|
349
|
+
* Finds the state.Provider instance that owns a specific data property.
|
350
|
+
* @param {String} path The full path of the data property.
|
351
|
+
* @returns {{owner: Neo.state.Provider, propertyName: String}|null}
|
435
352
|
*/
|
436
|
-
|
437
|
-
let me
|
438
|
-
parent = me.getParent();
|
353
|
+
getOwnerOfDataProperty(path) {
|
354
|
+
let me = this;
|
439
355
|
|
440
|
-
if (
|
441
|
-
return {
|
442
|
-
...parent.getHierarchyData(data),
|
443
|
-
...me.getPlainData()
|
444
|
-
}
|
356
|
+
if (me.#dataConfigs[path]) {
|
357
|
+
return {owner: me, propertyName: path}
|
445
358
|
}
|
446
359
|
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
* This excludes the property getters & setters.
|
453
|
-
* @param {Object} data=this.data
|
454
|
-
* @returns {Object}
|
455
|
-
*/
|
456
|
-
getPlainData(data=this.data) {
|
457
|
-
let plainData = {};
|
458
|
-
|
459
|
-
Object.entries(data).forEach(([key, value]) => {
|
460
|
-
if (Neo.typeOf(value) === 'Object') {
|
461
|
-
plainData[key] = this.getPlainData(value)
|
462
|
-
} else {
|
463
|
-
plainData[key] = value
|
464
|
-
}
|
465
|
-
});
|
360
|
+
// Check for parent ownership
|
361
|
+
const parent = me.getParent();
|
362
|
+
if (parent) {
|
363
|
+
return parent.getOwnerOfDataProperty(path)
|
364
|
+
}
|
466
365
|
|
467
|
-
return
|
366
|
+
return null
|
468
367
|
}
|
469
368
|
|
470
369
|
/**
|
@@ -472,13 +371,22 @@ class Provider extends Base {
|
|
472
371
|
* @returns {Neo.state.Provider|null}
|
473
372
|
*/
|
474
373
|
getParent() {
|
475
|
-
let
|
374
|
+
let me = this;
|
476
375
|
|
477
|
-
|
478
|
-
|
376
|
+
// Access the internal value of the parent_ config directly.
|
377
|
+
// This avoids recursive calls to the getter.
|
378
|
+
if (me._parent) {
|
379
|
+
return me._parent
|
380
|
+
}
|
381
|
+
|
382
|
+
// If no explicit parent is set, try to find it dynamically via the component.
|
383
|
+
// Ensure this.component exists before trying to access its parent.
|
384
|
+
if (me.component) {
|
385
|
+
return me.component.parent?.getStateProvider() || null
|
479
386
|
}
|
480
387
|
|
481
|
-
|
388
|
+
// No explicit parent and no component to derive it from.
|
389
|
+
return null
|
482
390
|
}
|
483
391
|
|
484
392
|
/**
|
@@ -505,10 +413,50 @@ class Provider extends Base {
|
|
505
413
|
return parentStateProvider.getStore(key, originStateProvider)
|
506
414
|
}
|
507
415
|
|
416
|
+
/**
|
417
|
+
* Checks if any data property in the hierarchy starts with the given path.
|
418
|
+
* This is used by the HierarchicalDataProxy to determine if it should return a nested proxy.
|
419
|
+
* @param {String} path The path to check (e.g., 'user').
|
420
|
+
* @returns {Boolean}
|
421
|
+
*/
|
422
|
+
hasNestedDataStartingWith(path) {
|
423
|
+
const pathWithDot = `${path}.`;
|
424
|
+
|
425
|
+
if (Object.keys(this.#dataConfigs).some(key => key.startsWith(pathWithDot))) {
|
426
|
+
return true
|
427
|
+
}
|
428
|
+
|
429
|
+
return this.getParent()?.hasNestedDataStartingWith(path) || false
|
430
|
+
}
|
431
|
+
|
432
|
+
/**
|
433
|
+
* Returns the top-level data keys for a given path within this provider's data.
|
434
|
+
* @param {String} path The path to get keys for (e.g., 'user.address').
|
435
|
+
* @returns {String[]}
|
436
|
+
*/
|
437
|
+
getTopLevelDataKeys(path) {
|
438
|
+
const keys = new Set();
|
439
|
+
const pathPrefix = path ? `${path}.` : '';
|
440
|
+
|
441
|
+
for (const fullPath in this.#dataConfigs) {
|
442
|
+
if (fullPath.startsWith(pathPrefix)) {
|
443
|
+
const relativePath = fullPath.substring(pathPrefix.length);
|
444
|
+
const topLevelKey = relativePath.split('.')[0];
|
445
|
+
if (topLevelKey) {
|
446
|
+
keys.add(topLevelKey);
|
447
|
+
}
|
448
|
+
}
|
449
|
+
}
|
450
|
+
return Array.from(keys);
|
451
|
+
}
|
452
|
+
|
508
453
|
/**
|
509
454
|
* Internal method to avoid code redundancy.
|
510
455
|
* Use setData() or setDataAtSameLevel() instead.
|
511
456
|
*
|
457
|
+
* This method handles setting data properties, including nested paths and Neo.data.Record instances.
|
458
|
+
* It determines the owning StateProvider in the hierarchy and delegates to #setConfigValue.
|
459
|
+
*
|
512
460
|
* Passing an originStateProvider param will try to set each key on the closest property match
|
513
461
|
* inside the parent stateProvider chain => setData()
|
514
462
|
* Not passing it will set all values on the stateProvider where the method gets called => setDataAtSameLevel()
|
@@ -518,35 +466,54 @@ class Provider extends Base {
|
|
518
466
|
* @protected
|
519
467
|
*/
|
520
468
|
internalSetData(key, value, originStateProvider) {
|
521
|
-
|
522
|
-
data, keyLeaf, parentStateProvider, scope;
|
469
|
+
const me = this;
|
523
470
|
|
524
|
-
|
525
|
-
|
526
|
-
|
527
|
-
|
528
|
-
|
471
|
+
// If the value is a Neo.data.Record, treat it as an atomic value
|
472
|
+
// and set it directly without further recursive processing of its properties.
|
473
|
+
if (Neo.isObject(value) && value.isRecord) {
|
474
|
+
const
|
475
|
+
ownerDetails = me.getOwnerOfDataProperty(key),
|
476
|
+
targetProvider = ownerDetails ? ownerDetails.owner : (originStateProvider || me);
|
477
|
+
|
478
|
+
me.#setConfigValue(targetProvider, key, value, null);
|
479
|
+
return
|
480
|
+
}
|
481
|
+
|
482
|
+
// If the key is an object, iterate over its entries and recursively call internalSetData.
|
483
|
+
// This handles setting multiple properties at once (e.g., setData({prop1: val1, prop2: val2})).
|
484
|
+
if (Neo.isObject(key)) {
|
529
485
|
Object.entries(key).forEach(([dataKey, dataValue]) => {
|
530
486
|
me.internalSetData(dataKey, dataValue, originStateProvider)
|
531
|
-
})
|
532
|
-
|
533
|
-
|
534
|
-
keyLeaf = data.key;
|
535
|
-
scope = data.scope;
|
536
|
-
|
537
|
-
if (scope?.hasOwnProperty(keyLeaf)) {
|
538
|
-
scope[keyLeaf] = value
|
539
|
-
} else {
|
540
|
-
if (originStateProvider) {
|
541
|
-
parentStateProvider = me.getParent();
|
487
|
+
});
|
488
|
+
return
|
489
|
+
}
|
542
490
|
|
543
|
-
|
544
|
-
|
545
|
-
|
546
|
-
|
547
|
-
|
548
|
-
|
549
|
-
|
491
|
+
// Handle single key/value pairs, including nested paths (e.g., 'user.firstName').
|
492
|
+
const
|
493
|
+
ownerDetails = me.getOwnerOfDataProperty(key),
|
494
|
+
targetProvider = ownerDetails ? ownerDetails.owner : (originStateProvider || me),
|
495
|
+
pathParts = key.split('.');
|
496
|
+
|
497
|
+
let currentPath = '',
|
498
|
+
currentConfig = null,
|
499
|
+
currentProvider = targetProvider;
|
500
|
+
|
501
|
+
for (let i = 0; i < pathParts.length; i++) {
|
502
|
+
const part = pathParts[i];
|
503
|
+
currentPath = currentPath ? `${currentPath}.${part}` : part;
|
504
|
+
currentConfig = currentProvider.getDataConfig(currentPath);
|
505
|
+
|
506
|
+
if (i === pathParts.length - 1) { // Last part of the path
|
507
|
+
// Set the value for the final property in the path.
|
508
|
+
me.#setConfigValue(currentProvider, currentPath, value, null)
|
509
|
+
} else { // Intermediate part of the path
|
510
|
+
// Ensure intermediate paths exist as objects. If not, create them.
|
511
|
+
// If an intermediate path exists but is not an object, overwrite it with an empty object.
|
512
|
+
if (!currentConfig) {
|
513
|
+
currentConfig = new Config({}); // Create an empty object config
|
514
|
+
currentProvider.#dataConfigs[currentPath] = currentConfig
|
515
|
+
} else if (!Neo.isObject(currentConfig.get())) {
|
516
|
+
currentConfig.set({})
|
550
517
|
}
|
551
518
|
}
|
552
519
|
}
|
@@ -561,144 +528,40 @@ class Provider extends Base {
|
|
561
528
|
return Neo.isString(value) && value.startsWith('stores.')
|
562
529
|
}
|
563
530
|
|
564
|
-
/**
|
565
|
-
* Override this method to change the order configs are applied to this instance.
|
566
|
-
* @param {Object} config
|
567
|
-
* @param {Boolean} [preventOriginalConfig] True prevents the instance from getting an originalConfig property
|
568
|
-
* @returns {Object} config
|
569
|
-
*/
|
570
|
-
mergeConfig(config, preventOriginalConfig) {
|
571
|
-
if (config.data) {
|
572
|
-
config.data = Neo.merge(Neo.clone(this.constructor.config.data, true) || {}, config.data)
|
573
|
-
}
|
574
|
-
|
575
|
-
return super.mergeConfig(config, preventOriginalConfig)
|
576
|
-
}
|
577
|
-
|
578
531
|
/**
|
579
532
|
* @param {String} key
|
580
533
|
* @param {*} value
|
581
534
|
* @param {*} oldValue
|
582
535
|
*/
|
583
536
|
onDataPropertyChange(key, value, oldValue) {
|
584
|
-
|
585
|
-
binding = me.bindings && Neo.ns(key, false, me.bindings),
|
586
|
-
component, config, hierarchyData, stateProvider;
|
587
|
-
|
588
|
-
if (binding) {
|
589
|
-
hierarchyData = {};
|
590
|
-
|
591
|
-
Object.entries(binding).forEach(([componentId, configObject]) => {
|
592
|
-
component = Neo.getComponent(componentId) || Neo.get(componentId); // timing issue: the cmp might not be registered inside manager.Component yet
|
593
|
-
config = {};
|
594
|
-
stateProvider = component.getStateProvider() || me;
|
595
|
-
|
596
|
-
if (!hierarchyData[stateProvider.id]) {
|
597
|
-
hierarchyData[stateProvider.id] = stateProvider.getHierarchyData()
|
598
|
-
}
|
599
|
-
|
600
|
-
Object.entries(configObject).forEach(([configField, formatter]) => {
|
601
|
-
// we can not call me.callFormatter(), since a data property inside a parent stateProvider
|
602
|
-
// could have changed which is relying on data properties inside a closer stateProvider
|
603
|
-
config[configField] = stateProvider.callFormatter(formatter, hierarchyData[stateProvider.id])
|
604
|
-
});
|
605
|
-
|
606
|
-
component?.set(config)
|
607
|
-
})
|
608
|
-
}
|
609
|
-
|
610
|
-
me.formulas && me.resolveFormulas({key, id: me.id, oldValue, value});
|
611
|
-
|
612
|
-
me.fire('dataPropertyChange', {key, id: me.id, oldValue, value})
|
537
|
+
// Can be overridden by subclasses
|
613
538
|
}
|
614
539
|
|
615
540
|
/**
|
616
|
-
*
|
617
|
-
*
|
618
|
-
* @param {
|
619
|
-
|
620
|
-
|
621
|
-
let me = this,
|
622
|
-
config = {};
|
623
|
-
|
624
|
-
if (component.bind) {
|
625
|
-
me.createBindings(component);
|
626
|
-
|
627
|
-
Object.entries(component.bind).forEach(([key, value]) => {
|
628
|
-
if (Neo.isObject(value)) {
|
629
|
-
value.key = me.getFormatterVariables(value.value)[0];
|
630
|
-
value = value.value
|
631
|
-
}
|
632
|
-
|
633
|
-
if (me.isStoreValue(value)) {
|
634
|
-
me.resolveStore(component, key, value.substring(7)) // remove the "stores." at the start
|
635
|
-
} else {
|
636
|
-
config[key] = me.callFormatter(value)
|
637
|
-
}
|
638
|
-
});
|
639
|
-
|
640
|
-
component.set(config)
|
641
|
-
}
|
642
|
-
}
|
643
|
-
|
644
|
-
/**
|
645
|
-
* Removes all bindings for a given component id inside this stateProvider as well as inside all parent stateProviders.
|
646
|
-
* @param {String} componentId
|
541
|
+
* Recursively processes a data object, creating or updating Neo.core.Config instances
|
542
|
+
* for each property and storing them in the #dataConfigs map.
|
543
|
+
* @param {Object} obj The data object to process.
|
544
|
+
* @param {String} [path=''] The current path prefix for nested objects.
|
545
|
+
* @protected
|
647
546
|
*/
|
648
|
-
|
547
|
+
processDataObject(obj, path = '') {
|
649
548
|
let me = this;
|
650
549
|
|
651
|
-
Object.entries(
|
652
|
-
|
653
|
-
});
|
654
|
-
|
655
|
-
me.getParent()?.removeBindings(componentId)
|
656
|
-
}
|
550
|
+
Object.entries(obj).forEach(([key, value]) => {
|
551
|
+
const fullPath = path ? `${path}.${key}` : key;
|
657
552
|
|
658
|
-
|
659
|
-
|
660
|
-
|
661
|
-
|
662
|
-
|
663
|
-
let me = this,
|
664
|
-
{formulas} = me,
|
665
|
-
initialRun = !data,
|
666
|
-
affectFormula, bindObject, fn, key, result, value;
|
667
|
-
|
668
|
-
if (formulas) {
|
669
|
-
if (!initialRun && (!data.key || !data.value)) {
|
670
|
-
console.warn('[StateProvider:formulas] missing key or value', data.key, data.value)
|
553
|
+
// Ensure a Config instance exists for the current fullPath
|
554
|
+
if (me.#dataConfigs[fullPath]) {
|
555
|
+
me.#dataConfigs[fullPath].set(value);
|
556
|
+
} else {
|
557
|
+
me.#dataConfigs[fullPath] = new Config(value);
|
671
558
|
}
|
672
559
|
|
673
|
-
|
674
|
-
|
675
|
-
|
676
|
-
// Check if the change affects a formula
|
677
|
-
if (!initialRun) {
|
678
|
-
affectFormula = Object.values(value.bind).includes(data.key)
|
679
|
-
}
|
680
|
-
|
681
|
-
if (affectFormula) {
|
682
|
-
// Create Bind-Object and fill with new values
|
683
|
-
bindObject = Neo.clone(value.bind);
|
684
|
-
fn = value.get;
|
685
|
-
|
686
|
-
Object.keys(bindObject).forEach((key, index) => {
|
687
|
-
bindObject[key] = me.getData(bindObject[key])
|
688
|
-
});
|
689
|
-
|
690
|
-
// Calc the formula
|
691
|
-
result = fn(bindObject);
|
692
|
-
|
693
|
-
// Assign if no error or null
|
694
|
-
if (isNaN(result)) {
|
695
|
-
me.setData(key, null)
|
696
|
-
} else {
|
697
|
-
me.setData(key, result)
|
698
|
-
}
|
699
|
-
}
|
560
|
+
// If the value is a plain object, recursively process its properties
|
561
|
+
if (Neo.typeOf(value) === 'Object') {
|
562
|
+
me.processDataObject(value, fullPath);
|
700
563
|
}
|
701
|
-
}
|
564
|
+
});
|
702
565
|
}
|
703
566
|
|
704
567
|
/**
|
@@ -714,6 +577,34 @@ class Provider extends Base {
|
|
714
577
|
}
|
715
578
|
}
|
716
579
|
|
580
|
+
/**
|
581
|
+
* Helper function to set a config value and trigger reactivity.
|
582
|
+
* This method creates a new Config instance if one doesn't exist for the given path,
|
583
|
+
* or updates an existing one. It also triggers binding effects and calls onDataPropertyChange.
|
584
|
+
* @param {Neo.state.Provider} provider The StateProvider instance owning the config.
|
585
|
+
* @param {String} path The full path of the data property (e.g., 'user.firstname').
|
586
|
+
* @param {*} newValue The new value to set.
|
587
|
+
* @param {*} oldVal The old value (optional, used for initial setup).
|
588
|
+
* @private
|
589
|
+
*/
|
590
|
+
#setConfigValue(provider, path, newValue, oldVal) {
|
591
|
+
let currentConfig = provider.getDataConfig(path),
|
592
|
+
oldValue = oldVal;
|
593
|
+
|
594
|
+
if (currentConfig) {
|
595
|
+
oldValue = currentConfig.get();
|
596
|
+
currentConfig.set(newValue);
|
597
|
+
} else {
|
598
|
+
currentConfig = new Config(newValue);
|
599
|
+
provider.#dataConfigs[path] = currentConfig;
|
600
|
+
// Trigger all binding effects to re-evaluate their dependencies
|
601
|
+
provider.#bindingEffects.forEach(effect => effect.run())
|
602
|
+
}
|
603
|
+
|
604
|
+
// Notify subscribers of the data property change.
|
605
|
+
provider.onDataPropertyChange(path, newValue, oldValue)
|
606
|
+
}
|
607
|
+
|
717
608
|
/**
|
718
609
|
* The method will assign all values to the closest stateProvider where it finds an existing key.
|
719
610
|
* In case no match is found inside the parent chain, a new data property will get generated.
|