@jinntec/fore
Version:
Fore - declarative user interfaces in plain HTML
282 lines (245 loc) • 8.37 kB
JavaScript
import { evaluateXPath, evaluateXPathToBoolean, evaluateXPathToNodes } from './xpath-evaluation';
import { XPathUtil } from './xpath-util';
import getInScopeContext from './getInScopeContext';
/**
* Class for holding ModelItem facets.
*
* A ModelItem annotates nodes that are referred by an fx-bind element with facets for calculation and validation.
* Each bound node in an instance has exactly one ModelItem associated with it.
*
* This refactored version includes observable mechanics and scoped XPath evaluation support.
*/
export class ModelItem {
static READONLY_DEFAULT = false;
static REQUIRED_DEFAULT = false;
static RELEVANT_DEFAULT = true;
static CONSTRAINT_DEFAULT = true;
static TYPE_DEFAULT = 'xs:string';
/**
* @param {string} path - Calculated normalized path expression linking to data
* @param {string} ref - Relative binding expression
* @param {Node} node - The node the 'ref' expression is referring to
* @param {import('./fx-bind').FxBind} bind - The fx-bind element having created this ModelItem
* @param {string} instance - The fx-instance id having created this ModelItem
* @param {import('./fx-fore').FxFore} fore - The fx-fore element this ModelItem belongs to
*/
constructor(path, ref, nodeOrLens, bind, instance, fore) {
this.path = path;
this.ref = ref;
this.readonly = ModelItem.READONLY_DEFAULT;
this.relevant = ModelItem.RELEVANT_DEFAULT;
this.required = ModelItem.REQUIRED_DEFAULT;
this.constraint = ModelItem.CONSTRAINT_DEFAULT;
this.type = ModelItem.TYPE_DEFAULT;
this.node = null;
this.lens = null;
if (nodeOrLens?.get && nodeOrLens?.set) {
this.lens = nodeOrLens;
} else {
this.node = nodeOrLens;
}
this.bind = bind;
this.instanceId = instance;
this.fore = fore;
this.changed = false;
// console.log('[ModelItem] created:', this.path);
/** @type {import('./ui/fx-alert').FxAlert[]} */
this.alerts = [];
/** @type {import('./ui/abstract-control').default[]} */
// For backward compatibility
this.boundControls = [];
// Observable mechanics
/**
* @type {Set<import('./ui/UIElement').UIElement>}
*/
this.observers = new Set();
this.dependencies = new Set();
this.stateExpressions = {}; // e.g. { required: { expr: '../x', type: 'boolean' } }
this.state = {}; // evaluated expression results
}
get value() {
if (this.lens) return this.lens.get();
if (!this.node) return null;
if (!this.node.nodeType) return this.node;
if (this.node.nodeType === Node.ATTRIBUTE_NODE) {
return this.node.nodeValue;
}
return this.node.textContent;
}
set value(newVal) {
if (this.lens) {
const oldVal = this.lens.get();
this.lens.set(newVal);
if (oldVal !== newVal) this.notify();
return;
}
if (!this.node) return;
const oldVal = this.value;
if (newVal?.nodeType && newVal.nodeType === Node.DOCUMENT_NODE) {
this.node.replaceWith(newVal.firstElementChild);
// this.node.appendChild(newVal.firstElementChild);
} else if (newVal?.nodeType && newVal.nodeType === Node.ELEMENT_NODE) {
this.node.replaceWith(newVal);
// this.node.appendChild(newVal);
} else if (newVal?.nodeType && this.node.nodeType === Node.ATTRIBUTE_NODE) {
this.node.nodeValue = newVal;
} else {
this.node.textContent = newVal;
}
if (this.value !== oldVal) {
this.notify();
}
}
/**
* Add an observer to this ModelItem
* @param {Object} observer - The observer to add
*/
addObserver(observer) {
// console.log('[ModelItem] adding observer:', observer);
this.observers.add(observer);
// For backward compatibility with boundControls
if (
observer.nodeName &&
(observer.nodeName.startsWith('FX-') || observer.nodeName.startsWith('UI-')) &&
!this.boundControls.includes(observer)
) {
this.boundControls.push(observer);
}
}
/**
* Remove an observer from this ModelItem
* @param {Object} observer - The observer to remove
*/
removeObserver(observer) {
this.observers.delete(observer);
// For backward compatibility with boundControls
const index = this.boundControls.indexOf(observer);
if (index !== -1) {
this.boundControls.splice(index, 1);
}
}
/**
* Notify all observers that this ModelItem has changed
*/
notify() {
// Only log in debug mode or reduce verbosity to prevent console flooding
// console.log('[ModelItem] notifying observers for path:', this);
// Add to batched notifications. TODO: is the else needed?
if (this.fore) {
this.fore.addToBatchedNotifications(this);
} else {
// Otherwise, notify observers immediately
if (this.observers) {
this.observers.forEach(observer => {
if (typeof observer.update === 'function') {
observer.update(this);
}
});
}
}
}
update() {
console.log('[ModelItem] update:', this);
this.evaluateStateExpressions();
}
addAlert(alert) {
if (!this.alerts.includes(alert)) {
this.alerts.push(alert);
}
}
cleanAlerts() {
this.alerts = [];
}
/**
* Attach dynamic expressions (fx-bind style) to this model item.
* @param {{ [key: string]: { expr: string, type: 'boolean' | 'value' } }} expressionMap
*/
setStateExpressions(expressionMap) {
this.stateExpressions = expressionMap;
this.resolveDependencies();
this.evaluateStateExpressions();
}
/**
* Register dependencies based on expression references.
*/
resolveDependencies() {
this.dependencies.forEach(dep => dep.removeObserver(this));
this.dependencies.clear();
const refs = Object.values(this.stateExpressions).flatMap(({ expr }) => this.extractRefs(expr));
for (const ref of refs) {
const dep = this.bind?.resolveModelItem?.(ref);
if (dep) {
dep.addObserver(this);
this.dependencies.add(dep);
}
}
}
/**
* Extract ref strings like '../required' => 'required'
* @param {string} expr
* @returns {string[]}
*/
extractRefs(expr) {
const matches = expr.match(/\.\.\/\w+/g);
return matches ? matches.map(s => s.replace('../', '')) : [];
}
/**
* Evaluate all state expressions attached to this model item.
*/
evaluateStateExpressions() {
let changed = false;
for (const [key, { expr, type }] of Object.entries(this.stateExpressions)) {
const result = this.evaluateExpression(expr, type);
if (this.state[key] !== result) {
this.state[key] = result;
changed = true;
}
}
if (changed) this.notify();
}
/**
* Evaluate expression in in-scope context using XPath.
* @param {string} expr
* @param {'boolean' | 'value'} type
* @returns {*}
*/
evaluateExpression(expr, type = 'boolean') {
const contextNodes = this._evalInContext();
if (!contextNodes || contextNodes.length === 0) {
return type === 'boolean' ? false : null;
}
try {
if (type === 'boolean') {
return contextNodes.some(node => evaluateXPathToBoolean(expr, node, this));
} else if (type === 'value') {
return evaluateXPath(expr, contextNodes[0], this);
}
} catch (e) {
console.warn(`Error evaluating XPath expression [${expr}] in context:`, e);
return type === 'boolean' ? false : null;
}
}
/**
* Resolves the in-scope context for this.ref using the bind and returns the nodeset.
* Does NOT mutate this.nodeset or this.node.
* @returns {Node[]}
*/
_evalInContext() {
const refAttrNode = this.bind?.getAttributeNode?.('ref') || this.bind;
const inScopeContext = getInScopeContext(refAttrNode, this.ref);
if (this.ref === '' || this.ref === null) {
return Array.isArray(inScopeContext) ? inScopeContext : [inScopeContext];
}
if (Array.isArray(inScopeContext)) {
if (XPathUtil.isSelfReference(this.ref)) {
return inScopeContext;
}
return inScopeContext.flatMap(n => evaluateXPathToNodes(this.ref, n, this));
}
const instance = this.bind?.getInstance?.(this.instanceId);
if (instance?.type === 'xml') {
return evaluateXPathToNodes(this.ref, inScopeContext, this);
}
return [];
}
}