@jinntec/fore
Version:
Fore - declarative user interfaces in plain HTML
685 lines (636 loc) • 21.4 kB
JavaScript
import getInScopeContext from './getInScopeContext.js';
import {
evaluateXPath,
evaluateXPathToFirstNode,
evaluateXPathToString,
evaluateXPathToNodes,
} from './xpath-evaluation.js';
import { XPathUtil } from './xpath-util.js';
/**
* Class hosting common utility functions used throughout all fore elements
*
* @example ../doc/demo.html
*/
export class Fore {
static READONLY_DEFAULT = false;
static REQUIRED_DEFAULT = false;
static RELEVANT_DEFAULT = true;
static CONSTRAINT_DEFAULT = true;
static TYPE_DEFAULT = 'xs:string';
/**
* Loads and return a piece of HTML
* @param url {String} - the Url to load from
* @returns {Promise<string>}
*/
static async loadHtml(url) {
try {
const response = await fetch(url, {
method: 'GET',
mode: 'cors',
credentials: 'same-origin',
headers: {
'Content-Type': 'text/html',
},
});
const responseContentType = response.headers.get('content-type').toLowerCase();
if (responseContentType.startsWith('text/html')) {
return response.text();
}
Fore.dispatch(this, 'error', {
message: `Response has wrong contentType '${responseContentType}'. Should be 'text/html'`,
level: 'Error',
});
} catch (error) {
Fore.dispatch(this, 'error', {
message: `Html couldn't be loaded from '${url}'`,
level: 'Error',
});
}
}
/**
* loads a Fore element from given `src`. Always returns the first occurrence of a `<fx-fore>`. The retured element
* will replace the `replace` element in the DOM.
*
* @param {Element} replace the element with a `src` attribute to resolvé.
* @param {string} src the Url to resolve
* @param {string} selector a querySelector expression to fetch certain element from loaded document
* @returns {Promise<HTMLElement>} The replacement element
*/
static async loadForeFromSrc(replace, src, selector) {
if (!src) {
Fore.dispatch(this, 'error', {
detail: {
message: "No 'src' attribute present",
},
});
return null;
}
const data = await Fore.loadHtml(src);
const parsed = new DOMParser().parseFromString(data, 'text/html');
// const theFore = parsed.querySelector('fx-fore');
const foreElement = parsed.querySelector(selector);
// console.log('foreElement', foreElement)
if (!foreElement) {
Fore.dispatch(this, 'error', {
detail: {
message: `Fore element not found in '${src}'. Maybe wrapped within 'template' element?`,
},
});
return;
}
foreElement.setAttribute('from-src', src);
const thisAttrs = replace.attributes;
Array.from(thisAttrs).forEach(attr => {
if (attr.name !== 'src') {
foreElement.setAttribute(attr.name, attr.value);
}
});
replace.replaceWith(foreElement);
return foreElement;
}
static buildPredicates(node) {
let attrPredicate = '';
Array.from(node.attributes).forEach(attr => {
// attrMap.set(attr.nodeName,attr.nodeValue);
// if(attr.nodeName !== 'xmlns'){
// if(attr.nodeValue !== ''){
// attrPredicate += `[@${attr.nodeName}='${attr.nodeValue}']`;
// }else{
attrPredicate += `[@${attr.nodeName}]`;
// }
// }
});
return attrPredicate;
}
/**
* returns true if target element is the widget itself or some element within the widget.
* @param {HTMLElement} target an event target
* @returns {boolean}
*/
static isWidget(target) {
if (target?.classList.contains('widget')) return true;
let parent = target.parentNode;
while (parent && parent.nodeName !== 'FX-CONTROL') {
if (parent?.classList?.contains('widget')) return true;
parent = parent.parentNode;
}
return false;
}
/**
* Get a string that can be used as a path to a node
*
* @param {Node} node
* @returns {string}
*/
static getDomNodeIndexString(node) {
const indexes = [];
let currentNode = node;
while (currentNode && currentNode.parentNode) {
const parent = currentNode.parentNode;
if (parent.childNodes && parent.childNodes.length > 0) {
const index = [...parent.childNodes].indexOf(currentNode);
indexes.unshift(index);
}
currentNode = parent;
}
return indexes.join('.');
}
/**
* Get the expression part of something
* @param {string} input
* @returns {string}
*/
static getExpression(input) {
if (input.startsWith('{') && input.endsWith('}')) {
return input.substring(1, input.length - 1);
}
return input;
}
/**
* returns the next `fx-fore` element upwards in tree
*
* @param {HTMLElement|Text} start
* @returns {import('./fx-fore.js').FxFore}
*/
static getFore(start) {
return start.nodeType === Node.TEXT_NODE
? start.parentNode.closest('fx-fore')
: start.closest('fx-fore');
}
static get ACTION_ELEMENTS() {
return [
'FX-ACTION',
'FX-DELETE',
'FX-DISPATCH',
'FX-HIDE',
'FX-INSERT',
'FX-LOAD',
'FX-MESSAGE',
'FX-REBUILD',
'FX-RECALCULATE',
'FX-REFRESH',
'FX-RENEW',
'FX-RELOAD',
'FX-REPLACE',
'FX-RESET',
'FX-RETAIN',
'FX-RETURN',
'FX-REVALIDATE',
'FX-SEND',
'FX-SETFOCUS',
'FX-SETINDEX',
'FX-SETVALUE',
'FX-SHOW',
'FX-TOGGLE',
'FX-UPDATE',
];
}
static createUUID() {
// http://www.ietf.org/rfc/rfc4122.txt
const s = [];
const hexDigits = '0123456789abcdef';
for (let i = 0; i < 36; i += 1) {
s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1);
}
s[14] = '4'; // bits 12-15 of the time_hi_and_version field to 0010
s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1); // bits 6-7 of the clock_seq_hi_and_reserved to 01
s[8] = s[13] = s[18] = s[23] = '-';
const uuid = s.join('');
return uuid;
}
static get XFORMS_NAMESPACE_URI() {
// todo: should be centralized somewhere as constant. Exists in several? places
return 'http://www.w3.org/2002/xforms';
}
/**
* @param {string} elementName
* @returns {boolean}
*/
static isActionElement(elementName) {
return Fore.ACTION_ELEMENTS.includes(elementName);
}
static get UI_ELEMENTS() {
return [
'FX-ALERT',
'FX-CONTROL',
'FX-DIALOG',
'FX-FILENAME',
'FX-MEDIATYPE',
'FX-GROUP',
'FX-HINT',
'FX-ITEMS',
'FX-OUTPUT',
'FX-RANGE',
'FX-REPEAT',
'FX-REPEATITEM',
'FX-REPEAT-ATTRIBUTES',
'FX-SWITCH',
'FX-SECRET',
'FX-SELECT',
'FX-SUBMIT',
'FX-TEXTAREA',
'FX-TRIGGER',
'FX-UPLOAD',
'FX-VAR',
];
}
static get MODEL_ELEMENTS() {
return ['FX-BIND', 'FX-FUNCTION', 'FX-MODEL', 'FX-INSTANCE', 'FX-SUBMISSION'];
}
/**
* @param {string} elementName
* @returns {boolean}
*/
static isUiElement(elementName) {
const found = Fore.UI_ELEMENTS.includes(elementName);
if (found) {
// console.log('_isUiElement ', found);
}
return Fore.UI_ELEMENTS.includes(elementName);
}
/**
* recursively refreshes all UI Elements.
*
* @param {HTMLElement} startElement
* @param {(boolean|{reason:'index-function'})} force Whether to do a forced refresh. Forced
* refreshes are very bad for performance, try to limit them. If the forced refresh is because index functions may change, it is better to pass the reason
* @returns {Promise<void>}
*/
static async refreshChildren(startElement, force) {
const refreshed = new Promise(resolve => {
/*
if there's an 'refresh-on-view' attribute the element wants to be handled by
handleIntersect function that calls the refresh of the respective element and
not the global one.
*/
// if(!force && startElement.hasAttribute('refresh-on-view')) return;
/* ### attempt with querySelectorAll is even slower than iterating recursively
const children = startElement.querySelectorAll('[ref]');
Array.from(children).forEach(uiElement => {
if (Fore.isUiElement(uiElement.nodeName) && typeof uiElement.refresh === 'function') {
uiElement.refresh();
}
});
*/
const { children } = startElement;
if (children) {
for (const element of Array.from(children)) {
if (element.nodeName.toUpperCase() === 'FX-FORE') {
break;
}
if (Fore.isUiElement(element.nodeName) && typeof element.refresh === 'function') {
/**
* @type {import('./ForeElementMixin.js').default}
*/
if (
force &&
typeof force === 'object' &&
force.reason === 'index-function' &&
element._dependencies.isInvalidatedByIndexFunction()
) {
element.refresh(force);
continue;
} else if (force === true) {
element.refresh(force);
}
// console.log('refreshing', element, element?.ref);
// console.log('refreshing ',element);
} else if (!(element.inert === true) ) {
// testing for inert catches model and action elements and should just leave updateable html elements
Fore.refreshChildren(element, force);
}
}
}
resolve('done');
});
return refreshed;
}
static copyDom(inputElement) {
console.time('convert');
const target = new DOMParser().parseFromString('<fx-fore></fx-fore>', 'text/html');
console.log('copyDom new doc', target);
console.log('copyDom new body', target.body);
console.log('copyDom new body', target.querySelector('fx-fore'));
const newFore = target.querySelector('fx-fore');
this.convertFromSimple(inputElement, newFore);
newFore.removeAttribute('convert');
console.log('converted', newFore);
console.timeEnd('convert');
return newFore;
}
static convertFromSimple(startElement, targetElement) {
const children = startElement.childNodes;
if (children) {
Array.from(children).forEach(node => {
const lookFor = `FX-${node.nodeName.toUpperCase()}`;
if (
Fore.MODEL_ELEMENTS.includes(lookFor) ||
Fore.UI_ELEMENTS.includes(lookFor) ||
Fore.ACTION_ELEMENTS.includes(lookFor)
) {
const conv = targetElement.ownerDocument.createElement(lookFor);
console.log('conv', node, conv);
targetElement.appendChild(conv);
Fore.copyAttributes(node, conv);
Fore.convertFromSimple(node, conv);
} else {
if (node.nodeType === Node.TEXT_NODE) {
const copied = targetElement.ownerDocument.createTextNode(node.textContent);
targetElement.appendChild(copied);
}
if (node.nodeType === Node.ELEMENT_NODE) {
const copied = targetElement.ownerDocument.createElement(node.nodeName);
targetElement.appendChild(copied);
Fore.copyAttributes(node, targetElement);
Fore.convertFromSimple(node, copied);
}
}
});
}
}
static copyAttributes(source, target) {
return Array.from(source.attributes).forEach(attribute => {
target.setAttribute(attribute.nodeName, attribute.nodeValue);
});
}
/**
* returns the proper content-type for instance.
*
* @param instance an fx-instance element
* @param contentType - the contentType
* @returns {string|null}
*/
static getContentType(instance, contentType) {
if (contentType === 'application/x-www-form-urlencoded') {
return 'application/x-www-form-urlencoded; charset=UTF-8';
}
if (instance.type === 'xml') {
return 'application/xml; charset=UTF-8';
}
if (instance.type === 'json') {
return 'application/json';
}
console.warn('content-type unknown ', instance.type);
return null;
}
static async handleResponse(response) {
const { status } = response;
if (status >= 400) {
// console.log('response status', status);
alert(`response status: ${status} - failed to load data for '${this.src}' - stopping.`);
throw new Error(`failed to load data - status: ${status}`);
}
const responseContentType = response.headers.get('content-type').toLowerCase();
// console.log('********** responseContentType *********', responseContentType);
if (responseContentType.startsWith('text/html')) {
// const htmlResponse = response.text();
// return new DOMParser().parseFromString(htmlResponse, 'text/html');
// return response.text();
return response.text().then(result =>
// console.log('xml ********', result);
new DOMParser().parseFromString(result, 'text/html'),
);
}
if (
responseContentType.startsWith('text/plain') ||
responseContentType.startsWith('text/markdown')
) {
// console.log("********** inside res plain *********");
return response.text();
}
if (responseContentType.startsWith('application/json')) {
// console.log("********** inside res json *********");
return response.json();
}
if (responseContentType.startsWith('application/xml')) {
const text = await response.text();
// console.log('xml ********', result);
return new DOMParser().parseFromString(text, 'application/xml');
}
return 'done';
}
/*
static 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, node.getOwnerForm());
const replaced = expr.replaceAll(match, result);
console.log('replacing ', expr, ' with ', replaced);
expr = replaced;
});
}
return expr;
}
*/
static fadeInElement(element) {
const duration = 600;
let fadeIn = () => {
// Stop all current animations
if (element.getAnimations) {
element.getAnimations().map(anim => anim.finish());
}
// Play the animation with the newly specified duration
fadeIn = element.animate(
{
opacity: [0, 1],
},
duration,
);
return fadeIn.finished;
};
return fadeIn();
}
static fadeOutElement(element, duration) {
// const duration = duration;
let fadeOut = () => {
// Stop all current animations
if (element.getAnimations) {
element.getAnimations().map(anim => anim.finish());
}
// Play the animation with the newly specified duration
fadeOut = element.animate(
{
opacity: [1, 0],
},
duration,
);
return fadeOut.finished;
};
return fadeOut();
}
static async dispatch(target, eventName, detail) {
if (!XPathUtil.contains(target?.ownerDocument, target)) {
// The target is gone from the document. This happens when we are done with a refresh that removed the component
return;
}
const event = new CustomEvent(eventName, {
composed: false,
bubbles: true,
detail,
});
event.listenerPromises = [];
target.dispatchEvent(event);
// By now, all listeners for the event should have registered their completion promises to us.
if (event.listenerPromises.length) {
await Promise.all(event.listenerPromises);
}
// console.log('!!! DISPATCH_DONE', eventName);
}
static formatXml(xml) {
const reg = /(>)(<)(\/*)/g;
const wsexp = / *(.*) +\n/g;
const contexp = /(<.+>)(.+\n)/g;
xml = xml.replace(reg, '$1\n$2$3').replace(wsexp, '$1\n').replace(contexp, '$1\n$2');
let formatted = '';
const lines = xml.split('\n');
let indent = 0;
let lastType = 'other';
// 4 types of tags - single, closing, opening, other (text, doctype, comment) - 4*4 = 16 transitions
const transitions = {
'single->single': 0,
'single->closing': -1,
'single->opening': 0,
'single->other': 0,
'closing->single': 0,
'closing->closing': -1,
'closing->opening': 0,
'closing->other': 0,
'opening->single': 1,
'opening->closing': 0,
'opening->opening': 1,
'opening->other': 1,
'other->single': 0,
'other->closing': -1,
'other->opening': 0,
'other->other': 0,
};
for (let i = 0; i < lines.length; i++) {
const ln = lines[i];
const single = Boolean(ln.match(/<.+\/>/)); // is this line a single tag? ex. <br />
const closing = Boolean(ln.match(/<\/.+>/)); // is this a closing tag? ex. </a>
const opening = Boolean(ln.match(/<[^!].*>/)); // is this even a tag (that's not <!something>)
const type = single ? 'single' : closing ? 'closing' : opening ? 'opening' : 'other';
const fromTo = `${lastType}->${type}`;
lastType = type;
let padding = '';
indent += transitions[fromTo];
for (let j = 0; j < indent; j++) {
padding += ' ';
}
formatted += `${padding + ln}\n`;
}
}
static stringifiedComponent(element) {
return `<${element.localName} ${Array.from(element.attributes)
.map(attr => `${attr.name}="${attr.value}"`)
.join(' ')}>…</${element.localName}>`;
}
/*
static async loadForeFromUrl(hostElement, url) {
// console.log('########## loading Fore from ', this.src, '##########');
await fetch(url, {
method: 'GET',
mode: 'cors',
credentials: 'include',
headers: {
'Content-Type': 'text/html',
},
})
.then(response => {
const responseContentType = response.headers.get('content-type').toLowerCase();
console.log('********** responseContentType *********', responseContentType);
if (responseContentType.startsWith('text/html')) {
return response.text().then(result =>
// console.log('xml ********', result);
new DOMParser().parseFromString(result, 'text/html'),
);
}
return 'done';
})
.then(data => {
// const theFore = fxEvaluateXPathToFirstNode('//fx-fore', data.firstElementChild);
const theFore = data.querySelector('fx-fore');
// console.log('thefore', theFore)
if (!theFore) {
hostElement.dispatchEvent(
new CustomEvent('error', {
composed: false,
bubbles: true,
detail: {
message: 'cyclic graph',
},
}),
);
}
const imported = document.importNode(theFore,true);
console.log(`########## loaded fore as component ##### ${hostElement.url}`);
imported.addEventListener(
'model-construct-done',
e => {
// console.log('subcomponent ready', e.target);
const defaultInst = imported.querySelector('fx-instance');
// console.log('defaultInst', defaultInst);
if(hostElement.initialNode){
const doc = new DOMParser().parseFromString('<data></data>', 'application/xml');
// Note: Clone the input to prevent the inner fore from editing the outer node
doc.firstElementChild.appendChild(hostElement.initialNode.cloneNode(true));
// defaultinst.setInstanceData(this.initialNode);
defaultInst.setInstanceData(doc);
}
// console.log('new data', defaultInst.getInstanceData());
// theFore.getModel().modelConstruct();
imported.getModel().updateModel();
imported.refresh();
return 'done';
},
{ once: true },
);
const dummy = hostElement.querySelector('input');
if (hostElement.hasAttribute('shadow')) {
dummy.parentNode.removeChild(dummy);
hostElement.shadowRoot.appendChild(imported);
} else {
// console.log(this, 'replacing widget with',theFore);
dummy.replaceWith(imported);
// this.appendChild(imported);
}
})
/!*.catch(error => {
hostElement.dispatchEvent(
new CustomEvent('error', {
composed: false,
bubbles: true,
detail: {
error: error,
message: `'${url}' not found or does not contain Fore element.`,
},
}),
);
});*!/
}
*/
/**
* clear all text nodes and attribute values to get a 'clean' template.
* @param n
* @private
*/
/*
static clear(n) {
n.textContent = '';
if (n.hasAttributes()) {
const attrs = n.attributes;
for (let i = 0; i < attrs.length; i+= 1) {
attrs[i].value = '';
}
}
const { children } = n;
for (let i = 0; i < children.length; i+= 1) {
Fore.clear(children[i]);
}
}
*/
}