@jinntec/fore 3.3.1 → 4.0.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/README.md +98 -70
- package/dist/fore-dev.js +5593 -6832
- package/dist/fore.js +5602 -4887
- package/index.js +5 -10
- package/package.json +7 -7
- package/resources/fore.css +33 -0
- package/src/DependencyNotifyingDomFacade.js +90 -21
- package/src/DependentXPathQueries.js +15 -2
- package/src/ForeElementMixin.js +110 -16
- package/src/UndoManager.js +267 -0
- package/src/actions/abstract-action.js +71 -30
- package/src/actions/fx-action.js +5 -0
- package/src/actions/fx-append.js +3 -3
- package/src/actions/fx-commit-history.js +26 -0
- package/src/actions/fx-hide.js +1 -1
- package/src/actions/fx-insert.js +25 -22
- package/src/actions/fx-load.js +5 -5
- package/src/actions/fx-redo.js +58 -0
- package/src/actions/fx-refresh.js +2 -2
- package/src/actions/fx-reload.js +1 -1
- package/src/actions/fx-replace.js +1 -1
- package/src/actions/fx-send.js +27 -5
- package/src/actions/fx-setattribute.js +11 -7
- package/src/actions/fx-undo.js +58 -0
- package/src/createNodes.js +314 -0
- package/src/fore.js +53 -18
- package/src/functions/fx-functionlib.js +10 -10
- package/src/fx-bind.js +30 -18
- package/src/fx-fore.js +222 -200
- package/src/fx-instance.js +18 -1
- package/src/fx-model.js +236 -69
- package/src/fx-submission.js +37 -29
- package/src/fx-var.js +49 -13
- package/src/getInScopeContext.js +1 -1
- package/src/json/JSONDomFacade.js +1 -1
- package/src/json/JSONLens.js +2 -2
- package/src/ui/UIElement.js +18 -8
- package/src/ui/abstract-control.js +45 -3
- package/src/ui/fx-alert.js +4 -0
- package/src/ui/fx-case.js +1 -1
- package/src/ui/fx-container.js +3 -0
- package/src/ui/fx-control-menu.js +79 -11
- package/src/ui/fx-control.js +130 -41
- package/src/ui/fx-dialog.js +5 -0
- package/src/ui/fx-items.js +6 -6
- package/src/ui/fx-output.js +37 -1
- package/src/ui/fx-repeat.js +1065 -103
- package/src/ui/fx-repeatitem.js +4 -1
- package/src/ui/fx-switch.js +116 -3
- package/src/ui/fx-trigger.js +9 -4
- package/src/ui/fx-upload.js +10 -4
- package/src/ui/repeat-base.js +20 -12
- package/src/withDraggability.js +10 -1
- package/src/xpath-evaluation.js +30 -18
- package/src/xpath-path.js +122 -0
- package/src/xpath-util.js +11 -126
- package/src/actions/StringTpl.js +0 -17
- package/src/extract-predicate-deps.js +0 -57
- package/src/extractPredicateDependencies.js +0 -36
- package/src/json/lensFromNode.js +0 -5
- package/src/json-util.js +0 -27
- package/src/tools/adi.js +0 -1111
- package/src/tools/deprecation.md +0 -1
- package/src/tools/fx-action-log.js +0 -745
- package/src/tools/fx-devtools.js +0 -444
- package/src/tools/fx-dom-inspector.js +0 -610
- package/src/tools/fx-json-instance.js +0 -444
- package/src/tools/fx-log-item.js +0 -128
- package/src/tools/fx-log-settings.js +0 -533
- package/src/tools/fx-minimap.js +0 -203
- package/src/tools/helpers.js +0 -132
- package/src/ui/TemplateExpression.js +0 -12
- package/src/ui/fx-dom-inspector.js +0 -1255
package/src/fx-bind.js
CHANGED
|
@@ -291,7 +291,12 @@ export class FxBind extends ForeElementMixin {
|
|
|
291
291
|
}
|
|
292
292
|
|
|
293
293
|
refs.forEach(ref => {
|
|
294
|
-
|
|
294
|
+
// `ref` may live in a different fx-instance than this bind's own `ref` (e.g. a
|
|
295
|
+
// `relevant` expression that reads `instance('other')/...`). Canonicalize its path
|
|
296
|
+
// using ITS OWN instance id, not the bind's -- reusing the bind's id here mislabels
|
|
297
|
+
// the dependency so the "changed" subgraph walk in recalculate() never finds it again.
|
|
298
|
+
const refInstanceId = this.model.getInstanceIdForNode?.(ref) ?? instanceId;
|
|
299
|
+
const otherPath = getPath(ref, refInstanceId);
|
|
295
300
|
|
|
296
301
|
// keep old XML-only hack
|
|
297
302
|
if (this.bindType === 'xml' && otherPath.endsWith('text()[1]')) return;
|
|
@@ -477,22 +482,34 @@ export class FxBind extends ForeElementMixin {
|
|
|
477
482
|
}
|
|
478
483
|
|
|
479
484
|
/**
|
|
480
|
-
* Get the nodes that are referred by the given XPath expression
|
|
485
|
+
* Get the nodes that are referred by the given XPath expression, as evaluated against a
|
|
486
|
+
* single context node.
|
|
481
487
|
*
|
|
482
488
|
* @param {string} propertyExpr The XPath to get the referenced nodes from
|
|
489
|
+
* @param {Node} node The context node to evaluate propertyExpr against
|
|
483
490
|
*
|
|
484
491
|
* @return {Node[]} The nodes that are referenced by the XPath
|
|
485
492
|
*
|
|
486
493
|
* todo: DependencyNotifyingDomFacade reports back too much in some cases like 'a[1]' and 'a[1]/text[1]'
|
|
487
494
|
*/
|
|
488
|
-
_getReferencesForProperty(propertyExpr) {
|
|
495
|
+
_getReferencesForProperty(propertyExpr, node) {
|
|
489
496
|
if (propertyExpr) {
|
|
490
|
-
return this.getReferences(propertyExpr);
|
|
497
|
+
return this.getReferences(propertyExpr, node);
|
|
491
498
|
}
|
|
492
499
|
return [];
|
|
493
500
|
}
|
|
494
501
|
|
|
495
|
-
|
|
502
|
+
/**
|
|
503
|
+
* NOTE: `node` must be the single row/context node this call is being made for (see
|
|
504
|
+
* _buildBindGraph()'s per-node loop) - NOT this.nodeset as a whole. Evaluating against the
|
|
505
|
+
* whole nodeset here previously meant that, for a bind matching N nodes (eg. N genericode
|
|
506
|
+
* `Row`s), building the dependency graph re-evaluated propertyExpr against all N nodes once
|
|
507
|
+
* per node in the outer loop - an O(N^2) blowup (802 Rows => ~643k evaluations) that measured
|
|
508
|
+
* at several seconds for UNTDID 1001 in demo/codelists/codelist-editor.html. It was also
|
|
509
|
+
* wrong: the touched-nodes set leaked in dependencies from every other node's evaluation, not
|
|
510
|
+
* just this node's own.
|
|
511
|
+
*/
|
|
512
|
+
getReferences(propertyExpr, node) {
|
|
496
513
|
// For XML, DependencyNotifyingDomFacade reliably reports the nodes touched during evaluation.
|
|
497
514
|
// For JSON lens nodes, the domFacade hook does not fire (evaluation goes through our lens resolver),
|
|
498
515
|
// so we must extract lookup tokens and resolve them explicitly.
|
|
@@ -504,16 +521,13 @@ export class FxBind extends ForeElementMixin {
|
|
|
504
521
|
const touchedNodes = new Set();
|
|
505
522
|
const tokens = this._extractJsonLookupTokens(propertyExpr);
|
|
506
523
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
// ignore: dependency extraction must never break bind initialization
|
|
515
|
-
}
|
|
516
|
-
});
|
|
524
|
+
tokens.forEach(token => {
|
|
525
|
+
try {
|
|
526
|
+
const refs = evaluateXPathToNodes(token, node, this);
|
|
527
|
+
refs.forEach(r => touchedNodes.add(r));
|
|
528
|
+
} catch (_e) {
|
|
529
|
+
// ignore: dependency extraction must never break bind initialization
|
|
530
|
+
}
|
|
517
531
|
});
|
|
518
532
|
|
|
519
533
|
return Array.from(touchedNodes.values());
|
|
@@ -522,9 +536,7 @@ export class FxBind extends ForeElementMixin {
|
|
|
522
536
|
// XML path: use dom facade for accurate dependency tracking
|
|
523
537
|
const touchedNodes = new Set();
|
|
524
538
|
const domFacade = new DependencyNotifyingDomFacade(otherNode => touchedNodes.add(otherNode));
|
|
525
|
-
|
|
526
|
-
evaluateXPathToString(propertyExpr, node, this, domFacade);
|
|
527
|
-
});
|
|
539
|
+
evaluateXPathToString(propertyExpr, node, this, domFacade);
|
|
528
540
|
return Array.from(touchedNodes.values());
|
|
529
541
|
}
|
|
530
542
|
_extractJsonLookupTokens(expr) {
|
package/src/fx-fore.js
CHANGED
|
@@ -2,15 +2,12 @@ import { Fore } from './fore.js';
|
|
|
2
2
|
import './fx-instance.js';
|
|
3
3
|
import { FxModel } from './fx-model.js';
|
|
4
4
|
import '@jinntec/jinn-toast';
|
|
5
|
-
import {
|
|
6
|
-
evaluateXPathToNodes,
|
|
7
|
-
evaluateXPathToString,
|
|
8
|
-
createNamespaceResolver,
|
|
9
|
-
} from './xpath-evaluation.js';
|
|
5
|
+
import { evaluateXPathToNodes, evaluateXPathToString } from './xpath-evaluation.js';
|
|
10
6
|
import getInScopeContext from './getInScopeContext.js';
|
|
11
7
|
import { XPathUtil } from './xpath-util.js';
|
|
12
8
|
import { FxRepeatAttributes } from './ui/fx-repeat-attributes.js';
|
|
13
9
|
import { FxBind } from './fx-bind.js';
|
|
10
|
+
import createNodes from './createNodes.js';
|
|
14
11
|
|
|
15
12
|
/**
|
|
16
13
|
* Makes the dirty state of the form.
|
|
@@ -24,21 +21,6 @@ const dirtyStates = {
|
|
|
24
21
|
CLEAN: 'clean',
|
|
25
22
|
DIRTY: 'dirty',
|
|
26
23
|
};
|
|
27
|
-
async function waitForFunctionLibs(rootEl) {
|
|
28
|
-
const libs = Array.from(rootEl.querySelectorAll('fx-functionlib'));
|
|
29
|
-
await Promise.all(libs.map(l => (l.readyPromise ? l.readyPromise : Promise.resolve())));
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/*
|
|
33
|
-
* Determine whether a string is a valid Name
|
|
34
|
-
*
|
|
35
|
-
* @param {string} name
|
|
36
|
-
* @returns {boolean} whether the name is a valid one
|
|
37
|
-
*/
|
|
38
|
-
function isValidName(name) {
|
|
39
|
-
const result = new DOMParser().parseFromString(`<${name}/>`, 'application/xml');
|
|
40
|
-
return result.querySelector('parsererror') === null;
|
|
41
|
-
}
|
|
42
24
|
|
|
43
25
|
/**
|
|
44
26
|
* Main class for Fore.Outermost container element for each Fore application.
|
|
@@ -347,7 +329,7 @@ export class FxFore extends HTMLElement {
|
|
|
347
329
|
this.initialRun = true;
|
|
348
330
|
this._scanForNewTemplateExpressionsNextRefresh = false;
|
|
349
331
|
this.repeatsFromAttributesCreated = false;
|
|
350
|
-
/*
|
|
332
|
+
/*
|
|
351
333
|
this.validateOn = this.hasAttribute('validate-on')
|
|
352
334
|
? this.getAttribute('validate-on')
|
|
353
335
|
: 'update';
|
|
@@ -392,11 +374,11 @@ export class FxFore extends HTMLElement {
|
|
|
392
374
|
getDebugSnapshot(options = {}) {
|
|
393
375
|
const model = this.model;
|
|
394
376
|
|
|
395
|
-
const debugRefElements = Array.from(this.querySelectorAll('[ref]'))
|
|
396
|
-
|
|
377
|
+
const debugRefElements = Array.from(this.querySelectorAll('[ref]')).filter(
|
|
378
|
+
element => typeof element.getDebugInfo === 'function',
|
|
379
|
+
);
|
|
397
380
|
|
|
398
|
-
const bindingElements = debugRefElements
|
|
399
|
-
.filter(element => element.localName === 'fx-bind');
|
|
381
|
+
const bindingElements = debugRefElements.filter(element => element.localName === 'fx-bind');
|
|
400
382
|
|
|
401
383
|
const boundElementNames = new Set([
|
|
402
384
|
'fx-control',
|
|
@@ -407,31 +389,32 @@ export class FxFore extends HTMLElement {
|
|
|
407
389
|
'fx-switch',
|
|
408
390
|
]);
|
|
409
391
|
|
|
410
|
-
const boundUiElements = debugRefElements
|
|
411
|
-
|
|
392
|
+
const boundUiElements = debugRefElements.filter(element =>
|
|
393
|
+
boundElementNames.has(element.localName),
|
|
394
|
+
);
|
|
412
395
|
|
|
413
396
|
const bindings = bindingElements.map(element => element.getDebugInfo());
|
|
414
397
|
|
|
415
398
|
const boundElements = boundUiElements.map(element => element.getDebugInfo());
|
|
416
399
|
|
|
417
400
|
const submissions = Array.from(this.querySelectorAll('fx-submission'))
|
|
418
|
-
|
|
419
|
-
|
|
401
|
+
.filter(element => typeof element.getDebugInfo === 'function')
|
|
402
|
+
.map(element => element.getDebugInfo());
|
|
420
403
|
|
|
421
404
|
return {
|
|
422
405
|
fore: this.getDebugInfo?.() || null,
|
|
423
406
|
|
|
424
407
|
model:
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
408
|
+
model?.getDebugInfo?.({
|
|
409
|
+
includeGraphs: options.includeGraphs === true,
|
|
410
|
+
}) || null,
|
|
428
411
|
|
|
429
412
|
instances: model?.instances?.map(instance => instance.getDebugInfo?.()) || [],
|
|
430
413
|
modelItems: model?.modelItems?.map(item => item.getDebugInfo?.()) || [],
|
|
431
414
|
|
|
432
415
|
bindings,
|
|
433
416
|
boundElements,
|
|
434
|
-
submissions
|
|
417
|
+
submissions,
|
|
435
418
|
};
|
|
436
419
|
}
|
|
437
420
|
|
|
@@ -710,6 +693,11 @@ export class FxFore extends HTMLElement {
|
|
|
710
693
|
registerVariables(child);
|
|
711
694
|
}
|
|
712
695
|
})(this);
|
|
696
|
+
// Model-internal evaluations (calculate and other facets) pass the fx-model as
|
|
697
|
+
// scope element to evaluateXPath — hand it the registry so model variables
|
|
698
|
+
// resolve there. FxModel does not extend ForeElementMixin, so it would
|
|
699
|
+
// otherwise fall through to implicit instance bindings only.
|
|
700
|
+
modelElement.inScopeVariables = variables;
|
|
713
701
|
|
|
714
702
|
// Ensure all function libraries are loaded/registered before model construction,
|
|
715
703
|
// so binds/calculate/XPath evaluations can safely call them.
|
|
@@ -830,6 +818,65 @@ export class FxFore extends HTMLElement {
|
|
|
830
818
|
if (this.hasAttribute('show-confirmation')) {
|
|
831
819
|
this.showConfirmation = true;
|
|
832
820
|
}
|
|
821
|
+
if (this.hasAttribute('keyboard-shortcuts')) {
|
|
822
|
+
// opt-in: overrides native text-field undo within the form.
|
|
823
|
+
// Listens on document (not this element) so the shortcuts keep working when
|
|
824
|
+
// focus fell back to the body, e.g. right after an undo re-rendered the
|
|
825
|
+
// previously focused control.
|
|
826
|
+
this._undoRedoKeyListener = e => {
|
|
827
|
+
const target = e.composedPath ? e.composedPath()[0] : e.target;
|
|
828
|
+
if (
|
|
829
|
+
this.contains(target) ||
|
|
830
|
+
target === document.body ||
|
|
831
|
+
target === document.documentElement
|
|
832
|
+
) {
|
|
833
|
+
this._handleUndoRedoKeys(e);
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
document.addEventListener('keydown', this._undoRedoKeyListener);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Commits any in-progress widget edit by blurring the focused element. Called before
|
|
842
|
+
* undo/redo so pending typing becomes its own undo step and no stale widget value
|
|
843
|
+
* can fire a late commit against the restored state (which would clear the redo stack).
|
|
844
|
+
*/
|
|
845
|
+
flushPendingWidgetEdit() {
|
|
846
|
+
const active = document.activeElement;
|
|
847
|
+
if (active && active !== document.body && this.contains(active)) {
|
|
848
|
+
active.blur();
|
|
849
|
+
// In a window without OS focus (background window, e.g. a windowed test run),
|
|
850
|
+
// blur() does not dispatch a 'blur' event — nothing is really focused at the OS
|
|
851
|
+
// level — so fx-control's commit listener never runs and the pending edit is
|
|
852
|
+
// silently lost. Dispatch a synthetic blur to commit it; harmless if redundant
|
|
853
|
+
// (an unchanged value discards its undo capture).
|
|
854
|
+
if (!document.hasFocus()) {
|
|
855
|
+
active.dispatchEvent(new FocusEvent('blur'));
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Ctrl/Cmd+Z -> undo, Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y -> redo.
|
|
862
|
+
* Only active when the `keyboard-shortcuts` attribute is present.
|
|
863
|
+
*/
|
|
864
|
+
async _handleUndoRedoKeys(e) {
|
|
865
|
+
if (!(e.ctrlKey || e.metaKey) || !this.model) return;
|
|
866
|
+
const key = e.key.toLowerCase();
|
|
867
|
+
const isRedo = (key === 'z' && e.shiftKey) || key === 'y';
|
|
868
|
+
if (!isRedo && key !== 'z') return;
|
|
869
|
+
e.preventDefault();
|
|
870
|
+
|
|
871
|
+
this.flushPendingWidgetEdit();
|
|
872
|
+
const done = isRedo ? this.model.redo() : this.model.undo();
|
|
873
|
+
if (!done) return;
|
|
874
|
+
this.model.updateModel();
|
|
875
|
+
await this.refresh(true);
|
|
876
|
+
Fore.dispatch(this, isRedo ? 'redo-done' : 'undo-done', {
|
|
877
|
+
canUndo: this.model.canUndo(),
|
|
878
|
+
canRedo: this.model.canRedo(),
|
|
879
|
+
});
|
|
833
880
|
}
|
|
834
881
|
|
|
835
882
|
/**
|
|
@@ -906,17 +953,13 @@ export class FxFore extends HTMLElement {
|
|
|
906
953
|
}
|
|
907
954
|
|
|
908
955
|
_injectDevtools() {
|
|
909
|
-
if (this.ownerDocument.querySelector('fx-
|
|
910
|
-
// There's already a
|
|
911
|
-
// One
|
|
956
|
+
if (this.ownerDocument.querySelector('fx-lens')) {
|
|
957
|
+
// There's already a lens, so we can ignore this one.
|
|
958
|
+
// One lens can focus multiple fore elements
|
|
912
959
|
return;
|
|
913
960
|
}
|
|
914
961
|
const { search } = window.location;
|
|
915
962
|
const urlParams = new URLSearchParams(search);
|
|
916
|
-
if (urlParams.has('inspect')) {
|
|
917
|
-
const devtools = document.createElement('fx-devtools');
|
|
918
|
-
document.body.appendChild(devtools);
|
|
919
|
-
}
|
|
920
963
|
if (urlParams.has('lens')) {
|
|
921
964
|
const lens = document.createElement('fx-lens');
|
|
922
965
|
document.body.appendChild(lens);
|
|
@@ -931,6 +974,7 @@ export class FxFore extends HTMLElement {
|
|
|
931
974
|
* @param {string} localNameOfElement
|
|
932
975
|
*/
|
|
933
976
|
signalChangeToElement(localNameOfElement) {
|
|
977
|
+
if (typeof localNameOfElement !== 'string' || !localNameOfElement) return;
|
|
934
978
|
this._localNamesWithChanges.add(localNameOfElement);
|
|
935
979
|
}
|
|
936
980
|
|
|
@@ -1015,6 +1059,10 @@ export class FxFore extends HTMLElement {
|
|
|
1015
1059
|
|
|
1016
1060
|
disconnectedCallback() {
|
|
1017
1061
|
this.removeEventListener('dragstart', this.dragstart);
|
|
1062
|
+
if (this._undoRedoKeyListener) {
|
|
1063
|
+
document.removeEventListener('keydown', this._undoRedoKeyListener);
|
|
1064
|
+
this._undoRedoKeyListener = null;
|
|
1065
|
+
}
|
|
1018
1066
|
/*
|
|
1019
1067
|
this.removeEventListener('model-construct-done', this._handleModelConstructDone);
|
|
1020
1068
|
this.removeEventListener('message', this._displayMessage);
|
|
@@ -1061,11 +1109,23 @@ export class FxFore extends HTMLElement {
|
|
|
1061
1109
|
if (isFullRefresh) {
|
|
1062
1110
|
performance.mark('force-refresh-start');
|
|
1063
1111
|
console.log('🔄 🔴🔴🔴 ### full refresh() on ', this);
|
|
1112
|
+
// A full refresh re-evaluates every ref anyway — pending structural-change
|
|
1113
|
+
// signals are consumed by it.
|
|
1114
|
+
this._localNamesWithChanges.clear();
|
|
1064
1115
|
await Fore.refreshChildren(this, force);
|
|
1065
1116
|
performance.mark('force-refresh-end');
|
|
1066
1117
|
performance.measure('force-refresh', 'force-refresh-start', 'force-refresh-end');
|
|
1067
1118
|
} else {
|
|
1119
|
+
// Evaluate UI variables synchronously so the batched-notification drain below
|
|
1120
|
+
// still starts in the same tick as refresh() itself — sync listeners (e.g.
|
|
1121
|
+
// action-performed) depend on that ordering.
|
|
1122
|
+
const variableConsumerRefreshes = this._refreshUIVariables();
|
|
1123
|
+
const structuralConsumerRefreshes = this._refreshStructuralDependents();
|
|
1068
1124
|
await this._processBatchedNotifications();
|
|
1125
|
+
const consumerRefreshes = [...variableConsumerRefreshes, ...structuralConsumerRefreshes];
|
|
1126
|
+
if (consumerRefreshes.length > 0) {
|
|
1127
|
+
await Promise.all(consumerRefreshes);
|
|
1128
|
+
}
|
|
1069
1129
|
}
|
|
1070
1130
|
|
|
1071
1131
|
if (force === true || this.initialRun || this._scanForNewTemplateExpressionsNextRefresh) {
|
|
@@ -1079,12 +1139,6 @@ export class FxFore extends HTMLElement {
|
|
|
1079
1139
|
this.initialRun = false;
|
|
1080
1140
|
this.style.visibility = 'visible';
|
|
1081
1141
|
|
|
1082
|
-
console.info(
|
|
1083
|
-
`%c ✅ refresh-done on #${this.id}`,
|
|
1084
|
-
'background:darkorange; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
|
|
1085
|
-
this.getModel().modelItems,
|
|
1086
|
-
);
|
|
1087
|
-
|
|
1088
1142
|
// Record timing before dispatching 'refresh-done' since listeners (eg. fx-debugger)
|
|
1089
1143
|
// may synchronously read this.debugInfo.lastRefresh in response to the event.
|
|
1090
1144
|
this.debugInfo.lastRefresh = {
|
|
@@ -1126,6 +1180,105 @@ export class FxFore extends HTMLElement {
|
|
|
1126
1180
|
}
|
|
1127
1181
|
}
|
|
1128
1182
|
|
|
1183
|
+
/**
|
|
1184
|
+
* XForms 2.0: variables outside a model and not within an action are evaluated in
|
|
1185
|
+
* document order during a refresh. The full-refresh path covers them through the
|
|
1186
|
+
* Fore.refreshChildren traversal; this pass covers partial refreshes, where fx-var
|
|
1187
|
+
* elements were previously never re-evaluated (stale `$var` values).
|
|
1188
|
+
*
|
|
1189
|
+
* When a variable's value changed at this evaluation point, elements whose ref
|
|
1190
|
+
* expressions reference it are refreshed. This is invalidation at the evaluation
|
|
1191
|
+
* point — variables themselves are never re-evaluated reactively (spec: deletes and
|
|
1192
|
+
* predicate changes do not affect a variable until its next evaluation point).
|
|
1193
|
+
*
|
|
1194
|
+
* Implicit per-instance bindings ($default / $<instance-id>) are not fx-var elements
|
|
1195
|
+
* and are never touched here.
|
|
1196
|
+
*
|
|
1197
|
+
* Synchronous by design — refresh() must reach the batched-notification drain in the
|
|
1198
|
+
* same tick. Returns the consumer refresh() promises for the caller to await after
|
|
1199
|
+
* the drain.
|
|
1200
|
+
*
|
|
1201
|
+
* @returns {Promise[]}
|
|
1202
|
+
* @private
|
|
1203
|
+
*/
|
|
1204
|
+
_refreshUIVariables() {
|
|
1205
|
+
const changedNames = [];
|
|
1206
|
+
this.querySelectorAll('fx-var').forEach(variable => {
|
|
1207
|
+
// Nested subforms evaluate their own variables
|
|
1208
|
+
if (variable.closest('fx-fore') !== this) return;
|
|
1209
|
+
// Model variables get their evaluation point in the model update cycle
|
|
1210
|
+
if (variable.closest('fx-model')) return;
|
|
1211
|
+
// Action-scoped variables are evaluated once per action execution
|
|
1212
|
+
for (let anc = variable.parentElement; anc && anc !== this; anc = anc.parentElement) {
|
|
1213
|
+
if (Fore.isActionElement(anc.nodeName)) return;
|
|
1214
|
+
}
|
|
1215
|
+
if (typeof variable.refreshAndReportChange !== 'function') return;
|
|
1216
|
+
if (variable.refreshAndReportChange()) {
|
|
1217
|
+
changedNames.push(variable.name);
|
|
1218
|
+
}
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
if (changedNames.length === 0) return [];
|
|
1222
|
+
|
|
1223
|
+
const refreshPromises = [];
|
|
1224
|
+
// [value] too: fx-output's value attribute is evaluated outside evalInContext and
|
|
1225
|
+
// never reaches el.dependencies, so the raw attribute text is scanned as well.
|
|
1226
|
+
this.querySelectorAll('[ref],[value]').forEach(el => {
|
|
1227
|
+
if (el.closest('fx-fore') !== this) return;
|
|
1228
|
+
if (typeof el.refresh !== 'function') return;
|
|
1229
|
+
// Actions only evaluate during execution; fx-var got its evaluation point above
|
|
1230
|
+
if (Fore.isActionElement(el.nodeName) || el.nodeName === 'FX-VAR') return;
|
|
1231
|
+
const exprs = `${el.getAttribute('ref') ?? ''} ${el.getAttribute('value') ?? ''}`;
|
|
1232
|
+
if (
|
|
1233
|
+
changedNames.some(name => exprs.includes(`$${name}`)) ||
|
|
1234
|
+
(typeof el.dependencies?.isInvalidatedByVariableChange === 'function' &&
|
|
1235
|
+
el.dependencies.isInvalidatedByVariableChange(changedNames))
|
|
1236
|
+
) {
|
|
1237
|
+
refreshPromises.push(el.refresh());
|
|
1238
|
+
}
|
|
1239
|
+
});
|
|
1240
|
+
return refreshPromises;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* Consumes the structural-change signals produced by fx-insert/fx-delete/fx-append/
|
|
1245
|
+
* fx-setattribute (signalChangeToElement). Elements whose ref expressions may be
|
|
1246
|
+
* affected by child-list changes to nodes with the signalled local names are
|
|
1247
|
+
* refreshed — this catches refs that hold no observer on the changed node itself
|
|
1248
|
+
* (e.g. a count() over rows that were inserted or deleted, or an attribute that
|
|
1249
|
+
* did not exist when the ref was last evaluated).
|
|
1250
|
+
*
|
|
1251
|
+
* Pessimistic substring matching (DependentXPathQueries.isInvalidatedByChildlistChanges):
|
|
1252
|
+
* false positives are cheap extra refreshes.
|
|
1253
|
+
*
|
|
1254
|
+
* Synchronous by design, like _refreshUIVariables — returns the consumer refresh()
|
|
1255
|
+
* promises for the caller to await after the batched-notification drain.
|
|
1256
|
+
*
|
|
1257
|
+
* @returns {Promise[]}
|
|
1258
|
+
* @private
|
|
1259
|
+
*/
|
|
1260
|
+
_refreshStructuralDependents() {
|
|
1261
|
+
if (this._localNamesWithChanges.size === 0) return [];
|
|
1262
|
+
const changedNames = Array.from(this._localNamesWithChanges);
|
|
1263
|
+
this._localNamesWithChanges.clear();
|
|
1264
|
+
|
|
1265
|
+
const refreshPromises = [];
|
|
1266
|
+
this.querySelectorAll('[ref]').forEach(el => {
|
|
1267
|
+
// Nested subforms consume their own signals
|
|
1268
|
+
if (el.closest('fx-fore') !== this) return;
|
|
1269
|
+
if (typeof el.refresh !== 'function') return;
|
|
1270
|
+
// Actions only evaluate during execution; fx-var snapshots are evaluation-point-only
|
|
1271
|
+
if (Fore.isActionElement(el.nodeName) || el.nodeName === 'FX-VAR') return;
|
|
1272
|
+
if (
|
|
1273
|
+
typeof el.dependencies?.isInvalidatedByChildlistChanges === 'function' &&
|
|
1274
|
+
el.dependencies.isInvalidatedByChildlistChanges(changedNames)
|
|
1275
|
+
) {
|
|
1276
|
+
refreshPromises.push(el.refresh());
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1279
|
+
return refreshPromises;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1129
1282
|
/**
|
|
1130
1283
|
* Process all batched notifications at the end of the refresh phase.
|
|
1131
1284
|
* Async so that all control refresh() calls (which are themselves async) are awaited
|
|
@@ -1154,14 +1307,6 @@ export class FxFore extends HTMLElement {
|
|
|
1154
1307
|
}
|
|
1155
1308
|
refreshPromises.push(uiElement.refresh(true));
|
|
1156
1309
|
}
|
|
1157
|
-
const nonrelevant = Array.from(this.querySelectorAll('[nonrelevant]'));
|
|
1158
|
-
if (nonrelevant) {
|
|
1159
|
-
nonrelevant.forEach(el => {
|
|
1160
|
-
if (el.refresh) {
|
|
1161
|
-
refreshPromises.push(el.refresh());
|
|
1162
|
-
}
|
|
1163
|
-
});
|
|
1164
|
-
}
|
|
1165
1310
|
if (entry.observers) {
|
|
1166
1311
|
entry.observers.forEach(observer => {
|
|
1167
1312
|
if (typeof observer.update === 'function') {
|
|
@@ -1171,6 +1316,21 @@ export class FxFore extends HTMLElement {
|
|
|
1171
1316
|
}
|
|
1172
1317
|
});
|
|
1173
1318
|
|
|
1319
|
+
// Nonrelevant subtrees are skipped by the normal recursive refresh traversal
|
|
1320
|
+
// (Fore.refreshChildren), so they need an explicit refresh() here to pick up any
|
|
1321
|
+
// other pending state changes. This only needs to run once per batch, not once per
|
|
1322
|
+
// entry -- doing it inside the forEach above re-scanned the whole form and re-pushed
|
|
1323
|
+
// a refresh() per nonrelevant element for EVERY batched entry, an O(entries x
|
|
1324
|
+
// nonrelevant) blowup that hangs/crashes the tab once more than a couple hundred
|
|
1325
|
+
// nodes change relevance at once (e.g. a cross-instance fx-bind[relevant] driving a
|
|
1326
|
+
// large fx-repeat from a single filter keystroke).
|
|
1327
|
+
const nonrelevant = Array.from(this.querySelectorAll('[nonrelevant]'));
|
|
1328
|
+
nonrelevant.forEach(el => {
|
|
1329
|
+
if (el.refresh) {
|
|
1330
|
+
refreshPromises.push(el.refresh());
|
|
1331
|
+
}
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1174
1334
|
this._processTemplateExpressions();
|
|
1175
1335
|
this.batchedNotifications.clear();
|
|
1176
1336
|
|
|
@@ -1999,6 +2159,11 @@ export class FxFore extends HTMLElement {
|
|
|
1999
2159
|
}
|
|
2000
2160
|
}
|
|
2001
2161
|
}
|
|
2162
|
+
/**
|
|
2163
|
+
* Create Nodes from an XPath
|
|
2164
|
+
* @param {string} ref
|
|
2165
|
+
* @param {Element} referenceNode
|
|
2166
|
+
*/
|
|
2002
2167
|
_createNodes(ref, referenceNode) {
|
|
2003
2168
|
if (!ref || !referenceNode) return null;
|
|
2004
2169
|
|
|
@@ -2013,6 +2178,8 @@ export class FxFore extends HTMLElement {
|
|
|
2013
2178
|
const ownerDoc =
|
|
2014
2179
|
referenceNode.nodeType === Node.DOCUMENT_NODE ? referenceNode : referenceNode.ownerDocument;
|
|
2015
2180
|
|
|
2181
|
+
if (!ownerDoc) return null;
|
|
2182
|
+
|
|
2016
2183
|
const baseElement =
|
|
2017
2184
|
referenceNode.nodeType === Node.DOCUMENT_NODE
|
|
2018
2185
|
? referenceNode.documentElement
|
|
@@ -2020,152 +2187,7 @@ export class FxFore extends HTMLElement {
|
|
|
2020
2187
|
? referenceNode.ownerElement
|
|
2021
2188
|
: referenceNode;
|
|
2022
2189
|
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
const baseNamespace = baseElement?.namespaceURI || null;
|
|
2026
|
-
const namespaceResolver = createNamespaceResolver(xpath, this);
|
|
2027
|
-
|
|
2028
|
-
const parseName = token => {
|
|
2029
|
-
const raw = token.trim();
|
|
2030
|
-
|
|
2031
|
-
if (raw.startsWith('@')) {
|
|
2032
|
-
const attrToken = raw.slice(1);
|
|
2033
|
-
if (attrToken.startsWith('*:')) {
|
|
2034
|
-
return { isAttribute: true, namespaceURI: null, localName: attrToken.substring(2) };
|
|
2035
|
-
}
|
|
2036
|
-
if (attrToken.includes(':')) {
|
|
2037
|
-
const [prefix, localName] = attrToken.split(':');
|
|
2038
|
-
return {
|
|
2039
|
-
isAttribute: true,
|
|
2040
|
-
namespaceURI: prefix === '*' ? null : namespaceResolver(prefix) || null,
|
|
2041
|
-
localName,
|
|
2042
|
-
};
|
|
2043
|
-
}
|
|
2044
|
-
return { isAttribute: true, namespaceURI: null, localName: attrToken };
|
|
2045
|
-
}
|
|
2046
|
-
|
|
2047
|
-
if (raw.startsWith('*:')) {
|
|
2048
|
-
return { isAttribute: false, namespaceURI: baseNamespace, localName: raw.substring(2) };
|
|
2049
|
-
}
|
|
2050
|
-
if (raw.includes(':')) {
|
|
2051
|
-
const [prefix, localName] = raw.split(':');
|
|
2052
|
-
return {
|
|
2053
|
-
isAttribute: false,
|
|
2054
|
-
namespaceURI: prefix === '*' ? baseNamespace : namespaceResolver(prefix) || baseNamespace,
|
|
2055
|
-
localName,
|
|
2056
|
-
};
|
|
2057
|
-
}
|
|
2058
|
-
return { isAttribute: false, namespaceURI: baseNamespace, localName: raw };
|
|
2059
|
-
};
|
|
2060
|
-
|
|
2061
|
-
const parseStep = step => {
|
|
2062
|
-
const trimmed = step.trim();
|
|
2063
|
-
const nameMatch = trimmed.match(/^([^\[]+)/);
|
|
2064
|
-
const token = nameMatch ? nameMatch[1].trim() : trimmed;
|
|
2065
|
-
const predicates = [];
|
|
2066
|
-
|
|
2067
|
-
const predicateRegex = /\[\s*@([^\]\s=]+)\s*=\s*(['"])(.*?)\2\s*\]/g;
|
|
2068
|
-
let match;
|
|
2069
|
-
while ((match = predicateRegex.exec(trimmed)) !== null) {
|
|
2070
|
-
predicates.push({ name: match[1], value: match[3] });
|
|
2071
|
-
}
|
|
2072
|
-
return { token, predicates };
|
|
2073
|
-
};
|
|
2074
|
-
|
|
2075
|
-
const splitSteps = xpath => {
|
|
2076
|
-
/**
|
|
2077
|
-
* @type {string[]}
|
|
2078
|
-
*/
|
|
2079
|
-
const steps = [];
|
|
2080
|
-
let scratch = '';
|
|
2081
|
-
let isInPredicate = false;
|
|
2082
|
-
for (const char of xpath.split('')) {
|
|
2083
|
-
if (char === '[') {
|
|
2084
|
-
isInPredicate = true;
|
|
2085
|
-
scratch += char;
|
|
2086
|
-
continue;
|
|
2087
|
-
}
|
|
2088
|
-
if (char === ']') {
|
|
2089
|
-
scratch += char;
|
|
2090
|
-
isInPredicate = false;
|
|
2091
|
-
continue;
|
|
2092
|
-
}
|
|
2093
|
-
if (!isInPredicate) {
|
|
2094
|
-
// Just add to the scratch. Do not check for slashes within predicates
|
|
2095
|
-
if (char === '/') {
|
|
2096
|
-
// Consume this path step
|
|
2097
|
-
if (scratch) {
|
|
2098
|
-
steps.push(scratch);
|
|
2099
|
-
}
|
|
2100
|
-
scratch = '';
|
|
2101
|
-
continue;
|
|
2102
|
-
}
|
|
2103
|
-
}
|
|
2104
|
-
scratch += char;
|
|
2105
|
-
}
|
|
2106
|
-
|
|
2107
|
-
if (scratch) {
|
|
2108
|
-
// Flush it
|
|
2109
|
-
steps.push(scratch);
|
|
2110
|
-
}
|
|
2111
|
-
|
|
2112
|
-
return steps;
|
|
2113
|
-
};
|
|
2114
|
-
|
|
2115
|
-
const steps = splitSteps(xpath)
|
|
2116
|
-
.map(step => step.trim())
|
|
2117
|
-
.filter(step => step && step !== '.');
|
|
2118
|
-
|
|
2119
|
-
if (!steps.length) return null;
|
|
2120
|
-
|
|
2121
|
-
let subtreeRoot = null;
|
|
2122
|
-
let current = null;
|
|
2123
|
-
|
|
2124
|
-
for (const rawStep of steps) {
|
|
2125
|
-
const { token, predicates } = parseStep(rawStep);
|
|
2126
|
-
if (!token || token === '.') {
|
|
2127
|
-
continue;
|
|
2128
|
-
}
|
|
2129
|
-
|
|
2130
|
-
const parsed = parseName(token);
|
|
2131
|
-
|
|
2132
|
-
if (!isValidName(parsed.localName)) {
|
|
2133
|
-
// This did not result in a valid name. Stop.
|
|
2134
|
-
console.warn(
|
|
2135
|
-
`Creating node for the XPath ${xpath} failed because the part ${parsed.localName} is not a valid Name.`,
|
|
2136
|
-
);
|
|
2137
|
-
return;
|
|
2138
|
-
}
|
|
2139
|
-
|
|
2140
|
-
if (parsed.isAttribute) {
|
|
2141
|
-
if (!current) {
|
|
2142
|
-
const attr = ownerDoc.createAttribute(parsed.localName);
|
|
2143
|
-
return attr;
|
|
2144
|
-
}
|
|
2145
|
-
current.setAttribute(parsed.localName, '');
|
|
2146
|
-
continue;
|
|
2147
|
-
}
|
|
2148
|
-
|
|
2149
|
-
const element = parsed.namespaceURI
|
|
2150
|
-
? ownerDoc.createElementNS(parsed.namespaceURI, parsed.localName)
|
|
2151
|
-
: ownerDoc.createElement(parsed.localName);
|
|
2152
|
-
|
|
2153
|
-
for (const predicate of predicates) {
|
|
2154
|
-
const attrName = predicate.name.includes(':')
|
|
2155
|
-
? predicate.name.split(':')[1]
|
|
2156
|
-
: predicate.name;
|
|
2157
|
-
element.setAttribute(attrName, predicate.value);
|
|
2158
|
-
}
|
|
2159
|
-
|
|
2160
|
-
if (!subtreeRoot) {
|
|
2161
|
-
subtreeRoot = element;
|
|
2162
|
-
} else {
|
|
2163
|
-
current.appendChild(element);
|
|
2164
|
-
}
|
|
2165
|
-
current = element;
|
|
2166
|
-
}
|
|
2167
|
-
|
|
2168
|
-
return subtreeRoot;
|
|
2190
|
+
return createNodes(ref, baseElement, this);
|
|
2169
2191
|
}
|
|
2170
2192
|
|
|
2171
2193
|
_handleDragStart(event) {
|