UNPKG

@jinntec/fore

Version:

Fore - declarative user interfaces in plain HTML

509 lines (459 loc) 15.5 kB
import ForeElementMixin from '../ForeElementMixin.js'; import { evaluateXPathToBoolean, resolveId, evaluateXPath } from '../xpath-evaluation.js'; import getInScopeContext from '../getInScopeContext.js'; import { Fore } from '../fore.js'; import { FxFore } from '../fx-fore.js'; import { XPathUtil } from '../xpath-util.js'; /** * @param {number} howLong How long to wait, in ms * @returns {Promise<void>} */ async function wait(howLong) { return new Promise(resolve => setTimeout(() => resolve(), howLong)); } /** * Superclass for all action elements. Provides basic wiring of events to targets as well as * handle conditionals and loops of actions. * * @fires action-performed - is dispatched after each execution of an action. * @customElement * @demo demo/index.html */ export class AbstractAction extends ForeElementMixin { static dataChanged = false; static get properties() { return { ...super.properties, /** * can be either 'cancel' or 'perform' (default) */ defaultAction: { type: String, }, /** * delay before executing action in milliseconds */ delay: { type: Number, }, /** * detail - event detail object */ detail: { type: Object, }, /** * event to listen for */ event: { type: Object, }, handler: { type: Object, }, /** * boolean XPath expression. If true the action will be executed. */ ifExpr: { type: String, }, /** * The iterate attribute can be added to any XForms action. It contains an expression * that is evaluated once using the in-scope evaluation context before the action is * executed, which will result in a sequence of items. The action will be executed with * each item in the sequence as its context. This context replaces the default in scope * evaluation context. * * The interaction with `@while` and `@if` is undefined. */ iterateExpr: { type: String, }, /** * whether nor not an action needs to run the update cycle */ needsUpdate: { type: Boolean, }, /** * The observer if given is the element on which an event is triggered. It must be an ancestor of the target * element of an event. */ observer: { type: Object, }, /** * can be either 'capture' or 'default' (default) */ phase: { type: String, }, /** * can be either 'stop' or 'continue' (default) */ propagate: { type: String, }, /** * id of target element to attach listener to */ target: { type: String, }, /** * boolean XPath expression. If true loop will be executed. If an ifExpr is present this * also needs to be true to actually run the action. */ whileExpr: { type: String, }, }; } constructor() { super(); this.detail = {}; this.needsUpdate = false; } disconnectedCallback() {} connectedCallback() { this.setAttribute('inert', 'true'); this.style.display = 'none'; this.propagate = this.hasAttribute('propagate') ? this.getAttribute('propagate') : 'continue'; this.repeatContext = undefined; if (this.hasAttribute('event')) { this.event = this.getAttribute('event'); } if (this.hasAttribute('defaultAction')) { this.defaultAction = this.getAttribute('defaultAction'); } else { this.defaultAction = 'perform'; } if (this.hasAttribute('phase')) { this.phase = this.getAttribute('phase'); } else { this.phase = 'default'; } /* this.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); }); */ this.ifExpr = this.hasAttribute('if') ? this.getAttribute('if') : null; this.whileExpr = this.hasAttribute('while') ? this.getAttribute('while') : null; this.delay = this.hasAttribute('delay') ? Number(this.getAttribute('delay')) : 0; this.iterateExpr = this.hasAttribute('iterate') ? this.getAttribute('iterate') : null; this._addUpdateListener(); } _addUpdateListener() { this.target = this.getAttribute('target'); if (this.target) { if (this.target === '#window') { window.addEventListener(this.event, e => this.execute(e), { capture: this.phase === 'capture', }); } else if (this.target === '#document') { document.addEventListener(this.event, e => this.execute(e), { capture: this.phase === 'capture', }); } else { this.targetElement = resolveId(this.target, this); if (!this.targetElement) return; // does not or does not yet exist this?.targetElement.addEventListener(this.event, e => this.execute(e), { capture: this.phase === 'capture', }); } } else { this.targetElement = this.parentNode; this.targetElement.addEventListener(this.event, e => this.execute(e), { capture: this.phase === 'capture', }); // console.log('adding listener for ', this.event , ` to `, this); } } async performSafe() { try { await this.perform(); // Return true to indicate success return true; } catch (error) { await Fore.dispatch(this, 'error', { origin: this, message: 'Action execution failed', expr: XPathUtil.getDocPath(this), level: 'Error', }); // Return false to indicate failure. Any loops must be canceled return false; } } /** * executes the action. * * Will first evaluate ifExpr and continue only if it evaluates to 'true'. The 'whileExpr' will be executed * considering the delay if present. * * After calling `perform' which actually implements the semantics of an concrete action * `actionPerformed` will make sure that update cycle is run if 'needsUpdate' is true. * * @param e */ async execute(e) { if(!this.getModel().modelConstructed) return; // console.log(this, this.event); if(this.event){ if(this.event === 'submit-done'){ console.info( `%csubmit-done ${this.event} #${this?.parentNode?.id}`, 'background:lime; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;', ); }else{ console.info( `%cexecuting ${this.constructor.name} ${this.event}`, 'background:lime; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;', ); } }else{ console.info( `%cexecuting ${this.constructor.name}`, 'background:limegreen; color:black; margin-left:1rem; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;', this ); } if (e && e.target.nodeType !== Node.DOCUMENT_NODE && e.target !== window) { /* ### ignore event if there's a parent fore and the current element is NOT part of it. This avoids ### an event to fire twice on an inner one and the surrounding one(s). ### e.target might be outside an fx-fore element and shouldn't get cancelled in that case. */ if (e.target.closest('fx-fore') && e.target.closest('fx-fore') !== this.closest('fx-fore')) { // Event originates from a sub-component. Ignore it! // No need to stop propagation. All other listeners will also ignore it from here return; } } if (this.propagate === 'stop') { // console.log('event propagation stopped', e) e.stopPropagation(); } if (this.defaultAction === 'cancel') { e.preventDefault(); } let resolveThisEvent = () => {}; if (e && e.listenerPromises) { e.listenerPromises.push( new Promise((resolve) => { resolveThisEvent = resolve; }), ); } // Outermost handling if (FxFore.outermostHandler === null) { console.log( `%coutermost Action on ${this.getOwnerForm().id}`, 'background:darkblue; color:white; padding:0.3rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;', this, ); FxFore.outermostHandler = this; this.dispatchEvent( new CustomEvent('outermost-action-start', { composed: true, bubbles: true, cancelable: true, detail: { cause: e?.type }, }), ); } if (e) { this.currentEvent = e; } this.needsUpdate = false; try { this.evalInContext(); } catch (error) { console.warn('evaluation failed', error); } if (this.targetElement && this.targetElement.nodeset) { this.nodeset = this.targetElement.nodeset; } // Order of application between if / while and iterate is undefined. See // https://www.w3.org/MarkUp/Forms/wiki/@iterate if (this.iterateExpr) { // Same as whileExpr, let it go update UI afterwards await this.handleIterateExpr(); this._finalizePerform(resolveThisEvent); return; } // Check if 'if' condition is true - otherwise exist right away if (this.ifExpr && !evaluateXPathToBoolean(this.ifExpr, getInScopeContext(this), this)) { this._finalizePerform(resolveThisEvent); return; } if (this.whileExpr) { // After loop is done call actionPerformed to update the model and UI await this.handleWhileExpr(); this._finalizePerform(resolveThisEvent); return; } if (this.delay) { // Delay further execution until the delay is done await wait(this.delay); if (!XPathUtil.contains(this.getOwnerForm(), this)) { // We are no longer in the document. Stop working this.actionPerformed(); resolveThisEvent(); return; } } await this.performSafe(); this._finalizePerform(resolveThisEvent); } async handleWhileExpr() { // While: while the condition is true, delay a bit and execute the action // Start by waiting await wait(this.delay || 0); if (!XPathUtil.contains(this.getOwnerForm(), this)) { // We are no longer in the document. Stop working return; } if (!evaluateXPathToBoolean(this.whileExpr, getInScopeContext(this), this)) { // Done with iterating return; } // Perform the action once. But quit if it failed if (!this.performSafe()) { return; } // Go for one more iteration if (this.delay) { // If we have a delay, fire and forget this. // Otherwise, if we have no delay, keep waiting for all iterations to be done. // The while is then uninterruptable and immediate this.handleWhileExpr(); return; } await this.handleWhileExpr(); } async handleIterateExpr() { try { // Iterate: get the context sequence and perform the action once per item. const contextSequence = evaluateXPath(this.iterateExpr, getInScopeContext(this), this); if (contextSequence.length === 0) { return; } if (!XPathUtil.contains(this.getOwnerForm(), this)) { // We are no longer in the document. Stop working return; } for (const item of contextSequence) { if (this.delay) { await wait(this.delay || 0); } // This will be picked up in `getInscopeContext` this.currentContext = item; // Perform the action once. But quit if it failed if (!(await this.performSafe())) { return; } } } finally { this.currentContext = null; } } _finalizePerform(resolveThisEvent) { this.currentEvent = null; this.actionPerformed(); if (FxFore.outermostHandler === this) { console.log( `%cfinalizing outermost Action on ${this.getOwnerForm().id}`, 'background:darkblue; color:white; padding:0.3rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;', this, ); FxFore.outermostHandler = null; /* console.info( `%coutermost Action done`, 'background:#e65100; color:white; padding:0.3rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;', this, ); console.timeEnd('outermostHandler'); */ this.dispatchEvent( new CustomEvent('outermost-action-end', { composed: true, bubbles: true, cancelable: true, }), ); } resolveThisEvent(); } /** * Template method to be implemented by each action that is called by execute() as part of * the processing. * * This function should not called on any action directly - call execute() instead to ensure proper execution of 'if' and 'while' */ async perform() { // await Fore.dispatch(document, 'execute-action', {action:this, event:this.event}); // todo: review - this evaluation seems redundant as we already evaluated in execute if (this.isBound() || this.nodeName === 'FX-ACTION') { this.evalInContext(); } this.dispatchEvent( new CustomEvent('execute-action', { composed: true, bubbles: true, cancelable: true, detail: { action: this, event: this.event }, }), ); } /** * calls the update cycle if action signalled that update is needed. */ actionPerformed() { const model = this.getModel(); if (!model) { return; } if (!model.inited) { return; } if ( FxFore.outermostHandler && !XPathUtil.contains(FxFore.outermostHandler.ownerDocument, FxFore.outermostHandler) ) { // The old outermostHandler fell out of the document. An error has happened. // Just remove the old one and act like we are starting anew. // console.warn('Unsetting outermost handler'); FxFore.outermostHandler = null; } // console.log('actionPerformed action parentNode ', this.parentNode); if (this.needsUpdate && (FxFore.outermostHandler === this || !FxFore.outermostHandler)) { // console.log('running update cycle for outermostHandler', this); model.recalculate(); model.revalidate(); model.parentNode.refresh(true); this.dispatchActionPerformed(); } else if (this.needsUpdate) { // console.log('Update delayed!'); // We need an update, but the outermost action handler is not done yet. Make this clear! // console.log('running actionperformed on', this, ' to be updated by ', FxFore.outermostHandler); FxFore.outermostHandler.needsUpdate = true; } // console.log('running actionperformed on', this, ' outermostHandler', FxFore.outermostHandler); } /** * dispatches action-performed event * * @event action-performed - whenever an action has been run */ dispatchActionPerformed() { // console.log('action-performed ', this); Fore.dispatch(this, 'action-performed', {}); } } if (!customElements.get('abstract-action')) { window.customElements.define('abstract-action', AbstractAction); }