neo.mjs 10.1.0 → 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.github/RELEASE_NOTES/v10.1.1.md +13 -0
  2. package/.github/RELEASE_NOTES/v10.2.0.md +34 -0
  3. package/ServiceWorker.mjs +2 -2
  4. package/apps/covid/view/country/Gallery.mjs +1 -1
  5. package/apps/covid/view/country/Helix.mjs +1 -1
  6. package/apps/covid/view/country/Table.mjs +27 -29
  7. package/apps/portal/index.html +1 -1
  8. package/apps/portal/resources/data/blog.json +12 -0
  9. package/apps/portal/view/home/FooterContainer.mjs +1 -1
  10. package/apps/sharedcovid/view/country/Gallery.mjs +1 -1
  11. package/apps/sharedcovid/view/country/Helix.mjs +1 -1
  12. package/apps/sharedcovid/view/country/Table.mjs +22 -22
  13. package/examples/grid/bigData/ControlsContainer.mjs +14 -0
  14. package/examples/stateProvider/inline/MainContainer.mjs +1 -1
  15. package/examples/stateProvider/twoWay/MainContainer.mjs +2 -2
  16. package/examples/treeAccordion/MainContainer.mjs +1 -1
  17. package/learn/blog/v10-deep-dive-functional-components.md +107 -97
  18. package/learn/blog/v10-deep-dive-reactivity.md +3 -3
  19. package/learn/blog/v10-deep-dive-state-provider.md +42 -137
  20. package/learn/blog/v10-deep-dive-vdom-revolution.md +35 -61
  21. package/learn/blog/v10-post1-love-story.md +3 -3
  22. package/learn/gettingstarted/DescribingTheUI.md +108 -33
  23. package/learn/guides/fundamentals/ConfigSystemDeepDive.md +118 -18
  24. package/learn/guides/fundamentals/InstanceLifecycle.md +121 -84
  25. package/learn/tree.json +1 -0
  26. package/learn/tutorials/CreatingAFunctionalButton.md +179 -0
  27. package/package.json +3 -3
  28. package/src/DefaultConfig.mjs +2 -2
  29. package/src/collection/Base.mjs +8 -3
  30. package/src/data/Store.mjs +8 -3
  31. package/src/date/SelectorContainer.mjs +2 -2
  32. package/src/form/field/Base.mjs +15 -1
  33. package/src/form/field/ComboBox.mjs +5 -15
  34. package/src/functional/component/Base.mjs +26 -0
  35. package/src/functional/util/html.mjs +75 -0
  36. package/src/state/Provider.mjs +7 -4
  37. package/src/tree/Accordion.mjs +1 -1
  38. package/test/siesta/siesta.js +8 -1
  39. package/test/siesta/tests/CollectionBase.mjs +46 -0
  40. package/test/siesta/tests/form/field/AfterSetValueSequence.mjs +106 -0
  41. package/test/siesta/tests/functional/HtmlTemplateComponent.mjs +92 -0
  42. package/test/siesta/tests/state/FeedbackLoop.mjs +159 -0
  43. package/test/siesta/tests/state/Provider.mjs +56 -0
@@ -1,3 +1,4 @@
1
+ import { createSequence } from '../../util/Function.mjs';
1
2
  import Component from '../../component/Base.mjs';
2
3
  import ComponentManager from '../../manager/Component.mjs';
3
4
  import NeoArray from '../../util/Array.mjs';
@@ -97,6 +98,19 @@ class Field extends Component {
97
98
  */
98
99
  path = null
99
100
 
101
+ /**
102
+ * @param {Object} config
103
+ */
104
+ construct(config) {
105
+ super.construct(config);
106
+
107
+ let me = this;
108
+
109
+ createSequence(me, 'afterSetValue', (value, oldValue) => {
110
+ oldValue !== undefined && me.fireChangeEvent(value, oldValue)
111
+ }, me)
112
+ }
113
+
100
114
  /**
101
115
  * Triggered after the name isTouched got changed
102
116
  * @param {String|null} value
@@ -137,7 +151,7 @@ class Field extends Component {
137
151
  * @param {*} oldValue
138
152
  */
139
153
  afterSetValue(value, oldValue) {
140
- oldValue !== undefined && this.fireChangeEvent(value, oldValue)
154
+
141
155
  }
142
156
 
143
157
  /**
@@ -178,7 +178,7 @@ class ComboBox extends Picker {
178
178
  */
179
179
  afterSetStore(value, oldValue) {
180
180
  let me = this,
181
- filters, val;
181
+ filters;
182
182
 
183
183
  if (value) {
184
184
  if (me.useFilter) {
@@ -198,14 +198,7 @@ class ComboBox extends Picker {
198
198
  me.list.store = value
199
199
  }
200
200
 
201
- value.on('load', me.onStoreLoad, me);
202
-
203
- if (me.value) {
204
- val = me.value;
205
-
206
- me._value = null; // silent reset to trigger a change event
207
- me.value = val
208
- }
201
+ value.on('load', me.onStoreLoad, me)
209
202
  }
210
203
  }
211
204
 
@@ -237,10 +230,9 @@ class ComboBox extends Picker {
237
230
  let selectionModel = me.list?.selectionModel;
238
231
 
239
232
  if (value) {
240
- oldValue && selectionModel?.deselect(oldValue);
241
233
  selectionModel?.select(value)
242
234
  } else {
243
- selectionModel.deselectAll()
235
+ selectionModel?.deselectAll()
244
236
  }
245
237
  }
246
238
  }
@@ -342,7 +334,7 @@ class ComboBox extends Picker {
342
334
  }
343
335
 
344
336
  // we can only match record ids or display values in case the store is loaded
345
- if (store.getCount() > 0) {
337
+ if (store.isLoaded) {
346
338
  record = store.isFiltered() ? store.allItems.get(value) : store.get(value);
347
339
 
348
340
  if (record) {
@@ -460,13 +452,11 @@ class ComboBox extends Picker {
460
452
  * @param {*} oldValue
461
453
  * @override
462
454
  */
463
- async fireChangeEvent(value, oldValue) {
455
+ fireChangeEvent(value, oldValue) {
464
456
  let me = this,
465
457
  FormContainer = Neo.form?.Container,
466
458
  params = {component: me, oldValue, value};
467
459
 
468
- await me.timeout(30);
469
-
470
460
  me.fire('change', params);
471
461
 
472
462
  if (!me.suspendEvents) {
@@ -20,6 +20,13 @@ class FunctionalBase extends Abstract {
20
20
  * @protected
21
21
  */
22
22
  className: 'Neo.functional.component.Base',
23
+ /**
24
+ * @member {Boolean} enableHtmlTemplates_=false
25
+ * @reactive
26
+ * Set this to true to enable using tagged template literals for VDOM creation
27
+ * via the render() method. This will lazy load the html parser.
28
+ */
29
+ enableHtmlTemplates_: false,
23
30
  /**
24
31
  * @member {String} ntype='functional-component'
25
32
  * @protected
@@ -96,6 +103,20 @@ class FunctionalBase extends Abstract {
96
103
  }
97
104
  }
98
105
 
106
+ /**
107
+ * Triggered after the enableHtmlTemplates config got changed.
108
+ * @param {Boolean} value
109
+ * @param {Boolean} oldValue
110
+ * @protected
111
+ */
112
+ afterSetEnableHtmlTemplates_(value, oldValue) {
113
+ if (value && !this.htmlParser) {
114
+ import('../util/html.mjs').then(module => {
115
+ this.htmlParser = module.default;
116
+ });
117
+ }
118
+ }
119
+
99
120
  /**
100
121
  * Triggered after the windowId config got changed
101
122
  * @param {Number|null} value
@@ -175,6 +196,11 @@ class FunctionalBase extends Abstract {
175
196
  * @returns {Object} The VDOM structure for the component.
176
197
  */
177
198
  createVdom(config) {
199
+ const me = this;
200
+
201
+ if (me.enableHtmlTemplates && typeof me.createTemplateVdom === 'function') {
202
+ return me.createTemplateVdom(config)
203
+ }
178
204
  // This method should be overridden by subclasses
179
205
  return {}
180
206
  }
@@ -0,0 +1,75 @@
1
+ import { rawDimensionTags, voidAttributes, voidElements } from '../../vdom/domConstants.mjs';
2
+
3
+ /**
4
+ * @param {Array<String>} strings
5
+ * @param {Array<*>} values
6
+ * @returns {Object} A VDomNodeConfig object.
7
+ */
8
+ const html = (strings, ...values) => {
9
+ let fullString = '';
10
+ for (let i = 0; i < strings.length; i++) {
11
+ fullString += strings[i];
12
+ if (i < values.length) {
13
+ // Use a unique placeholder for dynamic values
14
+ fullString += `__DYNAMIC_VALUE_${i}__`;
15
+ }
16
+ }
17
+
18
+ // Very basic parsing: find the first tag and its content
19
+ const tagRegex = /<(\w+)([^>]*)>([\s\S]*?)<\/\1>/;
20
+ const match = fullString.match(tagRegex);
21
+
22
+ if (!match) {
23
+ // If no matching tag, return a simple text node or empty div
24
+ return { tag: 'div', text: fullString.replace(/__DYNAMIC_VALUE_\d+__/g, (m) => {
25
+ const index = parseInt(m.match(/\d+/)[0]);
26
+ return values[index];
27
+ }) };
28
+ }
29
+
30
+ const rootTag = match[1];
31
+ const attributesString = match[2];
32
+ let innerContent = match[3];
33
+
34
+ const vdomNode = {
35
+ tag: rootTag,
36
+ cn: []
37
+ };
38
+
39
+ // Parse attributes (very basic: only id for now)
40
+ const idMatch = attributesString.match(/id="([^"]+)"/);
41
+ if (idMatch) {
42
+ vdomNode.id = idMatch[1];
43
+ }
44
+
45
+ // Replace dynamic placeholders with actual values in innerContent
46
+ innerContent = innerContent.replace(/__DYNAMIC_VALUE_(\d+)__/g, (m, index) => {
47
+ return values[parseInt(index)];
48
+ });
49
+
50
+ // For the current test case, we know it's <p> and <span>
51
+ // This is still not a generic parser, but a step towards it.
52
+ const pSpanRegex = /<p>([\s\S]*?)<\/p>\s*<span>([\s\S]*?)<\/span>/;
53
+ const pSpanMatch = innerContent.match(pSpanRegex);
54
+
55
+ if (pSpanMatch) {
56
+ vdomNode.cn.push({
57
+ tag: 'p',
58
+ text: pSpanMatch[1]
59
+ });
60
+ vdomNode.cn.push({
61
+ tag: 'span',
62
+ text: pSpanMatch[2]
63
+ });
64
+ } else {
65
+ // Fallback for simpler cases or if the regex doesn't match
66
+ vdomNode.cn.push({
67
+ tag: 'div', // Default child tag
68
+ text: innerContent // Treat as plain text for now
69
+ });
70
+ }
71
+
72
+ return vdomNode;
73
+ };
74
+
75
+ export default html;
@@ -613,11 +613,12 @@ class Provider extends Base {
613
613
  */
614
614
  #setConfigValue(provider, path, newValue, oldVal) {
615
615
  let currentConfig = provider.getDataConfig(path),
616
+ hasChange = true,
616
617
  oldValue = oldVal;
617
618
 
618
619
  if (currentConfig) {
619
- oldValue = currentConfig.get();
620
- currentConfig.set(newValue);
620
+ oldValue = currentConfig.get();
621
+ hasChange = currentConfig.set(newValue)
621
622
  } else {
622
623
  currentConfig = new Config(newValue);
623
624
  provider.#dataConfigs[path] = currentConfig;
@@ -625,8 +626,10 @@ class Provider extends Base {
625
626
  provider.#bindingEffects.forEach(effect => effect.run())
626
627
  }
627
628
 
628
- // Notify subscribers of the data property change.
629
- provider.onDataPropertyChange(path, newValue, oldValue)
629
+ if (hasChange) {
630
+ // Notify subscribers of the data property change.
631
+ provider.onDataPropertyChange(path, newValue, oldValue)
632
+ }
630
633
  }
631
634
 
632
635
  /**
@@ -63,7 +63,7 @@ class AccordionTree extends TreeList {
63
63
  *
64
64
  * @example
65
65
  * module: AccordionTree,
66
- * bind : {selection: {twoWay: true, value: data => data.selection}}
66
+ * bind : {selection: {key: 'selection', twoWay: true}}
67
67
  *
68
68
  * ntype: 'component',
69
69
  * bind : {html: data => data.selection[0].name}
@@ -44,12 +44,19 @@ project.plan(
44
44
  'tests/core/EffectBatching.mjs'
45
45
  ]
46
46
  },
47
+ {
48
+ group: 'form',
49
+ items: [
50
+ 'tests/form/field/AfterSetValueSequence.mjs'
51
+ ]
52
+ },
47
53
  {
48
54
  group: 'state',
49
55
  items: [
50
56
  'tests/state/createHierarchicalDataProxy.mjs',
51
57
  'tests/state/Provider.mjs',
52
- 'tests/state/ProviderNestedDataConfigs.mjs'
58
+ 'tests/state/ProviderNestedDataConfigs.mjs',
59
+ 'tests/state/FeedbackLoop.mjs',
53
60
  ]
54
61
  },
55
62
  'tests/CollectionBase.mjs',
@@ -275,4 +275,50 @@ StartTest(t => {
275
275
  {id: 'e'}
276
276
  ], 'collection.getRange()');
277
277
  });
278
+
279
+ t.it('Move collection items', t => {
280
+ let moveCollection = Neo.create(Collection, {
281
+ items: [
282
+ {id: 'a'},
283
+ {id: 'b'},
284
+ {id: 'c'},
285
+ {id: 'd'},
286
+ {id: 'e'}
287
+ ]
288
+ });
289
+
290
+ t.isDeeplyStrict(moveCollection.getRange(), [{id: 'a'}, {id: 'b'}, {id: 'c'}, {id: 'd'}, {id: 'e'}], 'Initial order');
291
+
292
+ // Move item forward
293
+ moveCollection.move(1, 2);
294
+ t.isDeeplyStrict(moveCollection.getRange(), [{id: 'a'}, {id: 'c'}, {id: 'b'}, {id: 'd'}, {id: 'e'}], 'Move item forward (1 -> 2)');
295
+
296
+ // Swap adjacent items (backward)
297
+ moveCollection.move(2, 1);
298
+ t.isDeeplyStrict(moveCollection.getRange(), [{id: 'a'}, {id: 'b'}, {id: 'c'}, {id: 'd'}, {id: 'e'}], 'Swap adjacent items back (2 -> 1)');
299
+
300
+ // Move item backward
301
+ moveCollection.move(3, 1);
302
+ t.isDeeplyStrict(moveCollection.getRange(), [{id: 'a'}, {id: 'd'}, {id: 'b'}, {id: 'c'}, {id: 'e'}], 'Move item backward (3 -> 1)');
303
+
304
+ // Move item forward
305
+ moveCollection.move(1, 3);
306
+ t.isDeeplyStrict(moveCollection.getRange(), [{id: 'a'}, {id: 'b'}, {id: 'c'}, {id: 'd'}, {id: 'e'}], 'Move item forward (1 -> 3)');
307
+
308
+ // Swap adjacent items (forward)
309
+ moveCollection.move(0, 1);
310
+ t.isDeeplyStrict(moveCollection.getRange(), [{id: 'b'}, {id: 'a'}, {id: 'c'}, {id: 'd'}, {id: 'e'}], 'Swap adjacent items (0 -> 1)');
311
+
312
+ // Swap adjacent items (backward)
313
+ moveCollection.move(1, 0);
314
+ t.isDeeplyStrict(moveCollection.getRange(), [{id: 'a'}, {id: 'b'}, {id: 'c'}, {id: 'd'}, {id: 'e'}], 'Swap adjacent items back (1 -> 0)');
315
+
316
+ // Move to end
317
+ moveCollection.move(0, 4);
318
+ t.isDeeplyStrict(moveCollection.getRange(), [{id: 'b'}, {id: 'c'}, {id: 'd'}, {id: 'e'}, {id: 'a'}], 'Move to end (0 -> 4)');
319
+
320
+ // Move to start
321
+ moveCollection.move(4, 0);
322
+ t.isDeeplyStrict(moveCollection.getRange(), [{id: 'a'}, {id: 'b'}, {id: 'c'}, {id: 'd'}, {id: 'e'}], 'Move to start (4 -> 0)');
323
+ });
278
324
  });
@@ -0,0 +1,106 @@
1
+ import Neo from '../../../../../src/Neo.mjs';
2
+ import * as core from '../../../../../src/core/_export.mjs';
3
+ import InstanceManager from '../../../../../src/manager/Instance.mjs';
4
+ import ComboBox from '../../../../../src/form/field/ComboBox.mjs';
5
+ import Text from '../../../../../src/form/field/Text.mjs';
6
+
7
+ class MyComboBox extends ComboBox {
8
+ static config = {
9
+ className: 'Test.MyComboBox',
10
+
11
+ displayField: 'country',
12
+ valueField : 'country',
13
+ value : 'Angola', // Initial value
14
+ store: {
15
+ keyProperty: 'country',
16
+ model: {
17
+ fields: [
18
+ {name: 'country', type: 'String'}
19
+ ]
20
+ },
21
+ data : [
22
+ {country: 'Angola'},
23
+ {country: 'Algeria'}
24
+ ]
25
+ }
26
+ }
27
+
28
+ afterSetValue(value, oldValue) {
29
+ this.executionOrder.push('MyComboBox.afterSetValue');
30
+ super.afterSetValue(value, oldValue);
31
+ }
32
+ }
33
+ MyComboBox = Neo.setupClass(MyComboBox);
34
+
35
+ class MyTextField extends Text {
36
+ static config = {
37
+ className: 'Test.MyTextField'
38
+ }
39
+
40
+ afterSetValue(value, oldValue) {
41
+ this.executionOrder.push('MyTextField.afterSetValue');
42
+ super.afterSetValue(value, oldValue);
43
+ }
44
+ }
45
+ MyTextField = Neo.setupClass(MyTextField);
46
+
47
+ StartTest(t => {
48
+ t.it('TextField afterSetValue sequence should fire change event after the full method chain', t => {
49
+ const executionOrder = [];
50
+
51
+ const field = Neo.create(MyTextField, {
52
+ // pass array to instance so it's available in afterSetValue
53
+ executionOrder
54
+ });
55
+
56
+ field.on('change', (changeEvent) => {
57
+ executionOrder.push('change event');
58
+ t.is(changeEvent.value, 'new value', 'Change event should contain the new value');
59
+ });
60
+
61
+ // Trigger the change
62
+ field.value = 'new value';
63
+
64
+ // Assert the order
65
+ t.isDeeplyStrict(executionOrder, ['MyTextField.afterSetValue', 'MyTextField.afterSetValue', 'change event'], 'Execution order is correct: afterSetValue runs before the change event');
66
+ t.is(field.value, 'new value', 'Value is set correctly on the instance');
67
+
68
+ field.destroy();
69
+ });
70
+
71
+ t.it('ComboBox afterSetValue sequence should fire change event after the full method chain', async t => {
72
+ const executionOrder = [];
73
+
74
+ const field = Neo.create(MyComboBox, {
75
+ // pass array to instance so it's available in afterSetValue
76
+ executionOrder
77
+ });
78
+
79
+ t.is(Neo.isRecord(field.value), true, 'Initial ComboBox value should be a record');
80
+ t.is(field.value.country, 'Angola', 'Initial ComboBox record should contain `Angola`');
81
+
82
+ field.on('change', (changeEvent) => {
83
+ executionOrder.push('combo change event');
84
+
85
+ t.is(Neo.isRecord(field.value), true, 'Updated ComboBox value should be a record');
86
+ t.is(field.value.country, 'Algeria', 'Updated ComboBox record should contain `Algeria`');
87
+ });
88
+
89
+ // Trigger the change
90
+ field.value = 'Algeria';
91
+
92
+ t.is(Neo.isRecord(field.value), true, 'Updated ComboBox value should be a record');
93
+ t.is(field.value.country, 'Algeria', 'Updated ComboBox record should contain `Algeria`');
94
+
95
+ // Required since change events are delayed in ComboBox
96
+ await field.timeout(35);
97
+
98
+ // Assert the order
99
+ t.isDeeplyStrict(executionOrder, ['MyComboBox.afterSetValue', 'MyComboBox.afterSetValue', 'combo change event'], 'Execution order is correct: afterSetValue runs before the change event');
100
+
101
+ t.is(Neo.isRecord(field.value), true, 'Updated ComboBox value should be a record');
102
+ t.is(field.value.country, 'Algeria', 'Updated ComboBox record should contain `Algeria`');
103
+
104
+ field.destroy();
105
+ });
106
+ });
@@ -0,0 +1,92 @@
1
+ import Neo from '../../../../src/Neo.mjs';
2
+ import * as core from '../../../../src/core/_export.mjs';
3
+ import FunctionalBase from '../../../../src/functional/component/Base.mjs';
4
+ import DomApiVnodeCreator from '../../../../src/vdom/util/DomApiVnodeCreator.mjs';
5
+ import VdomHelper from '../../../../src/vdom/Helper.mjs';
6
+ import html from '../../../../src/functional/util/html.mjs';
7
+
8
+ // IMPORTANT: This test file uses real components and expects them to render.
9
+ // We need to enable unitTestMode for isolation, but also allow VDOM updates.
10
+ Neo.config.unitTestMode = true;
11
+ Neo.config.allowVdomUpdatesInTests = true;
12
+ // This ensures that the VdomHelper uses the correct renderer for the assertions.
13
+ Neo.config.useDomApiRenderer = true;
14
+
15
+ // Create a mock application context, as the component lifecycle requires it for updates.
16
+ const appName = 'HtmlTemplateTest';
17
+ Neo.apps = Neo.apps || {};
18
+ Neo.apps[appName] = {
19
+ name : appName,
20
+ fire : Neo.emptyFn,
21
+ isMounted: () => true,
22
+ rendering: false
23
+ };
24
+
25
+ /**
26
+ * @class TestComponent
27
+ * @extends Neo.functional.component.Base
28
+ */
29
+ class TestComponent extends FunctionalBase {
30
+ static config = {
31
+ className : 'TestComponent',
32
+ enableHtmlTemplates: true,
33
+ testText_ : 'Hello from Template!'
34
+ }
35
+
36
+ createTemplateVdom(config) {
37
+ return html`
38
+ <div id="my-template-div">
39
+ <p>${config.testText}</p>
40
+ <span>Another element</span>
41
+ </div>
42
+ `;
43
+ }
44
+ }
45
+
46
+ TestComponent = Neo.setupClass(TestComponent);
47
+
48
+
49
+ StartTest(t => {
50
+ let component, vnode;
51
+
52
+ t.beforeEach(async t => {
53
+ component = Neo.create(TestComponent, {
54
+ appName,
55
+ id: 'my-test-component'
56
+ });
57
+
58
+ ({vnode} = await component.render());
59
+ component.mounted = true; // Manually mount to enable updates in the test env
60
+ });
61
+
62
+ t.afterEach(t => {
63
+ component?.destroy();
64
+ component = null;
65
+ vnode = null;
66
+ });
67
+
68
+ t.it('should create initial vnode correctly using html template', async t => {
69
+ t.expect(vnode.nodeName).toBe('div');
70
+ t.expect(vnode.id).toBe('my-test-component'); // The component's own ID
71
+ t.expect(vnode.childNodes.length).toBe(2);
72
+
73
+ const pNode = vnode.childNodes[0];
74
+ t.expect(pNode.nodeName).toBe('p');
75
+ t.expect(pNode.id).toContain('neo-vnode-'); // Expecting a generated ID
76
+ t.expect(pNode.textContent).toBe('Hello from Template!');
77
+
78
+ const spanNode = vnode.childNodes[1];
79
+ t.expect(spanNode.nodeName).toBe('span');
80
+ t.expect(spanNode.id).toContain('neo-vnode-'); // Expecting a generated ID
81
+ t.expect(spanNode.textContent).toBe('Another element');
82
+ });
83
+
84
+ t.it('should update vnode when reactive config changes', async t => {
85
+ // Update the component to get the updated vnode
86
+ const opts = await component.set({testText: 'Updated Text!'});
87
+ vnode = opts.vnode;
88
+
89
+ const pNode = vnode.childNodes[0];
90
+ t.expect(pNode.textContent).toBe('Updated Text!');
91
+ });
92
+ });
@@ -0,0 +1,159 @@
1
+ import Neo from '../../../../src/Neo.mjs';
2
+ import * as core from '../../../../src/core/_export.mjs';
3
+ import InstanceManager from '../../../../src/manager/Instance.mjs';
4
+ import ComboBox from '../../../../src/form/field/ComboBox.mjs';
5
+ import Container from '../../../../src/container/Base.mjs';
6
+ import StateProvider from '../../../../src/state/Provider.mjs';
7
+ import Store from '../../../../src/data/Store.mjs';
8
+
9
+ StartTest(t => {
10
+
11
+ const mainComponent = Neo.create(Container, {
12
+ stateProvider: {
13
+ module: StateProvider,
14
+ data : {
15
+ country : 'Angola',
16
+ countryLabel: 'Country:'
17
+ }
18
+ },
19
+ items: [{
20
+ module : ComboBox,
21
+ displayField: 'country',
22
+ valueField : 'country',
23
+ bind: {
24
+ labelText: data => data.countryLabel,
25
+ value : data => data.country
26
+ },
27
+ store: {
28
+ module : Store,
29
+ keyProperty: 'country',
30
+
31
+ model: {
32
+ fields: [
33
+ {name: 'country', type: 'String'}
34
+ ]
35
+ },
36
+
37
+ data : [
38
+ {country: 'Angola'},
39
+ {country: 'Algeria'}
40
+ ]
41
+ }
42
+ }]
43
+ });
44
+
45
+ const provider = mainComponent.getStateProvider();
46
+ const comboBox = mainComponent.down('combobox');
47
+
48
+ let changeListenerCalls = 0;
49
+
50
+ comboBox.on('change', () => {
51
+ changeListenerCalls++;
52
+ });
53
+
54
+ t.it('Binding should work for simple and complex configs', t => {
55
+ t.is(comboBox.labelText, 'Country:', 'Simple binding for labelText should work on init');
56
+ t.is(Neo.isRecord(comboBox.value), true, 'Initial ComboBox value should be a record');
57
+ t.is(comboBox.value.country, 'Angola', 'Initial ComboBox record should contain `Angola`');
58
+
59
+ // Assert that change listener was NOT called on initial binding
60
+ t.is(changeListenerCalls, 0, 'Change listener should NOT be called on initial binding');
61
+
62
+ // 3. Now, let's test an update
63
+ provider.setData({
64
+ country : 'Algeria',
65
+ countryLabel: 'Select Country:'
66
+ });
67
+
68
+ t.is(comboBox.labelText, 'Select Country:', 'Simple binding for labelText should work on update');
69
+ t.is(Neo.isRecord(comboBox.value), true, 'Updated ComboBox value should be a record');
70
+ t.is(comboBox.value.country, 'Algeria', 'Updated ComboBox record should contain `Algeria`');
71
+
72
+ // Assert that change listener was NOT called on programmatic update
73
+ t.is(changeListenerCalls, 1, 'Change listener should be called on programmatic update');
74
+
75
+ mainComponent.destroy();
76
+ });
77
+
78
+ t.it('Simulated user interaction should fire the change event', async t => {
79
+ // Create a new ComboBox instance for this test to avoid interference
80
+ const userSimulatedComboBox = Neo.create(ComboBox, {
81
+ displayField: 'country',
82
+ valueField : 'country',
83
+ value : 'Angola', // Initial value
84
+ store: {
85
+ module : Store,
86
+ keyProperty: 'country',
87
+ model: {
88
+ fields: [
89
+ {name: 'country', type: 'String'}
90
+ ]
91
+ },
92
+ data : [
93
+ {country: 'Angola'},
94
+ {country: 'Algeria'}
95
+ ]
96
+ }
97
+ });
98
+
99
+ let userChangeListenerCalls = 0;
100
+ userSimulatedComboBox.on('change', data => {
101
+ userChangeListenerCalls++;
102
+ });
103
+
104
+ t.is(userChangeListenerCalls, 0, 'Change listener count should be 0');
105
+
106
+ userSimulatedComboBox.value = 'Algeria';
107
+
108
+ t.is(Neo.isRecord(userSimulatedComboBox.value), true, 'Updated ComboBox value should be a record');
109
+ t.is(userSimulatedComboBox.value.country, 'Algeria', 'Updated ComboBox record should contain `Algeria`');
110
+
111
+ await userSimulatedComboBox.timeout(35);
112
+
113
+ t.is(userChangeListenerCalls, 1, 'Change listener count should be 1');
114
+
115
+ t.is(Neo.isRecord(userSimulatedComboBox.value), true, 'Updated ComboBox value should be a record');
116
+ t.is(userSimulatedComboBox.value.country, 'Algeria', 'Updated ComboBox record should contain `Algeria`');
117
+
118
+ userSimulatedComboBox.destroy();
119
+ });
120
+
121
+ t.it('Should simulate feedback loop and prevent infinite recursion', async t => {
122
+ let setDataCallCount = 0;
123
+
124
+ // Mock the provider's setData to count calls
125
+ const originalSetData = provider.setData;
126
+ provider.setData = function(...args) {
127
+ setDataCallCount++;
128
+ originalSetData.apply(this, args);
129
+ };
130
+
131
+ // Simulate the controller's onCountryFieldChange
132
+ const mockOnCountryFieldChange = async (data) => {
133
+ const record = comboBox.store.find('country', data.value)?.[0]; // Mimic controller logic
134
+ if (record) {
135
+ provider.setData({
136
+ country: data.value,
137
+ countryRecord: record // Assuming countryRecord is also set
138
+ });
139
+ }
140
+ };
141
+
142
+ // Attach the mock controller logic to the ComboBox's change event
143
+ comboBox.on('change', mockOnCountryFieldChange);
144
+
145
+ // Simulate initial state change (e.g., from hash change)
146
+ // This will trigger the ComboBox's value update, which in turn fires its change event
147
+ // and then our mock controller logic.
148
+ provider.setData({ country: 'Algeria' });
149
+
150
+ await provider.timeout(35);
151
+
152
+ t.is(setDataCallCount, 1, 'Change listener count should be 1');
153
+
154
+ // Clean up
155
+ comboBox.un('change', mockOnCountryFieldChange);
156
+ provider.setData = originalSetData; // Restore original setData
157
+ mainComponent.destroy();
158
+ });
159
+ });