UNPKG

@jinntec/fore

Version:

Fore - declarative user interfaces in plain HTML

576 lines (521 loc) 20.2 kB
import { DepGraph } from './dep_graph.js'; import { Fore } from './fore.js'; import './fx-instance.js'; import { ModelItem } from './modelitem.js'; import { evaluateXPath, evaluateXPathToBoolean } from './xpath-evaluation.js'; import { XPathUtil } from './xpath-util.js'; /** * The model of this Fore scope. It holds all the intances, binding, submissions and custom functions that * as required. * * The model is updatin by executing rebuild (as needed), recalculate and revalidate in turn. * * After the cycle is run all modelItems have updated their stete to reflect latest computations. * */ export class FxModel extends HTMLElement { static dataChanged = false; constructor() { super(); // this.id = ''; /** * @type {import('./fx-instance.js').FxInstance[]} */ this.instances = []; /** * @type {import('./modelitem.js').ModelItem[]} */ this.modelItems = []; this.defaultContext = {}; this.changed = []; // this.mainGraph = new DepGraph(false); this.inited = false; this.modelConstructed = false; this.attachShadow({ mode: 'open' }); this.computes = 0; this.fore = {}; } get formElement() { return this.parentElement; } connectedCallback() { // console.log('connectedCallback ', this); this.setAttribute('inert', 'true'); this.shadowRoot.innerHTML = ` <slot></slot> `; /* this.addEventListener('model-construct-done', () => { // this.modelConstructed = true; // console.log('model-construct-done fired ', this.modelConstructed); // console.log('model-construct-done fired ', e.detail.model.instances); }, { once: true }, ); */ this.skipUpdate = false; this.fore = this.parentNode; } /** * @param {FxModel} model The model to create a model item for * @param {string} ref The XPath ref that led to this model item * @param {Node} node The node the XPath led to * @param {ForeElementMixin} formElement The form element making this model. Used to resolve variables against */ static lazyCreateModelItem(model, ref, node, formElement) { // console.log('lazyCreateModelItem ', node); const instanceId = XPathUtil.resolveInstance(formElement, ref); if (model.parentNode.createNodes && (node === null || node === undefined)) { // ### intializing ModelItem with default values (as there is no <fx-bind> matching for given ref) const mi = new ModelItem( undefined, ref, Fore.READONLY_DEFAULT, false, Fore.REQUIRED_DEFAULT, Fore.CONSTRAINT_DEFAULT, Fore.TYPE_DEFAULT, null, this, instanceId, ); // console.log('new ModelItem is instanceof ModelItem ', mi instanceof ModelItem); model.registerModelItem(mi); return mi; } let targetNode = {}; if (node === null || node === undefined) return null; if (node.nodeType === Node.TEXT_NODE) { // const parent = node.parentNode; // console.log('PARENT ', parent); targetNode = node.parentNode; } else { targetNode = node; } // const path = fx.evaluateXPath('path()',node); let path; if (node.nodeType) { path = XPathUtil.getPath(node, instanceId); } else { path = null; targetNode = node; } // const path = XPathUtil.getPath(node); // ### intializing ModelItem with default values (as there is no <fx-bind> matching for given ref) const mi = new ModelItem( path, ref, Fore.READONLY_DEFAULT, Fore.RELEVANT_DEFAULT, Fore.REQUIRED_DEFAULT, Fore.CONSTRAINT_DEFAULT, Fore.TYPE_DEFAULT, targetNode, this, instanceId, ); // console.log('new ModelItem is instanceof ModelItem ', mi instanceof ModelItem); model.registerModelItem(mi); return mi; } /** * modelConstruct starts actual processing of the model by * * 1. loading instances if present or constructing one * 2. calling updateModel to run the model update cycle of rebuild, recalculate and revalidate * * @event model-construct-done is fired once all instances have be loaded or after generating instance * */ async modelConstruct() { console.info( `%cdispatching model-construct for #${this.parentNode.id}`, 'background:lightblue; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;', ); // this.dispatchEvent(new CustomEvent('model-construct', { detail: this })); Fore.dispatch(this, 'model-construct', { model: this }); // console.time('instance-loading'); const instances = this.querySelectorAll('fx-instance'); if (instances.length > 0) { const promises = []; instances.forEach(instance => { promises.push(instance.init()); }); // Wait until all the instances are built await Promise.all(promises); this.instances = Array.from(instances); // console.log('_modelConstruct this.instances ', this.instances); // Await until the model-construct-done event is handled off this.modelConstructed = true; await Fore.dispatch(this, 'model-construct-done', { model: this }); this.inited = true; this.updateModel(); } else { // ### if there's no instance one will created console.log(`### <<<<< dispatching model-construct-done for '${this.fore.id}' >>>>>`); this.modelConstructed = true; await this.dispatchEvent( new CustomEvent('model-construct-done', { composed: false, bubbles: true, detail: { model: this }, }), ); } const functionlibImports = Array.from(this.querySelectorAll('fx-functionlib')); await Promise.all(functionlibImports.map(lib => lib.readyPromise)); // console.timeEnd('instance-loading'); this.inited = true; } registerModelItem(modelItem) { // console.log('ModelItem registered ', modelItem); this.modelItems.push(modelItem); } /** * update action triggering the update cycle */ updateModel() { // console.time('updateModel'); this.rebuild(); /* if (this.skipUpdate){ console.info('%crecalculate/revalidate skipped - no bindings', 'font-style: italic; background: #90a4ae; color:lightgrey; padding:0.3rem 5rem 0.3rem 0.3rem;display:block;width:100%;'); return; } */ this.recalculate(); this.revalidate(); // console.log('updateModel finished with modelItems ', this.modelItems); // console.timeEnd('updateModel'); } rebuild() { // console.log(`### <<<<< rebuild() '${this.fore.id}' >>>>>`); this.mainGraph = new DepGraph(false); // do: should be moved down below binds.length check but causes errors in tests. this.modelItems = []; // trigger recursive initialization of the fx-bind elements const binds = this.querySelectorAll('fx-model > fx-bind'); if (binds.length === 0) { // console.log('skipped model update'); this.skipUpdate = true; return; } binds.forEach(bind => { bind.init(this); }); console.log('mainGraph', this.mainGraph); console.log('rebuild mainGraph calc order', this.mainGraph.overallOrder()); // this.dispatchEvent(new CustomEvent('rebuild-done', {detail: {maingraph: this.mainGraph}})); Fore.dispatch(this, 'rebuild-done', { maingraph: this.mainGraph }); console.log('mainGraph', this.mainGraph); } /** * recalculation of all modelItems. Uses dependency graph to determine order of computation. * * todo: use 'changed' flag on modelItems to determine subgraph for recalculation. Flag already exists but is not used. */ recalculate() { if (!this.mainGraph) { return; } // console.log(`### <<<<< recalculate() '${this.fore.id}' >>>>>`); // console.log('changed nodes ', this.changed); this.computes = 0; this.subgraph = new DepGraph(false); // ### create the subgraph for all changed modelItems if (this.changed.length !== 0) { // ### build the subgraph this.changed.forEach(modelItem => { this.subgraph.addNode(modelItem.path, modelItem.node); // const dependents = this.mainGraph.dependantsOf(modelItem.path, false); // this._addSubgraphDependencies(modelItem.path); if (this.mainGraph.hasNode(modelItem.path)) { // const dependents = this.mainGraph.directDependantsOf(modelItem.path) const all = this.mainGraph.dependantsOf(modelItem.path, false); const dependents = all.reverse(); if (dependents.length !== 0) { dependents.forEach(dep => { // const subdep = this.mainGraph.dependentsOf(dep,false); // subgraph.addDependency(dep, modelItem.path); const val = this.mainGraph.getNodeData(dep); this.subgraph.addNode(dep, val); if (dep.includes(':')) { const path = dep.substring(0, dep.indexOf(':')); this.subgraph.addNode(path, val); const deps = this.mainGraph.dependentsOf(modelItem.path, false); // if we find the dep to be first in list of dependents we are dependent on ourselves not adding edge to modelItem.path if (deps.indexOf(dep) !== 0) { this.subgraph.addDependency(dep, modelItem.path); } } // subgraph.addDependency(dep,modelItem.path); }); } } }); // ### compute the subgraph const ordered = this.subgraph.overallOrder(false); ordered.forEach(path => { if (this.mainGraph.hasNode(path)) { const node = this.mainGraph.getNodeData(path); this.compute(node, path); } }); const toRefresh = [...this.changed]; this.formElement.toRefresh = toRefresh; this.changed = []; Fore.dispatch(this, 'recalculate-done', { graph: this.subgraph, computes: this.computes }); } else { const v = this.mainGraph.overallOrder(false); v.forEach(path => { const node = this.mainGraph.getNodeData(path); this.compute(node, path); }); Fore.dispatch(this, 'recalculate-done', { graph: this.mainGraph, computes: this.computes }); } console.log(`${this.parentElement.id} recalculate finished with modelItems `, this.modelItems); } /* _addSubgraphDependencies(path){ const dependents = this.mainGraph.directDependantsOf(path) const alreadyInGraph = this.subgraph.incomingEdges[path]; // const alreadyInGraph = path in this.subgraph; if(dependents.length !== 0 && alreadyInGraph.length === 0){ dependents.forEach(dep => { // const val= this.mainGraph.getNodeData(dep); // this.subgraph.addNode(dep,val); if(dep.includes(':')){ const subpath = dep.substring(0, dep.indexOf(':')); // this.subgraph.addNode(subpath,val); this.subgraph.addDependency(subpath,dep); this.subgraph.addDependency(dep,path); /!* const subdeps = this.mainGraph.directDependantsOf(path); console.log('subdeps',path, subdeps); subdeps.forEach(sdep => { const sval= this.mainGraph.getNodeData(sdep); this.subgraph.addNode(sdep,sval); console.log('subdep',sdep); }); *!/ if(this.subgraph.incomingEdges[dep] === 0){ this._addSubgraphDependencies(subpath) } } }); } } */ /** * (re-) computes a modelItem. * @param {Node} node - the node the modelItem is attached to * @param {string} path - the canonical XPath of the node */ compute(node, path) { const modelItem = this.getModelItem(node); if (modelItem && path.includes(':')) { const property = path.split(':')[1]; if (property) { /* if (property === 'readonly') { // make sure that calculated items are always readonly if(modelItem.bind['calculate']){ modelItem.readonly = true; }else { const expr = modelItem.bind[property]; const compute = evaluateXPathToBoolean(expr, modelItem.node, this); modelItem.readonly = compute; } } */ const expr = modelItem.bind[property]; if (property === 'calculate') { const compute = evaluateXPath(expr, modelItem.node, this); modelItem.value = compute; modelItem.readonly = true; // calculated nodes are always readonly } else if (property !== 'constraint' && property !== 'type') { // ### re-compute the Boolean value of all facets expect 'constraint' and 'type' which are handled in revalidate() if (expr) { const compute = evaluateXPathToBoolean(expr, modelItem.node, this); modelItem[property] = compute; /* console.log( `recalculating path ${path} - Expr:'${expr}' computed`, modelItem[property], ); */ } } } this.computes += 1; } } /** * Iterates all modelItems to calculate the validation status. * * Model alerts are given on 'fx-bind' elements as either attribute `alert` or as `fx-alert` child elements. * * During model-construct all model alerts are added to the modelItem if any * * to revalidate: * Gets the `constraint` attribute declaration from modelItem.bind * Computes the XPath to a Boolean * Updates the modelItem.constraint property * * todo: type checking * todo: run browser validation API * */ revalidate() { if (this.modelItems.length === 0) return true; // console.log(`### <<<<< revalidate() '${this.fore.id}' >>>>>`); // reset submission validation // this.parentNode.classList.remove('submit-validation-failed') let valid = true; this.modelItems.forEach(modelItem => { // console.log('validating node ', modelItem.node); const { bind } = modelItem; if (bind) { /* todo: investigate why bind is an element when created in fx-bind.init() and an fx-bind object when created lazily. */ if (typeof bind.hasAttribute === 'function' && bind.hasAttribute('constraint')) { const constraint = bind.getAttribute('constraint'); if (constraint && modelItem.node) { const compute = evaluateXPathToBoolean(constraint, modelItem.node, this); // console.log('modelItem validity computed: ', compute); modelItem.constraint = compute; this.formElement.addToRefresh(modelItem); // let fore know that modelItem needs refresh if (!compute) { console.log('validation failed on modelitem ', modelItem); valid = false; } } } if (typeof bind.hasAttribute === 'function' && bind.hasAttribute('required')) { const required = bind.getAttribute('required'); if (required) { const compute = evaluateXPathToBoolean(required, modelItem.node, this); // console.log('modelItem required computed: ', compute); modelItem.required = compute; this.formElement.addToRefresh(modelItem); // let fore know that modelItem needs refresh if (!modelItem.node.textContent) { /* console.log( 'node is required but has no value ', XPathUtil.getDocPath(modelItem.node), ); */ valid = false; } // if (!compute) valid = false; /* if (!this.modelConstructed) { // todo: get alert from attribute or child element const alert = bind.getAlert(); if (alert) { modelItem.addAlert(alert); } } */ } } } }); console.log('modelItems after revalidate: ', this.modelItems); return valid; } addChanged(modelItem) { if (this.inited) { this.changed.push(modelItem); } } /** * * @param {Node} node * @returns {ModelItem} */ getModelItem(node) { return this.modelItems.find(m => m.node === node); } /** * get the default evaluation context for this model. * @returns {Element} */ getDefaultContext() { return this.instances[0].getDefaultContext(); } /** * @returns {import('./fx-instance.js').FxInstance} */ getDefaultInstance() { /* if (this.instances.length === 0) { throw new Error('No instances defined. Fore cannot work without any <data/> elements.'); } */ if (this.instances.length) { return this.instances[0]; } return this.getInstance('default'); } getDefaultInstanceData() { return this.instances[0].getInstanceData(); } getInstance(id) { // console.log('getInstance ', id); // console.log('instances ', this.instances); // console.log('instances array ',Array.from(this.instances)); let found; if (id === 'default') { found = this.instances[0]; } // ### lookup in local instances first if (!found) { const instArray = Array.from(this.instances); found = instArray.find(inst => inst.id === id); const parentFore = this.fore.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE ? this.fore.parentNode.host.closest('fx-fore') : this.fore.parentNode.closest('fx-fore'); } // ### lookup in parent Fore if present if (!found) { // const parentFore = this.fore.parentNode.closest('fx-fore'); const parentFore = this.fore.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE ? this.fore.parentNode.host.closest('fx-fore') : this.fore.parentNode.closest('fx-fore'); if (parentFore) { // console.log('shared instances from parent', this.parentNode.id); const parentInstances = parentFore.getModel().instances; const shared = parentInstances.filter(shared => shared.hasAttribute('shared')); found = shared.find(found => found.id === id); } } if (found) { return found; } if (id === 'default') { return this.getDefaultInstance(); // if id is not found always defaults to first in doc order } if (!found && this.fore.strict) { // return this.getDefaultInstance(); // if id is not found always defaults to first in doc order Fore.dispatch(this, 'error', { origin: this, message: `Instance '${id}' does not exist`, level: 'Error', }); } return null; } evalBinding(bindingExpr) { // console.log('MODEL.evalBinding ', bindingExpr); // default context of evaluation is always the default instance const result = this.instances[0].evalXPath(bindingExpr); return result; } } if (!customElements.get('fx-model')) { customElements.define('fx-model', FxModel); }