neo.mjs 10.0.0-beta.2 → 10.0.0-beta.4

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 (52) hide show
  1. package/.github/RELEASE_NOTES/v10.0.0-beta.4.md +41 -0
  2. package/ServiceWorker.mjs +2 -2
  3. package/apps/form/view/FormPageContainer.mjs +2 -3
  4. package/apps/portal/index.html +1 -1
  5. package/apps/portal/view/ViewportController.mjs +1 -1
  6. package/apps/portal/view/home/FooterContainer.mjs +1 -1
  7. package/apps/portal/view/learn/ContentComponent.mjs +18 -11
  8. package/apps/portal/view/learn/MainContainerController.mjs +6 -6
  9. package/learn/README.md +9 -14
  10. package/learn/guides/datahandling/Collections.md +436 -0
  11. package/learn/guides/datahandling/Grids.md +621 -0
  12. package/learn/guides/datahandling/Records.md +287 -0
  13. package/learn/guides/{StateProviders.md → datahandling/StateProviders.md} +145 -1
  14. package/learn/guides/fundamentals/ExtendingNeoClasses.md +359 -0
  15. package/learn/guides/uibuildingblocks/CustomComponents.md +287 -0
  16. package/learn/guides/uibuildingblocks/Layouts.md +248 -0
  17. package/learn/guides/userinteraction/Forms.md +449 -0
  18. package/learn/guides/userinteraction/form_fields/ComboBox.md +241 -0
  19. package/learn/tree.json +63 -52
  20. package/package.json +2 -2
  21. package/resources/scss/src/apps/portal/learn/ContentComponent.scss +9 -0
  22. package/src/DefaultConfig.mjs +2 -2
  23. package/src/Neo.mjs +37 -29
  24. package/src/collection/Base.mjs +29 -2
  25. package/src/component/Base.mjs +6 -16
  26. package/src/controller/Base.mjs +87 -63
  27. package/src/core/Base.mjs +72 -17
  28. package/src/core/Compare.mjs +3 -13
  29. package/src/core/Config.mjs +139 -0
  30. package/src/core/ConfigSymbols.mjs +3 -0
  31. package/src/core/Util.mjs +3 -18
  32. package/src/data/RecordFactory.mjs +22 -3
  33. package/src/form/field/ComboBox.mjs +6 -1
  34. package/src/util/Function.mjs +52 -5
  35. package/src/vdom/Helper.mjs +7 -5
  36. package/test/siesta/tests/ReactiveConfigs.mjs +112 -0
  37. package/learn/guides/CustomComponents.md +0 -45
  38. package/learn/guides/Forms.md +0 -1
  39. package/learn/guides/Layouts.md +0 -1
  40. /package/learn/guides/{Tables.md → datahandling/Tables.md} +0 -0
  41. /package/learn/guides/{ApplicationBootstrap.md → fundamentals/ApplicationBootstrap.md} +0 -0
  42. /package/learn/guides/{ConfigSystemDeepDive.md → fundamentals/ConfigSystemDeepDive.md} +0 -0
  43. /package/learn/guides/{DeclarativeComponentTreesVsImperativeVdom.md → fundamentals/DeclarativeComponentTreesVsImperativeVdom.md} +0 -0
  44. /package/learn/guides/{InstanceLifecycle.md → fundamentals/InstanceLifecycle.md} +0 -0
  45. /package/learn/guides/{MainThreadAddons.md → fundamentals/MainThreadAddons.md} +0 -0
  46. /package/learn/guides/{Mixins.md → specificfeatures/Mixins.md} +0 -0
  47. /package/learn/guides/{MultiWindow.md → specificfeatures/MultiWindow.md} +0 -0
  48. /package/learn/guides/{PortalApp.md → specificfeatures/PortalApp.md} +0 -0
  49. /package/learn/guides/{ComponentsAndContainers.md → uibuildingblocks/ComponentsAndContainers.md} +0 -0
  50. /package/learn/guides/{WorkingWithVDom.md → uibuildingblocks/WorkingWithVDom.md} +0 -0
  51. /package/learn/guides/{events → userinteraction/events}/CustomEvents.md +0 -0
  52. /package/learn/guides/{events → userinteraction/events}/DomEvents.md +0 -0
@@ -0,0 +1,139 @@
1
+ import {isDescriptor} from './ConfigSymbols.mjs';
2
+
3
+ /**
4
+ * @src/util/ClassSystem.mjs Neo.core.Config
5
+ * @private
6
+ * @internal
7
+ *
8
+ * Represents an observable container for a config property.
9
+ * This class manages the value of a config, its subscribers, and custom behaviors
10
+ * like merge strategies and equality checks defined via a descriptor object.
11
+ *
12
+ * The primary purpose of this class is to enable fine-grained reactivity and
13
+ * decoupled cross-instance state sharing within the Neo.mjs framework.
14
+ */
15
+ class Config {
16
+ /**
17
+ * The internal value of the config property.
18
+ * @private
19
+ * @apps/portal/view/about/MemberContainer.mjs {any} #value
20
+ */
21
+ #value;
22
+
23
+ /**
24
+ * A Set to store callback functions that subscribe to changes in this config's value.
25
+ * @private
26
+ * @apps/portal/view/about/MemberContainer.mjs {Set<Function>} #subscribers
27
+ */
28
+ #subscribers = new Set();
29
+
30
+ /**
31
+ * The strategy to use when merging new values into this config.
32
+ * Defaults to 'deep'. Can be overridden via a descriptor.
33
+ * @apps/portal/view/about/MemberContainer.mjs {string} mergeStrategy
34
+ */
35
+ mergeStrategy = 'deep';
36
+
37
+ /**
38
+ * The function used to compare new and old values for equality.
39
+ * Defaults to `Neo.isEqual`. Can be overridden via a descriptor.
40
+ * @apps/portal/view/about/MemberContainer.mjs {Function} isEqual
41
+ */
42
+ isEqual = Neo.isEqual;
43
+
44
+ /**
45
+ * Creates an instance of Config.
46
+ * @param {any|Object} configObject - The initial value for the config.
47
+ */
48
+ constructor(configObject) {
49
+ if (Neo.isObject(configObject) && configObject[isDescriptor] === true) {
50
+ this.initDescriptor(configObject)
51
+ } else {
52
+ this.#value = configObject
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Gets the current value of the config property.
58
+ * @returns {any} The current value.
59
+ */
60
+ get() {
61
+ return this.#value;
62
+ }
63
+
64
+ /**
65
+ * Initializes the `Config` instance using a descriptor object.
66
+ * Extracts `mergeStrategy` and `isEqual` from the descriptor.
67
+ * The internal `#value` is NOT set by this method.
68
+ * @param {Object} descriptor - The descriptor object for the config.
69
+ * @param {any} descriptor.value - The default value for the config (not set by this method).
70
+ * @param {string} [descriptor.merge='deep'] - The merge strategy.
71
+ * @param {Function} [descriptor.isEqual=Neo.isEqual] - The equality comparison function.
72
+ */
73
+ initDescriptor({isEqual, merge, value}) {
74
+ let me = this;
75
+
76
+ me.#value = value
77
+ me.mergeStrategy = merge || me.mergeStrategy;
78
+ me.isEqual = isEqual || me.isEqual;
79
+ }
80
+
81
+ /**
82
+ * Notifies all subscribed callbacks about a change in the config's value.
83
+ * @param {any} newValue - The new value of the config.
84
+ * @param {any} oldValue - The old value of the config.
85
+ */
86
+ notify(newValue, oldValue) {
87
+ for (const callback of this.#subscribers) {
88
+ callback(newValue, oldValue);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Sets a new value for the config property.
94
+ * This method performs an equality check using `this.isEqual` before updating the value.
95
+ * If the value has changed, it updates `#value` and notifies all subscribers.
96
+ * @param {any} newValue - The new value to set.
97
+ * @returns {Boolean} True if the value changed, false otherwise.
98
+ */
99
+ set(newValue) {
100
+ if (newValue === undefined) return false; // Preserve original behavior for undefined
101
+
102
+ const
103
+ me = this,
104
+ oldValue = me.#value;
105
+
106
+ // The setter automatically uses the configured equality check
107
+ if (!me.isEqual(newValue, oldValue)) {
108
+ me.#value = newValue;
109
+ me.notify(newValue, oldValue);
110
+ return true
111
+ }
112
+
113
+ return false
114
+ }
115
+
116
+ /**
117
+ * Sets the internal value of the config property directly, without performing
118
+ * an equality check or notifying subscribers.
119
+ * This method is intended for internal framework use where direct assignment
120
+ * is necessary (e.g., during initial setup or specific internal optimizations).
121
+ * @param {any} newValue - The new value to set directly.
122
+ */
123
+ setRaw(newValue) {
124
+ this.#value = newValue
125
+ }
126
+
127
+ /**
128
+ * Subscribes a callback function to changes in this config's value.
129
+ * The callback will be invoked with `(newValue, oldValue)` whenever the config changes.
130
+ * @param {Function} callback - The function to call when the config value changes.
131
+ * @returns {Function} A cleanup function to unsubscribe the callback.
132
+ */
133
+ subscribe(callback) {
134
+ this.#subscribers.add(callback);
135
+ return () => this.#subscribers.delete(callback)
136
+ }
137
+ }
138
+
139
+ export default Config;
@@ -0,0 +1,3 @@
1
+
2
+ // e.g., in a new file src/core/ConfigSymbols.mjs
3
+ export const isDescriptor = Symbol.for('Neo.Config.isDescriptor');
package/src/core/Util.mjs CHANGED
@@ -1,10 +1,7 @@
1
- import Base from './Base.mjs';
2
-
3
1
  /**
4
2
  * @class Neo.core.Util
5
- * @extends Neo.core.Base
6
3
  */
7
- class Util extends Base {
4
+ class Util {
8
5
  /**
9
6
  * A regex to remove camel case syntax
10
7
  * @member {RegExp} decamelRegEx=/([a-z])([A-Z])/g
@@ -13,19 +10,6 @@ class Util extends Base {
13
10
  */
14
11
  static decamelRegEx = /([a-z])([A-Z])/g
15
12
 
16
- static config = {
17
- /**
18
- * @member {String} className='Neo.core.Util'
19
- * @protected
20
- */
21
- className: 'Neo.core.Util',
22
- /**
23
- * @member {String} ntype='core-util'
24
- * @protected
25
- */
26
- ntype: 'core-util'
27
- }
28
-
29
13
  /**
30
14
  * @param {Object} scope
31
15
  * @param {String[]} values
@@ -229,7 +213,8 @@ class Util extends Base {
229
213
  }
230
214
  }
231
215
 
232
- Util = Neo.setupClass(Util);
216
+ const ns = Neo.ns('Neo.core', true);
217
+ ns.Util = Util;
233
218
 
234
219
  // aliases
235
220
  Neo.applyFromNs(Neo, Util, {
@@ -81,7 +81,7 @@ class RecordFactory extends Base {
81
81
  return this[dataSymbol][fieldName]
82
82
  },
83
83
  set(value) {
84
- instance.setRecordFields({
84
+ this.notifyChange({
85
85
  fields: {[fieldPath]: instance.parseRecordValue({record: this, field, value})},
86
86
  model,
87
87
  record: this
@@ -193,6 +193,25 @@ class RecordFactory extends Base {
193
193
  return null
194
194
  }
195
195
 
196
+ /**
197
+ * The single source of truth for record field changes.
198
+ * Executes instance.setRecordFields(), and can get used via:
199
+ * - Neo.util.Function:createSequence()
200
+ * - Neo.util.Function:intercept(),
201
+ * to "listen" to field changes
202
+ * @param {Object} data
203
+ * @param {Object} data.fields
204
+ * @param {Neo.data.Model} data.model
205
+ * @param {Object} data.record
206
+ * @param {Boolean} silent=false
207
+ * @returns {Object}
208
+ */
209
+ notifyChange(data, silent=false) {
210
+ const param = {...data, silent}
211
+ instance.setRecordFields(param);
212
+ return param
213
+ }
214
+
196
215
  /**
197
216
  * Bulk-update multiple record fields at once
198
217
  * @param {Object} fields
@@ -207,7 +226,7 @@ class RecordFactory extends Base {
207
226
  * @param {Object} fields
208
227
  */
209
228
  set(fields) {
210
- instance.setRecordFields({fields, model, record: this})
229
+ this.notifyChange({fields, model, record: this})
211
230
  }
212
231
 
213
232
  /**
@@ -225,7 +244,7 @@ class RecordFactory extends Base {
225
244
  * @param {Object} fields
226
245
  */
227
246
  setSilent(fields) {
228
- instance.setRecordFields({fields, model, record: this, silent: true})
247
+ this.notifyChange({fields, model, record: this}, true)
229
248
  }
230
249
 
231
250
  /**
@@ -118,7 +118,12 @@ class ComboBox extends Picker {
118
118
  * which you want to submit instead
119
119
  * @member {Number|String} valueField='id'
120
120
  */
121
- valueField: 'id'
121
+ valueField: 'id',
122
+ /**
123
+ * Default width to prevent rendering issues.
124
+ * @member {Number} width=150
125
+ */
126
+ width: 150
122
127
  }
123
128
 
124
129
  /**
@@ -1,3 +1,6 @@
1
+ const originalMethodSymbol = Symbol('originalMethod');
2
+ const sequencedFnsSymbol = Symbol('sequencedFns');
3
+
1
4
  /**
2
5
  * Append args instead of prepending them
3
6
  * @param {Function} fn
@@ -67,12 +70,30 @@ export function createInterceptor(target, targetMethodName, interceptFunction, s
67
70
  * @returns {Function}
68
71
  */
69
72
  export function createSequence(target, methodName, fn, scope) {
70
- let method = target[methodName] || Neo.emptyFn;
73
+ let currentMethod = target[methodName],
74
+ wrapper;
75
+
76
+ if (currentMethod && currentMethod[sequencedFnsSymbol]) {
77
+ // Already a sequenced method, add to its list
78
+ wrapper = currentMethod;
79
+ wrapper[sequencedFnsSymbol].push({fn, scope})
80
+ } else {
81
+ // First time sequencing this method
82
+ let originalMethod = currentMethod || Neo.emptyFn;
83
+
84
+ wrapper = function() {
85
+ originalMethod.apply(this, arguments); // Call the original method
86
+
87
+ // Call all sequenced functions
88
+ wrapper[sequencedFnsSymbol].forEach(seqFn => {
89
+ seqFn.fn.apply(seqFn.scope || this, arguments);
90
+ });
91
+ };
92
+ wrapper[sequencedFnsSymbol] = [{fn, scope}];
93
+ wrapper[originalMethodSymbol] = originalMethod; // Store original method
94
+ }
71
95
 
72
- return (target[methodName] = function() {
73
- method.apply(this, arguments);
74
- return fn.apply(scope || this, arguments)
75
- })
96
+ return (target[methodName] = wrapper);
76
97
  }
77
98
 
78
99
  /**
@@ -178,3 +199,29 @@ export function throttle(callback, scope, delay=300) {
178
199
  }
179
200
  }
180
201
  }
202
+
203
+ /**
204
+ * @param {Neo.core.Base} target
205
+ * @param {String} methodName
206
+ * @param {Function} fn
207
+ * @param {Object} scope
208
+ */
209
+ export function unSequence(target, methodName, fn, scope) {
210
+ let currentMethod = target[methodName];
211
+
212
+ if (!currentMethod || !currentMethod[sequencedFnsSymbol]) {
213
+ return // Not a sequenced method
214
+ }
215
+
216
+ const sequencedFunctions = currentMethod[sequencedFnsSymbol];
217
+
218
+ // Filter out the function to unsequence
219
+ currentMethod[sequencedFnsSymbol] = sequencedFunctions.filter(seqFn =>
220
+ !(seqFn.fn === fn && seqFn.scope === scope)
221
+ );
222
+
223
+ if (currentMethod[sequencedFnsSymbol].length === 0) {
224
+ // If no functions left, restore the original method
225
+ target[methodName] = currentMethod[originalMethodSymbol]
226
+ }
227
+ }
@@ -508,11 +508,13 @@ class Helper extends Base {
508
508
 
509
509
  let me = this;
510
510
 
511
- // Subscribe to global Neo.config changes for dynamic renderer switching.
512
- Neo.currentWorker.on({
513
- neoConfigChange: me.onNeoConfigChange,
514
- scope : me
515
- });
511
+ if (!NeoConfig.unitTestMode) {
512
+ // Subscribe to global Neo.config changes for dynamic renderer switching.
513
+ Neo.currentWorker.on({
514
+ neoConfigChange: me.onNeoConfigChange,
515
+ scope : me
516
+ })
517
+ }
516
518
 
517
519
  await me.importUtil()
518
520
  }
@@ -0,0 +1,112 @@
1
+ import Neo from '../../../src/Neo.mjs';
2
+ import * as core from '../../../src/core/_export.mjs';
3
+ import {isDescriptor} from '../../../src/core/ConfigSymbols.mjs';
4
+
5
+ class MyComponent extends core.Base {
6
+ static config = {
7
+ className: 'Neo.TestComponent',
8
+ myConfig_ : 'initialValue',
9
+ arrayConfig_: {
10
+ [isDescriptor]: true,
11
+ value: [],
12
+ merge: 'replace'
13
+ },
14
+ objectConfig_: {
15
+ [isDescriptor]: true,
16
+ value: {},
17
+ merge: 'deep'
18
+ }
19
+ }
20
+
21
+ afterSetMyConfig(value, oldValue) {
22
+ // This will be called by the framework
23
+ }
24
+ }
25
+
26
+ MyComponent = Neo.setupClass(MyComponent);
27
+
28
+ StartTest(t => {
29
+ t.it('Basic reactivity with subscribe', t => {
30
+ const instance = Neo.create(MyComponent);
31
+ const configController = instance.getConfig('myConfig');
32
+
33
+ let subscriberCalled = false;
34
+ let receivedNewValue, receivedOldValue;
35
+
36
+ const cleanup = configController.subscribe((newValue, oldValue) => {
37
+ subscriberCalled = true;
38
+ receivedNewValue = newValue;
39
+ receivedOldValue = oldValue;
40
+ });
41
+
42
+ instance.myConfig = 'newValue';
43
+
44
+ t.ok(subscriberCalled, 'Subscriber callback should be called');
45
+ t.is(receivedNewValue, 'newValue', 'New value should be passed to subscriber');
46
+ t.is(receivedOldValue, 'initialValue', 'Old value should be passed to subscriber');
47
+
48
+ // Test cleanup
49
+ subscriberCalled = false;
50
+ cleanup();
51
+ instance.myConfig = 'anotherValue';
52
+ t.notOk(subscriberCalled, 'Subscriber callback should not be called after cleanup');
53
+ });
54
+
55
+ t.it('Descriptor: arrayConfig_ with merge: replace', t => {
56
+ const instance = Neo.create(MyComponent);
57
+ const configController = instance.getConfig('arrayConfig');
58
+
59
+ let subscriberCalled = 0;
60
+ configController.subscribe((newValue, oldValue) => {
61
+ subscriberCalled++;
62
+ });
63
+
64
+ const arr1 = [1, 2, 3];
65
+ instance.arrayConfig = arr1;
66
+ t.is(instance.arrayConfig, arr1, 'Array should be replaced');
67
+ t.is(subscriberCalled, 1, 'Subscriber called once for array replacement');
68
+
69
+ const arr2 = [4, 5, 6];
70
+ instance.arrayConfig = arr2;
71
+ t.is(instance.arrayConfig, arr2, 'Array should be replaced again');
72
+ t.is(subscriberCalled, 2, 'Subscriber called twice for array replacement');
73
+
74
+ // Setting the same array should not trigger a change by default isEqual
75
+ instance.arrayConfig = arr2;
76
+ t.is(subscriberCalled, 2, 'Subscriber not called when setting the same array reference');
77
+ });
78
+
79
+ t.it('Descriptor: objectConfig_ with merge: deep', t => {
80
+ const instance = Neo.create(MyComponent);
81
+ const configController = instance.getConfig('objectConfig');
82
+
83
+ let subscriberCalled = 0;
84
+ configController.subscribe((newValue, oldValue) => {
85
+ subscriberCalled++;
86
+ });
87
+
88
+ const obj1 = {a: 1, b: {c: 2}};
89
+ instance.objectConfig = obj1;
90
+ t.is(instance.objectConfig, obj1, 'Object should be set');
91
+ t.is(subscriberCalled, 1, 'Subscriber called once for object set');
92
+
93
+ // Deep merge should happen, but default isEqual will still compare references
94
+ const obj2 = {a: 1, b: {c: 3}};
95
+ instance.objectConfig = obj2;
96
+ t.is(instance.objectConfig.a, 1, 'Object property a should be 1');
97
+ t.is(instance.objectConfig.b.c, 3, 'Object property b.c should be 3');
98
+ t.is(subscriberCalled, 2, 'Subscriber called twice for object change');
99
+
100
+ // Setting the same object reference should not trigger a change
101
+ instance.objectConfig = obj2;
102
+ t.is(subscriberCalled, 2, 'Subscriber not called when setting the same object reference');
103
+
104
+ // Modifying a nested property should trigger a change if isEqual is deep
105
+ // NOTE: The current Config.mjs uses Neo.isEqual which is a deep comparison.
106
+ // If the object reference changes, it will trigger. If the object reference stays the same, but content changes, it will not trigger unless isEqual is customized.
107
+ // For now, this test relies on the fact that setting a new object reference triggers the change.
108
+ const obj3 = {a: 1, b: {c: 2}};
109
+ instance.objectConfig = obj3;
110
+ t.is(subscriberCalled, 3, 'Subscriber called for new object reference');
111
+ });
112
+ });
@@ -1,45 +0,0 @@
1
- ## Introduction
2
-
3
- Neo.mjs is class-based, which means you're free to extend any component (or any other Neo.mjs class).
4
-
5
-
6
- ## Overriding ancestor configs
7
-
8
- ## Introducing new configs
9
-
10
- ## Lifecycle config properties
11
-
12
- ```javascript live-preview
13
- import Button from '../button/Base.mjs';
14
- // In practice this would be some handy reusable component
15
- class MySpecialButton extends Button {
16
- static config = {
17
- className: 'Example.view.MySpecialButton',
18
- iconCls : 'far fa-face-grin-wide',
19
- ui : 'ghost'
20
- }
21
- }
22
-
23
- MySpecialButton = Neo.setupClass(MySpecialButton);
24
-
25
-
26
- import Container from '../container/Base.mjs';
27
-
28
- class MainView extends Container {
29
- static config = {
30
- className: 'Example.view.MainView',
31
- layout : {ntype:'vbox', align:'start'},
32
- items : [{
33
- module : Button,
34
- iconCls: 'fa fa-home',
35
- text : 'A framework button'
36
- }, {
37
- module : MySpecialButton,
38
- text : 'My special button'
39
- }]
40
- }
41
- }
42
-
43
- Neo.setupClass(MainView);
44
- ```
45
-
@@ -1 +0,0 @@
1
- ## todo
@@ -1 +0,0 @@
1
- ## todo