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.
- package/.github/RELEASE_NOTES/v10.1.1.md +13 -0
- package/.github/RELEASE_NOTES/v10.2.0.md +34 -0
- package/ServiceWorker.mjs +2 -2
- package/apps/covid/view/country/Gallery.mjs +1 -1
- package/apps/covid/view/country/Helix.mjs +1 -1
- package/apps/covid/view/country/Table.mjs +27 -29
- package/apps/portal/index.html +1 -1
- package/apps/portal/resources/data/blog.json +12 -0
- package/apps/portal/view/home/FooterContainer.mjs +1 -1
- package/apps/sharedcovid/view/country/Gallery.mjs +1 -1
- package/apps/sharedcovid/view/country/Helix.mjs +1 -1
- package/apps/sharedcovid/view/country/Table.mjs +22 -22
- package/examples/grid/bigData/ControlsContainer.mjs +14 -0
- package/examples/stateProvider/inline/MainContainer.mjs +1 -1
- package/examples/stateProvider/twoWay/MainContainer.mjs +2 -2
- package/examples/treeAccordion/MainContainer.mjs +1 -1
- package/learn/blog/v10-deep-dive-functional-components.md +107 -97
- package/learn/blog/v10-deep-dive-reactivity.md +3 -3
- package/learn/blog/v10-deep-dive-state-provider.md +42 -137
- package/learn/blog/v10-deep-dive-vdom-revolution.md +35 -61
- package/learn/blog/v10-post1-love-story.md +3 -3
- package/learn/gettingstarted/DescribingTheUI.md +108 -33
- package/learn/guides/fundamentals/ConfigSystemDeepDive.md +118 -18
- package/learn/guides/fundamentals/InstanceLifecycle.md +121 -84
- package/learn/tree.json +1 -0
- package/learn/tutorials/CreatingAFunctionalButton.md +179 -0
- package/package.json +3 -3
- package/src/DefaultConfig.mjs +2 -2
- package/src/collection/Base.mjs +8 -3
- package/src/data/Store.mjs +8 -3
- package/src/date/SelectorContainer.mjs +2 -2
- package/src/form/field/Base.mjs +15 -1
- package/src/form/field/ComboBox.mjs +5 -15
- package/src/functional/component/Base.mjs +26 -0
- package/src/functional/util/html.mjs +75 -0
- package/src/state/Provider.mjs +7 -4
- package/src/tree/Accordion.mjs +1 -1
- package/test/siesta/siesta.js +8 -1
- package/test/siesta/tests/CollectionBase.mjs +46 -0
- package/test/siesta/tests/form/field/AfterSetValueSequence.mjs +106 -0
- package/test/siesta/tests/functional/HtmlTemplateComponent.mjs +92 -0
- package/test/siesta/tests/state/FeedbackLoop.mjs +159 -0
- package/test/siesta/tests/state/Provider.mjs +56 -0
package/src/form/field/Base.mjs
CHANGED
@@ -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
|
-
|
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
|
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
|
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.
|
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
|
-
|
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;
|
package/src/state/Provider.mjs
CHANGED
@@ -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
|
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
|
-
|
629
|
-
|
629
|
+
if (hasChange) {
|
630
|
+
// Notify subscribers of the data property change.
|
631
|
+
provider.onDataPropertyChange(path, newValue, oldValue)
|
632
|
+
}
|
630
633
|
}
|
631
634
|
|
632
635
|
/**
|
package/src/tree/Accordion.mjs
CHANGED
@@ -63,7 +63,7 @@ class AccordionTree extends TreeList {
|
|
63
63
|
*
|
64
64
|
* @example
|
65
65
|
* module: AccordionTree,
|
66
|
-
* bind : {selection: {
|
66
|
+
* bind : {selection: {key: 'selection', twoWay: true}}
|
67
67
|
*
|
68
68
|
* ntype: 'component',
|
69
69
|
* bind : {html: data => data.selection[0].name}
|
package/test/siesta/siesta.js
CHANGED
@@ -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
|
+
});
|