@jinntec/fore
Version:
Fore - declarative user interfaces in plain HTML
365 lines (334 loc) • 10.8 kB
JavaScript
import { XPathUtil } from './xpath-util.js';
import { FxModel } from './fx-model.js';
import {
evaluateXPath,
evaluateXPathToFirstNode,
evaluateXPathToString,
} from './xpath-evaluation.js';
import getInScopeContext from './getInScopeContext.js';
import { Fore } from './fore.js';
import DependentXPathQueries from './DependentXPathQueries.js';
import { getPath } from './xpath-path.js';
/**
* Mixin containing all general functions that are shared by all Fore element classes.
* @extends {HTMLElement}
*/
export default class ForeElementMixin extends HTMLElement {
static get properties() {
return {
/**
* context object for evaluation
*/
context: {
type: Object,
},
/**
* the model of this element
*/
model: {
type: Object,
},
/**
* The modelitem object associated to the bound node holding the evaluated state.
*/
modelItem: {
type: Object,
},
/**
* the node(s) bound by this element
*/
nodeset: {
type: Object,
},
/**
* XPath binding expression pointing to bound node
*/
ref: {
type: String,
},
inScopeVariables: {
type: Map,
},
};
}
constructor() {
super();
this.context = null;
this.model = null;
this.modelItem = null;
this.ref = this.hasAttribute('ref') ? this.getAttribute('ref') : '';
/**
* @type {Map<string, import('./fx-var.js').FxVariable>}
*/
this.inScopeVariables = new Map();
this.dependencies = new DependentXPathQueries();
this.ownerForm = null;
}
connectedCallback() {
if (this.parentElement) {
this.dependencies.setParentDependencies(this.parentElement?.closest('[ref]')?.dependencies);
}
// The fx-model linked to here won't ever change
this.model = this.getModel();
this.ownerForm = this.getOwnerForm();
}
/**
* @returns {import('./fx-model.js').FxModel}
*/
getModel() {
// console.log('getModel this ', this);
if (this.model) {
return this.model;
}
// const ownerForm = this.closest('fx-fore');
// const ownerForm = this.getOwnerForm(this);
const ownerForm = this.getOwnerForm();
return ownerForm.querySelector('fx-model');
}
/**
*
* @returns {import('./fx-fore.js').FxFore} The fx-fore element associated with this form node
*/
getOwnerForm() {
if (this.ownerForm) {
return this.ownerForm;
}
let currentElement = this;
while (currentElement && currentElement.parentNode) {
// console.log('current ', currentElement);
if (currentElement.nodeName.toUpperCase() === 'FX-FORE') {
return currentElement;
}
if (currentElement.parentNode instanceof DocumentFragment) {
currentElement = currentElement.parentNode.host;
} else {
currentElement = currentElement.parentNode;
}
}
return null;
}
/**
* evaluation of fx-bind and UiElements differ in details so that each class needs it's own implementation.
*/
evalInContext() {
this.dependencies.resetDependencies();
// const inscopeContext = this.getInScopeContext();
const model = this.getModel();
if (!model) {
return;
}
let inscopeContext;
if (this.hasAttribute('context')) {
inscopeContext = getInScopeContext(this.getAttributeNode('context') || this, this.context);
}
if (this.hasAttribute('ref')) {
inscopeContext = getInScopeContext(this.getAttributeNode('ref') || this, this.ref);
this.dependencies.addXPath(this.ref);
}
if (!inscopeContext && this.getModel().instances.length !== 0) {
// ### always fall back to default context with there's neither a 'context' or 'ref' present
inscopeContext = this.getModel().getDefaultInstance().getDefaultContext();
// console.warn('no in scopeContext for ', this);
// console.warn('using default context ', this);
// return;
}
if (this.ref === '') {
this.nodeset = inscopeContext;
} else if (Array.isArray(inscopeContext)) {
/*
inscopeContext.forEach(n => {
if (XPathUtil.isSelfReference(this.ref)) {
this.nodeset = inscopeContext;
} else {
const localResult = evaluateXPathToFirstNode(this.ref, n, this);
// console.log('local result: ', localResult);
this.nodeset.push(localResult);
}
});
*/
// this.nodeset = evaluateXPathToFirstNode(this.ref, inscopeContext[0], this);
this.nodeset = evaluateXPath(this.ref, inscopeContext[0], this);
} else {
// this.nodeset = fx.evaluateXPathToFirstNode(this.ref, inscopeContext, null, {namespaceResolver: this.namespaceResolver});
if (!inscopeContext) return;
if (this.nodeName === 'FX-REPEAT') {
// Repeats are special: they have multiple nodes in their nodeset
this.nodeset = evaluateXPath(this.ref, inscopeContext, this);
} else {
this.nodeset = evaluateXPath(this.ref, inscopeContext, this)[0] || null;
}
}
// console.log('UiElement evaluated to nodeset: ', this.nodeset);
}
/**
* resolves template expressions for a single attribute
* @param {string} expr an attribute value containing curly brackets containing XPath expressions to evaluate
* @param {Node} node the attribute node used for scoped resolution
* @returns {string}
* @protected
*/
evaluateAttributeTemplateExpression(expr, node) {
const matches = expr.match(/{[^}]*}/g);
if (matches) {
matches.forEach(match => {
// console.log('match ', match);
const naked = match.substring(1, match.length - 1);
const inscope = getInScopeContext(node, naked);
const result = evaluateXPathToString(naked, inscope, this.getOwnerForm());
const replaced = expr.replaceAll(match, result);
// console.log('replacing ', expr, ' with ', replaced);
expr = replaced;
});
}
return expr;
}
isNotBound() {
return !this.hasAttribute('ref');
}
isBound() {
return this.hasAttribute('ref');
}
getBindingExpr() {
if (this.hasAttribute('ref')) {
return this.getAttribute('ref');
}
// try to get closest parent bind
const parent = XPathUtil.getClosest('[ref]', this.parentNode);
if (!parent) {
return 'instance()'; // the default instance
}
return parent.getAttribute('ref');
}
/**
* @returns {import('./fx-instance.js').FxInstance}
*/
getInstance() {
if (this.ref.startsWith('instance(')) {
const instId = XPathUtil.getInstanceId(this.ref);
return this.getModel().getInstance(instId);
}
return this.getModel().getInstance('default');
}
_getParentBindingElement(start) {
if (start.parentNode.host) {
const { host } = start.parentNode;
if (host.hasAttribute('ref')) {
return host;
}
} else if (start.parentNode) {
if (start.parentNode.hasAttribute('ref')) {
return this.parentNode;
}
this._getParentBindingElement(this.parentNode);
}
return null;
}
/**
* @returns {import('./modelitem.js').ModelItem}
*/
/**
* @returns {import('./modelitem.js').ModelItem}
*/
getModelItem() {
if (!this.getModel()) return null;
const model = this.getModel();
// Resolve the effective bound node for repeated contexts
const repeated = XPathUtil.getClosest('fx-repeatitem', this);
let effectiveNode = this.nodeset;
if (repeated) {
const { index } = repeated;
if (Array.isArray(effectiveNode)) {
effectiveNode = effectiveNode[index - 1];
}
}
// 1) Try exact lookup by node OR lens object (model.getModelItem was updated earlier)
let existed = effectiveNode ? model.getModelItem(effectiveNode) : null;
if (existed) {
this.modelItem = existed;
return existed;
}
// 2) Try lookup by canonical path (XML + JSON)
const instanceId = XPathUtil.resolveInstance(this, this.ref);
// Normalize XML text node -> parent
let targetNode = effectiveNode;
if (targetNode?.nodeType === Node.TEXT_NODE) targetNode = targetNode.parentNode;
let path = null;
// XML node path
if (targetNode?.nodeType) {
path = getPath(targetNode, instanceId);
}
// JSON lens node path (preferred)
else if (targetNode?.__jsonlens__ && typeof targetNode.getPath === 'function') {
// JSONNode.getPath() already returns the canonical path you want
path = targetNode.getPath();
}
// As a last resort: try getPath() util for JSON lens nodes if it supports them
else if (targetNode?.__jsonlens__) {
try {
path = getPath(targetNode, instanceId);
} catch (_e) {
// ignore
}
}
if (path) {
existed = model.modelItems.find(item => item.path === path) || null;
if (existed) {
// CRITICAL: retarget existing ModelItem to the current backing object
const isLensObject =
targetNode &&
typeof targetNode === 'object' &&
typeof targetNode.get === 'function' &&
typeof targetNode.set === 'function';
if (isLensObject) {
existed.lens = targetNode;
existed.node = null;
} else {
existed.node = targetNode;
existed.lens = null;
}
this.modelItem = existed;
return existed;
}
}
// 3) Not found: lazily create (lazyCreateModelItem now dedupes/retargets by path)
const lazyCreatedModelItem = FxModel.lazyCreateModelItem(model, this.ref, effectiveNode, this);
this.modelItem = lazyCreatedModelItem;
return lazyCreatedModelItem;
}
/**
* Returns the effective value for the element.
* a: look for 'value' attribute and if present evaluate it and return the resulting value
* b: look for textContent and return the value if present
* c: return null
* @returns {string}
*/
getValue() {
if (this.hasAttribute('value')) {
const valAttr = this.getAttribute('value');
try {
const inscopeContext = getInScopeContext(this, valAttr);
return evaluateXPathToString(valAttr, inscopeContext, this.getOwnerForm());
} catch (error) {
console.error(error);
Fore.dispatch(this, 'error', { message: error });
}
}
if (this.textContent) {
return this.textContent;
}
return null;
}
/**
* @returns {Node}
*/
getInScopeContext() {
return getInScopeContext(this.getAttributeNode('ref') || this, this.ref);
}
/**
* Set variables in scope here
* @param {Map} inScopeVariables
*/
setInScopeVariables(inScopeVariables) {
this.inScopeVariables = inScopeVariables;
}
}