@jinntec/fore
Version:
Fore - declarative user interfaces in plain HTML
1,323 lines (1,180 loc) • 44.9 kB
JavaScript
import { Fore } from './fore.js';
import './fx-instance.js';
import { FxModel } from './fx-model.js';
import '@jinntec/jinn-toast';
import {
evaluateXPathToBoolean,
evaluateXPathToNodes,
evaluateXPathToFirstNode,
evaluateXPathToString,
} from './xpath-evaluation.js';
import getInScopeContext from './getInScopeContext.js';
import { XPathUtil } from './xpath-util.js';
import { FxRepeatAttributes } from './ui/fx-repeat-attributes.js';
import { ModelItem } from './modelitem.js';
/**
* Makes the dirty state of the form.
*
* Clean when there are no changes yet or all is submitted,
* Dirty when there are unsaved changes.
*
* We might need a 'saving' state later
*/
const dirtyStates = {
CLEAN: 'clean',
DIRTY: 'dirty',
};
/**
* Main class for Fore.Outermost container element for each Fore application.
*
* Root element for Fore. Kicks off initialization and displays messages.
*
* fx-fore is the outermost container for each form. A form can have exactly one model
* with arbitrary number of instances.
*
* Main responsibilities are initialization and updating of model and instances, update of UI (refresh) and global messaging.
*
* @event compute-exception - dispatched in case the dependency graph is circular
* @event refresh-done - dispatched after a refresh() run
* @event ready - dispatched after Fore has fully been initialized
* @event error - dispatches error when template expression fails to evaluate
*
* @ts-check
*/
export class FxFore extends HTMLElement {
static outermostHandler = null;
static draggedItem = null;
static get properties() {
return {
/**
* wether to create nodes that are missing in the loaded data and
* auto-create nodes when there's a binding found in the UI.
*/
createNodes: {
type: Boolean,
},
/**
* ignore certain nodes for template expression search
*/
ignoreExpressions: {
type: String,
},
/**
* merge-partial
*/
mergePartial: {
type: Boolean,
},
/**
* Setting this marker attribute will refresh the UI in a lazy fashion just updating elements being
* in viewport.
*
* this feature is still experimental and should be used with caution and extra testing
*/
lazyRefresh: {
type: Boolean,
},
model: {
type: Object,
},
ready: {
type: Boolean,
},
strict: {
type: Boolean,
},
/**
*
*/
validateOn: {
type: String,
},
version: {
type: String,
},
};
}
/**
* attaches handlers for
*
* - `model-construct-done` to trigger the processing of the UI
* - `message` - to display a message triggered by an fx-message action
* - `error` - to display an error message
* - 'compute-exception` - warn about circular dependencies in graph
*/
constructor() {
super();
this.version = '[VI]Version: {version} - built on {date}[/VI]';
this.model = {};
this.inited = false;
// this.addEventListener('model-construct-done', this._handleModelConstructDone);
// todo: refactoring - these should rather go into connectedcallback
this.addEventListener('message', this._displayMessage);
// this.addEventListener('error', this._displayError);
this.addEventListener('error', this._logError);
this.addEventListener('warn', this._displayWarning);
// this.addEventListener('log', this._logError);
window.addEventListener('compute-exception', e => {
console.error('circular dependency: ', e);
});
this.ready = false;
this.storedTemplateExpressionByNode = new Map();
// Stores the outer most action handler. If an action handler is already running, all
// updates are included in that one
this.outermostHandler = null;
this.copiedElements = new WeakSet();
this.dirtyState = dirtyStates.CLEAN;
this.showConfirmation = false;
const style = `
:host {
display: block;
}
:host ::slotted(fx-model){
display:none;
}
#modalMessage .dialogActions{
text-align:center;
}
.overlay {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.7);
transition: all 500ms;
visibility: hidden;
opacity: 0;
z-index:10;
}
.overlay.show {
visibility: visible;
opacity: 1;
}
.popup {
margin: 70px auto;
background: #fff;
border-radius: 5px;
width: 30%;
position: relative;
transition: all 5s ease-in-out;
padding: 20px;
}
.popup h2 {
margin-top: 0;
width:100%;
background:#eee;
position:absolute;
top:0;
right:0;
left:0;
height:40px;
border-radius: 5px;
}
.popup .close {
position: absolute;
top: 3px;
right: 10px;
transition: all 200ms;
font-size: 30px;
font-weight: bold;
text-decoration: none;
color: #333;
}
.popup .close:focus{
outline:none;
}
.popup .close:hover {
color: #06D85F;
}
#messageContent{
margin-top:40px;
}
.warning{
background:orange;
}
`;
const html = `
<!-- <slot name="errors"></slot> -->
<jinn-toast id="message" gravity="bottom" position="left"></jinn-toast>
<jinn-toast id="sticky" gravity="bottom" position="left" duration="-1" close="true" data-class="sticky-message"></jinn-toast>
<jinn-toast id="error" text="error" duration="-1" data-class="error" close="true" position="right" gravity="top" escape-markup="false"></jinn-toast>
<jinn-toast id="warn" text="warning" duration="5000" data-class="warning" position="left" gravity="top"></jinn-toast>
<slot id="default"></slot>
<slot name="messages"></slot>
<div id="modalMessage" class="overlay">
<div class="popup">
<h2></h2>
<a class="close" href="#" onclick="event.target.parentNode.parentNode.classList.remove('show')" autofocus>×</a>
<div id="messageContent"></div>
</div>
</div>
<slot name="event"></slot>
`;
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
${style}
</style>
${html}
`;
this.toRefresh = [];
this.initialRun = true;
this._scanForNewTemplateExpressionsNextRefresh = false;
this.repeatsFromAttributesCreated = false;
this.validateOn = this.hasAttribute('validate-on')
? this.getAttribute('validate-on')
: 'update';
// this.mergePartial = this.hasAttribute('merge-partial')? true:false;
this.mergePartial = false;
this.createNodes = this.hasAttribute('create-nodes') ? true : false;
}
connectedCallback() {
this.style.visibility = 'hidden';
console.time('init');
this.strict = !!this.hasAttribute('strict');
/*
document.addEventListener('ready', (e) =>{
if(e.target !== this){
// e.preventDefault();
console.log('>>> e', e);
console.log('event this', this);
// console.log('event eventPhase', e.eventPhase);
// console.log('event cancelable', e.cancelable);
console.log('event target', e.target);
console.log('event composed', e.composedPath());
console.log('<<< event stopping');
e.stopPropagation();
}else{
console.log('event proceed', this);
}
// e.stopImmediatePropagation();
},true);
*/
this.ignoreExpressions = this.hasAttribute('ignore-expressions')
? this.getAttribute('ignore-expressions')
: null;
this.lazyRefresh = this.hasAttribute('refresh-on-view');
if (this.lazyRefresh) {
const options = {
root: null,
rootMargin: '0px',
threshold: 0.3,
};
this.intersectionObserver = new IntersectionObserver(this.handleIntersect, options);
}
this.src = this.hasAttribute('src') ? this.getAttribute('src') : null;
if (this.src) {
this._loadFromSrc();
return;
}
this._injectDevtools();
const slot = this.shadowRoot.querySelector('slot#default');
slot.addEventListener('slotchange', async event => {
// preliminary addition for auto-conversion of non-prefixed element into prefixed elements. See fore.js
// console.log(`### <<<<< slotchange on '${this.id}' >>>>>`);
if (this.inited) return;
if (this.hasAttribute('convert')) {
this.replaceWith(Fore.copyDom(this));
// Fore.copyDom(this);
return;
}
if (this.ignoreExpressions) {
this.ignoredNodes = Array.from(this.querySelectorAll(this.ignoreExpressions));
}
const children = event.target.assignedElements();
let modelElement = children.find(
modelElem => modelElem.nodeName.toUpperCase() === 'FX-MODEL',
);
if (!modelElement) {
const generatedModel = document.createElement('fx-model');
this.appendChild(generatedModel);
modelElement = generatedModel;
// We are going to get a new slotchange event immediately, because we changed a slot.
// so cancel this one.
return;
}
if (!modelElement.inited) {
console.info(
`%cFore is processing fx-fore#${this.id}`,
'background:#64b5f6; color:white; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
);
const variables = new Map();
(function registerVariables(node) {
for (const child of node.children) {
if ('setInScopeVariables' in child) {
child.setInScopeVariables(variables);
}
registerVariables(child);
}
})(this);
await modelElement.modelConstruct();
this._handleModelConstructDone();
}
this.model = modelElement;
this._createRepeatsFromAttributes();
this.inited = true;
});
this.addEventListener('path-mutated', () => {
this.someInstanceDataStructureChanged = true;
});
this.addEventListener('refresh', () => {
this.refresh(true);
});
if (this.hasAttribute('show-confirmation')) {
this.showConfirmation = true;
}
}
_injectDevtools() {
if (this.ownerDocument.querySelector('fx-devtools')) {
// There's already a devtools, so we can ignore this one.
// One devtools can focus multiple fore elements
return;
}
const { search } = window.location;
const urlParams = new URLSearchParams(search);
if (urlParams.has('inspect')) {
const devtools = document.createElement('fx-devtools');
document.body.appendChild(devtools);
}
if (urlParams.has('lens')) {
const lens = document.createElement('fx-lens');
document.body.appendChild(lens);
lens.setAttribute('open', 'open');
}
}
/**
* Add a model item to the refresh list
*
* @param {import('./modelitem.js').ModelItem} modelItem
* @returns {void}
*/
addToRefresh(modelItem) {
const found = this.toRefresh.find(mi => mi.path === modelItem.path);
if (!found) {
this.toRefresh.push(modelItem);
}
}
/**
* Raise a flag that there might be new template expressions under some node. This happens with
* repeats updating (new repeat items can have new template expressions) or switches changing their case (new case = new raw HTML)
*/
scanForNewTemplateExpressionsNextRefresh() {
// TODO: also ask for the root of any new HTML: this can prevent some very deep queries.
this._scanForNewTemplateExpressionsNextRefresh = true;
}
markAsClean() {
this.addEventListener(
'value-changed',
() => {
this.dirtyState = dirtyStates.DIRTY;
},
{ once: true },
);
this.dirtyState = dirtyStates.CLEAN;
}
/**
* loads a Fore from an URL given by `src`.
*
* Will extract the `fx-fore` element from that target file and use and replace current `fx-fore` element with the loaded one.
* @private
*/
async _loadFromSrc() {
// console.log('########## loading Fore from ', this.src, '##########');
await Fore.loadForeFromSrc(this, this.src, 'fx-fore');
}
/**
* refreshes the UI by using IntersectionObserver API. This is the handler being called
* by the observer whenever elements come into / move out of viewport.
* @param entries
* @param observer
*/
handleIntersect(entries, observer) {
// console.time('refreshLazy');
entries.forEach(entry => {
const { target } = entry;
const fore = Fore.getFore(target);
if (fore.initialRun) return;
if (entry.isIntersecting) {
// console.log('in view', entry);
// console.log('repeat in view entry', entry.target);
// const target = entry.target;
// if(target.hasAttribute('refresh-on-view')){
target.classList.add('loaded');
// }
// todo: too restrictive here? what if target is a usual html element? shouldn't it refresh downwards?
if (typeof target.refresh === 'function') {
// console.log('refreshing target', target);
target.refresh(target, true);
} else {
// console.log('refreshing children', target);
Fore.refreshChildren(target, true);
}
}
});
entries[0].target.getOwnerForm().dispatchEvent(new CustomEvent('refresh-done'));
// console.timeEnd('refreshLazy');
}
evaluateToNodes(xpath, context) {
return evaluateXPathToNodes(xpath, context, this);
}
disconnectedCallback() {
this.removeEventListener('dragstart', this.dragstart);
/*
this.removeEventListener('model-construct-done', this._handleModelConstructDone);
this.removeEventListener('message', this._displayMessage);
this.removeEventListener('error', this._displayError);
this.storedTemplateExpressionByNode=null;
this.shadowRoot = undefined;
*/
}
/**
* refreshes the whole UI by visiting each bound element (having a 'ref' attribute) and applying the state of
* the bound modelItem to the bound element.
*
*
* force - boolean - if true will refresh all children disregarding toRefresh array
*
*/
async forceRefresh() {
// console.time('refresh');
// console.group('### forced refresh', this);
Fore.refreshChildren(this, true);
this._updateTemplateExpressions();
this._scanForNewTemplateExpressionsNextRefresh = false; // reset
this._processTemplateExpressions();
// console.log(`### <<<<< refresh-done ${this.id} >>>>>`);
Fore.dispatch(this, 'refresh-done', {});
// console.groupEnd();
// console.timeEnd('refresh');
}
// async refresh(force, changedPaths) {
/**
* @param {(boolean|{reason:'index-function'})} [force]fx-fore
*/
async refresh(force) {
/*
if (!changedPaths) {
changedPaths = this.toRefresh.map(item => item.path);
} else {
this.toRefresh.push(
...changedPaths
.map(
path =>
this.getModel()
.modelItems
.find(item => item.path === path)
)
.filter(Boolean)
);
for(const changedPath of changedPaths) {
for (const repeat of this.querySelectorAll('fx-repeat')) {
if (repeat.closest('fx-fore') !== this) {
continue;
}
if (repeat.touchedPaths && repeat.touchedPaths.has(changedPath)) {
// Make a temporary model-item-like structure for this
this.toRefresh.push({
path: changedPath,
boundControls: [repeat]
});
console.log('Found a repeat to update!!!', repeat)
}
}
}
}
*/
if (this.isRefreshing) {
return;
}
this.isRefreshing = true;
// console.log(`### <<<<< refresh() on '${this.id}' >>>>>`);
// refresh () {
// ### refresh Fore UI elements
// if (!this.initialRun && this.toRefresh.length !== 0) {
if (!force && !this.initialRun && this.toRefresh.length !== 0) {
// console.log('toRefresh', this.toRefresh);
let needsRefresh = false;
// ### after recalculation the changed modelItems are copied to 'toRefresh' array for processing
this.toRefresh.forEach(modelItem => {
// check if modelItem has boundControls - if so, call refresh() for each of them
const controlsToRefresh = modelItem.boundControls;
if (controlsToRefresh) {
controlsToRefresh.forEach(ctrl => {
ctrl.refresh(force);
});
}
// ### check if other controls depend on current modelItem
const { mainGraph } = this.getModel();
if (mainGraph && mainGraph.hasNode(modelItem.path)) {
const deps = this.getModel().mainGraph.dependentsOf(modelItem.path, false);
// ### iterate dependant modelItems and refresh all their boundControls
if (deps.length !== 0) {
deps.forEach(dep => {
// ### if changed modelItem has a 'facet' path we use the basePath that is the locationPath without facet name
const basePath = XPathUtil.getBasePath(dep);
const modelItemOfDep = this.getModel().modelItems.find(mip => mip.path === basePath);
// ### refresh all boundControls
modelItemOfDep.boundControls.forEach(control => {
control.refresh(force);
});
});
needsRefresh = true;
}
}
});
this.toRefresh = [];
/*
if (!needsRefresh) {
console.log('no dependants to refresh');
}
*/
} else {
// ### resetting visited state for controls to refresh
/*
const visited = this.parentNode.querySelectorAll('.visited');
Array.from(visited).forEach(v =>{
v.classList.remove('visited');
});
*/
if (this.inited) {
Fore.refreshChildren(this, force);
}
// console.timeEnd('refreshChildren');
}
// ### refresh template expressions
if (this.initialRun || this._scanForNewTemplateExpressionsNextRefresh) {
this._updateTemplateExpressions();
this._scanForNewTemplateExpressionsNextRefresh = false; // reset
}
this._processTemplateExpressions();
// console.log('### <<<<< dispatching refresh-done - end of UI update cycle >>>>>');
// this.dispatchEvent(new CustomEvent('refresh-done'));
// this.initialRun = false;
this.style.visibility = 'visible';
console.info(
`%crefresh-done on #${this.id}`,
'background:darkorange; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
);
Fore.dispatch(this, 'refresh-done', {});
// this.isRefreshing = true;
// this.parentNode.closest('fx-fore')?.refresh(false, changedPaths);
const subFores = Array.from(this.querySelectorAll('fx-fore'));
/*
calling the parent to refresh causes errors and inconsistent state. Also it is questionable
if a child should actually interact with its parent in this way.
This only affects the refreshing NOT the data mutation itself which is happening as expected.
Current solution is that a child that wants the parent to refresh must do so by adding an additional
event handler that dispatches an event upwards and having a handler in the parent to refresh itself.
So refreshed propagate downwards but not upwards which is at least an option to consider.
if(this.parentNode.nodeType !== Node.DOCUMENT_FRAGMENT_NODE){
// await this.parentNode.closest('fx-fore')?.refresh(false);
}
*/
for (const subFore of subFores) {
// subFore.refresh(false, changedPaths);
if (subFore.ready) {
await subFore.refresh(force);
}
}
this.isRefreshing = false;
}
/**
* entry point for processing of template expression enclosed in '{}' brackets.
*
* Expressions are found with an XPath search. For each node an entry is added to the storedTemplateExpressionByNode map.
*
*
* @private
*/
_updateTemplateExpressions() {
const search =
"(descendant-or-self::*!(text(), @*))[contains(., '{')][substring-after(., '{') => contains('}')][not(ancestor-or-self::fx-model)]";
const tmplExpressions = evaluateXPathToNodes(search, this, this);
// console.log('template expressions found ', tmplExpressions);
if (!this.storedTemplateExpressions) {
this.storedTemplateExpressions = [];
}
// console.log('######### storedTemplateExpressions', this.storedTemplateExpressions.length);
/*
storing expressions and their nodes for re-evaluation
*/
Array.from(tmplExpressions).forEach(node => {
const ele = node.nodeType === Node.ATTRIBUTE_NODE ? node.ownerElement : node.parentNode;
if (ele.closest('fx-fore') !== this) {
// We found something in a sub-fore. Act like it's not there
return;
}
if (this.storedTemplateExpressionByNode.has(node)) {
// If the node is already known, do not process it twice
return;
}
const expr = this._getTemplateExpression(node);
// console.log('storedTemplateExpressionByNode', this.storedTemplateExpressionByNode);
if (expr) {
this.storedTemplateExpressionByNode.set(node, expr);
}
});
// console.log('stored template expressions ', this.storedTemplateExpressionByNode);
// TODO: Should we clean up nodes that existed but are now gone?
this._processTemplateExpressions();
}
_processTemplateExpressions() {
for (const node of Array.from(this.storedTemplateExpressionByNode.keys())) {
if (node.nodeType === Node.ATTRIBUTE_NODE) {
// Attribute nodes are not contained by the document, but their owner elements are!
if (!XPathUtil.contains(this, node.ownerElement)) {
this.storedTemplateExpressionByNode.delete(node);
continue;
}
} else if (!XPathUtil.contains(this, node)) {
// For all other nodes, if this `fore` element does not contain them, they are dead
this.storedTemplateExpressionByNode.delete(node);
continue;
}
this._processTemplateExpression({
node,
expr: this.storedTemplateExpressionByNode.get(node),
});
}
}
// eslint-disable-next-line class-methods-use-this
_processTemplateExpression(exprObj) {
// console.log('processing template expression ', exprObj);
const { expr } = exprObj;
const { node } = exprObj;
// console.log('expr ', expr);
this.evaluateTemplateExpression(expr, node);
}
/**
* evaluate a template expression on a node either text- or attribute node.
* @param {string} expr The string to parse for expressions
* @param {Node} node the node which will get updated with evaluation result
*/
evaluateTemplateExpression(expr, node) {
// ### do not evaluate template expressions with nonrelevant sections
if (node.nodeType === Node.ATTRIBUTE_NODE && node.ownerElement.closest('[nonrelevant]')) return;
if (node.nodeType === Node.TEXT_NODE && node.parentNode.closest('[nonrelevant]')) return;
if (node.nodeType === Node.ELEMENT_NODE && node.closest('[nonrelevant]')) return;
// if(node.closest('[nonrelevant]')) return;
const replaced = expr.replace(/{[^}]*}/g, match => {
if (match === '{}') return match;
const naked = match.substring(1, match.length - 1);
const inscope = getInScopeContext(node, naked);
if (!inscope) {
console.warn('no inscope context for expr', naked);
const errNode =
node.nodeType === Node.TEXT_NODE || node.nodeType === Node.ATTRIBUTE_NODE
? node.parentNode
: node;
return match;
}
// Templates are special: they use the namespace configuration from the place where they are
// being defined
const instanceId = XPathUtil.getInstanceId(naked);
// If there is an instance referred
const inst = instanceId
? this.getModel().getInstance(instanceId)
: this.getModel().getDefaultInstance();
try {
return evaluateXPathToString(naked, inscope, node, null, inst);
} catch (error) {
console.warn('ignoring unparseable expr', error);
return match;
}
});
// Update to the new value. Don't do it though if nothing changed to prevent iframes or
// images from reloading for example
if (node.nodeType === Node.ATTRIBUTE_NODE) {
const parent = node.ownerElement;
if (parent.getAttribute(node.nodeName) !== replaced) {
parent.setAttribute(node.nodeName, replaced);
}
} else if (node.nodeType === Node.TEXT_NODE) {
if (node.textContent !== replaced) {
node.textContent = replaced;
}
}
}
// eslint-disable-next-line class-methods-use-this
_getTemplateExpression(node) {
if (this.ignoredNodes) {
if (node.nodeType === Node.ATTRIBUTE_NODE) {
node = node.ownerElement;
}
const found = this.ignoredNodes.find(n => n.contains(node));
if (found) return null;
}
if (node.nodeType === Node.ATTRIBUTE_NODE) {
return node.value;
}
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent.trim();
}
return null;
}
/**
* called when `model-construct-done` event is received to
* start initing the UI.
*
* @private
*/
_handleModelConstructDone() {
this.markAsClean();
if (this.showConfirmation) {
window.addEventListener('beforeunload', event => {
if (this.dirtyState === dirtyStates.DIRTY) {
event.preventDefault();
return true;
}
return false;
});
}
this._initUI();
}
/**
* If there's no instance element found in a fx-model during init it will construct
* an instance from UI bindings.
*
* @returns {Promise<void>}
* @private
*/
async _lazyCreateInstance() {
const model = this.querySelector('fx-model');
// ##### lazy creation should NOT take place if there's a parent Fore using shared instances
const parentFore =
this.parentNode.nodeType !== Node.DOCUMENT_FRAGMENT_NODE
? this.parentNode.closest('fx-fore')
: null;
if (this.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
console.log('fragment', this.parentNode);
}
if (parentFore) {
const shared = parentFore
.getModel()
.instances.filter(shared => shared.hasAttribute('shared'));
if (shared.length !== 0) return;
}
// still need to catch just in case...
try {
if (model.instances.length === 0) {
// console.log('### lazy creation of instance');
const generatedInstance = document.createElement('fx-instance');
model.appendChild(generatedInstance);
const generated = document.implementation.createDocument(null, 'data', null);
// const newData = this._generateInstance(this, generated.firstElementChild);
this._generateInstance(this, generated.firstElementChild);
generatedInstance.instanceData = generated;
model.instances.push(generatedInstance);
// console.log('generatedInstance ', this.getModel().getDefaultInstanceData());
Fore.dispatch(this, 'instance-loaded', { instance: this });
}
} catch (e) {
console.warn(
'lazyCreateInstance created an error attempting to create a document',
e.message,
);
}
}
/**
* @param {Element} start
* @param {Element} parent
*/
_generateInstance(start, parent) {
if (start.hasAttribute('ref') && !Fore.isActionElement(start.nodeName)) {
const ref = start.getAttribute('ref');
if (ref.includes('/')) {
// console.log('complex path to create ', ref);
const steps = ref.split('/');
steps.forEach(step => {
// const generated = document.createElement(ref);
parent = this._generateNode(parent, step, start);
});
} else {
parent = this._generateNode(parent, ref, start);
}
}
if (start.hasChildNodes()) {
const list = start.children;
for (let i = 0; i < list.length; i += 1) {
this._generateInstance(list[i], parent);
}
}
return parent;
}
// eslint-disable-next-line class-methods-use-this
_generateNode(parent, step, start) {
const generated = parent.ownerDocument.createElement(step);
if (start.children.length === 0) {
generated.textContent = start.textContent;
}
parent.appendChild(generated);
parent = generated;
return parent;
}
/*
_createStep(){
}
*/
/*
_generateInstance(start, parent) {
if (start.hasAttribute('ref')) {
const ref = start.getAttribute('ref');
if(ref.includes('/')){
console.log('complex path to create ', ref);
const steps = ref.split('/');
steps.forEach(step => {
console.log('step ', step);
});
}
// const generated = document.createElement(ref);
const generated = parent.ownerDocument.createElement(ref);
if (start.children.length === 0) {
generated.textContent = start.textContent;
}
parent.appendChild(generated);
parent = generated;
}
if (start.hasChildNodes()) {
const list = start.children;
for (let i = 0; i < list.length; i += 1) {
this._generateInstance(list[i], parent);
}
}
return parent;
}
*/
/**
* Start the initialization of the UI by
*
* 1. checking if a instance needs to be generated
* 2. attaching lazy loading intersection observers if `refresh-on-view` attributes are found
* 3. doing a full refresh of the UI
*
* @returns {Promise<void>}
* @private
*/
async _initUI() {
// console.log('### _initUI()');
console.info(
`%cinitUI #${this.id}`,
'background:lightblue; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
);
if (!this.initialRun) return;
this.classList.add('initialRun');
await this._lazyCreateInstance();
/*
const options = {
root: null,
rootMargin: '0px',
threshold: 0.3,
};
*/
// First refresh should be forced
if (this.createNodes) {
this.initData();
}
await this.refresh(true);
// this.style.display='block'
this.classList.add('fx-ready');
document.body.classList.add('fx-ready');
this.ready = true;
this.initialRun = false;
// console.log('### >>>>> dispatching ready >>>>>', this);
console.info(
`%c #${this.id} is ready`,
'background:lightblue; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
);
// console.log(`### <<<<< ${this.id} ready >>>>>`);
// console.log('### modelItems: ', this.getModel().modelItems);
Fore.dispatch(this, 'ready', {});
// console.log('dataChanged', FxModel.dataChanged);
console.timeEnd('init');
this.addEventListener('dragstart', this._handleDragStart);
// this.addEventListener('dragend', this._handleDragEnd);
this.handleDrop = event => this._handleDrop(event);
this.ownerDocument.body.addEventListener('drop', this.handleDrop);
this.ownerDocument.body.addEventListener('dragover', e => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
});
}
/**
* @param {HTMLElement} root The root of the data initialization. fx-repeat overrides this when it makes new repeat items
*
*/
initData(root = this) {
// const created = new Promise(resolve => {
console.log('INIT');
const boundControls = Array.from(root.querySelectorAll('[ref]:not(fx-model *),fx-repeatitem'));
if (root.matches('fx-repeatitem')) {
boundControls.unshift(root);
}
// console.log('_initD', boundControls);
for (let i = 0; i < boundControls.length; i++) {
const control = boundControls[i];
if (!control.matches('fx-repeatitem')) {
// Repeat items are dumb. They do not respond to evalInContext
control.evalInContext();
}
let ownerDoc;
if (control.nodeset !== null) {
// console.log('Node exists', control.nodeset);
continue;
}
// console.log('Node does not exists', control.ref);
// We need to create that node!
const previousControl = boundControls[i - 1];
// Previous control can either be an ancestor of us, or a previous node, which can be a sibling, or a child of a sibling.
// First: parent
if (previousControl.contains(control)) {
// Parent is here.
// console.log('insert into', control,previousControl);
// console.log('insert into nodeset', control.nodeset);
const parentNodeset = previousControl.nodeset;
// console.log('parentNodeset', parentNodeset);
// const parentModelItemNode = parentModelItem.node;
const ref = control.ref;
// const newElement = parentModelItemNode.ownerDocument.createElement(ref);
if (parentNodeset.querySelector(`[ref="${ref}"]`)) {
console.log(`Node with ref "${ref}" already exists.`);
continue;
}
const newElement = this._createNodes(ref, parentNodeset);
// Plonk it in at the start!
parentNodeset.insertBefore(newElement, parentNodeset.firstChild);
control.evalInContext();
console.log('CREATED child', newElement);
// console.log('new control evaluated to ', control.nodeset);
// Done!
continue;
}
// console.log('previousControl', previousControl);
// console.log('control', control);
// Is previousControl a sibling or a descendant of a logical sibling? Keep looking backwards until we share parents!
const ourParent = XPathUtil.getParentBindingElement(control);
// console.log('ourParent', ourParent);
let siblingControl = null;
/*
for (let j = i - 1; j >= 0; --j) {
const potentialSibling = boundControls[j];
if (XPathUtil.getParentBindingElement(potentialSibling) === ourParent) {
siblingControl = potentialSibling;
break; // Exit once the sibling is found
}
}
*/
for (let j = i - 1; j > 0; --j) {
const siblingOrDescendant = boundControls[j];
if (XPathUtil.getParentBindingElement(siblingOrDescendant) === ourParent) {
siblingControl = siblingOrDescendant;
break;
}
}
if (!siblingControl) {
throw new Error('Unexpected! there must be a sibling right?');
}
// console.log('sibling', siblingControl);
const parentNodeset = ourParent.nodeset;
const ref = control.ref;
let referenceNodeset = siblingControl.nodeset;
const newElement = this._createNodes(ref, parentNodeset);
// We know which node to insert this new element to, but it might be a descendant of a child of the actual parent. Walk up until we have a reference under our parent
while (referenceNodeset?.parentNode && referenceNodeset?.parentNode !== parentNodeset) {
referenceNodeset = referenceNodeset.parentNode;
}
// Insert before the next sibling our our logical previous sibling
parentNodeset.insertBefore(newElement, referenceNodeset.nextElementSibling);
/*
console.log('control inscope', control.getInScopeContext());
console.log('control ref', control.ref);
console.log('control new element parent', newElement.parentNode.nodeName);
*/
control.evalInContext();
// console.log('new control evaluated to ', control.nodeset);
console.log('CREATED sibling', newElement);
}
// console.log('DATA', this.getModel().getDefaultContext());
}
_createNodes(ref, referenceNode) {
// console.log('creating', ref)
// console.log('ownerDoc', referenceNode.ownerDocument);
/*
const existingNode = evaluateXPathToFirstNode(ref, referenceNode, this);
if(existingNode){
console.log(`Node already exists for ref: ${ref}`);
return existingNode;
}
*/
console.log(`creating new node for ref: ${ref}`);
let newElement;
if (ref.includes('/')) {
// multi-step ref expressions
newElement = XPathUtil.createElementFromXPath(ref, referenceNode.ownerDocument, this);
// console.log('new subtree', newElement);
return newElement;
} else {
return XPathUtil.createElementFromXPath(ref, referenceNode.ownerDocument, this);
}
}
_handleDragStart(event) {
const draggedItem = event.target.closest('[draggable="true"]');
this.originalDraggedItem = draggedItem;
console.log('DRAG START', this);
if (draggedItem.getAttribute('drop-action') === 'copy') {
event.dataTransfer.dropEffect = 'copy';
event.dataTransfer.effectAllowed = 'copy';
this.draggedItem = draggedItem.cloneNode(true);
this.draggedItem.setAttribute('drop-action', 'move');
this.copiedElements.add(this.draggedItem);
} else {
event.dataTransfer.dropEffect = 'move';
event.dataTransfer.effectAllowed = 'move';
this.draggedItem = draggedItem;
}
}
_handleDrop(event) {
console.log('DROP ON BODY', this);
if (!this.draggedItem) {
return;
}
// A drop on 'body' should be a removal.
if (event.dataTransfer.dropEffect === 'none') {
if (this.copiedElements.has(this.originalDraggedItem)) {
this.originalDraggedItem.remove();
}
}
this.originalDraggedItem = null;
this.draggedItem = null;
event.stopPropagation();
}
registerLazyElement(element) {
if (this.intersectionObserver) {
// console.log('registerLazyElement',element);
this.intersectionObserver.observe(element);
}
}
unRegisterLazyElement(element) {
if (this.intersectionObserver) {
this.intersectionObserver.unobserve(element);
}
}
/**
*
* @returns {FxModel}
*/
getModel() {
return this.querySelector('fx-model');
}
_displayMessage(e) {
// console.log('_displayMessage',e);
const { level } = e.detail;
const msg = e.detail.message;
this._showMessage(level, msg);
e.stopPropagation();
}
_displayError(e) {
// const { error } = e.detail;
const msg = e.detail.message;
// this._showMessage('modal', msg);
const toast = this.shadowRoot.querySelector('#error');
toast.showToast(msg);
}
_displayWarning(e) {
const msg = e.detail.message;
// this._showMessage('modal', msg);
const path = XPathUtil.shortenPath(evaluateXPathToString('path()', e.target, this));
const toast = this.shadowRoot.querySelector('#warn');
toast.showToast(`WARN: ${path}:${msg}`);
}
_logError(e) {
e.stopPropagation();
e.preventDefault();
console.error('ERROR', e.detail.message);
console.error(e.detail.origin);
if (e.detail.expr) {
console.error('Failing expression', e.detail.expr);
}
if (this.strict) {
this._displayError(e);
}
}
_copyToClipboard(target) {
console.log('copyToClipboard', target.value);
navigator.clipboard.writeText(target.value);
}
_showMessage(level, msg) {
if (level === 'modal') {
// this.$.messageContent.innerText = msg;
// this.$.modalMessage.open();
this.shadowRoot.getElementById('messageContent').innerText = msg;
// this.shadowRoot.getElementById('modalMessage').open();
this.shadowRoot.getElementById('modalMessage').classList.add('show');
} else if (level === 'sticky' || level === 'error' || level === 'warn') {
// const notification = this.$.modeless;
this.shadowRoot.querySelector(`#${level}`).showToast(msg);
} else {
const toast = this.shadowRoot.querySelector('#message');
toast.showToast(msg);
}
}
/**
* wraps the element having a 'data-ref' attribute with an fx-repeat-attributes element.
* @private
*/
_createRepeatsFromAttributes() {
if (this.repeatsFromAttributesCreated) return;
const repeats = this.querySelectorAll('[data-ref]');
if (repeats) {
Array.from(repeats).forEach(item => {
if (item.closest('fx-control')) return;
/*
const parentRepeat = item.closest('fx-repeat');
if(parentRepeat){
this.dispatchEvent(
new CustomEvent('log', {
composed: false,
bubbles: true,
cancelable:true,
detail: { id:this.id, message: `nesting elements with data-ref attributes within fx-repeat is not supported by now`, level:'Error'},
}),
);
}
*/
const table = item.parentNode.closest('table');
let host;
if (table) {
host = table.cloneNode(true);
} else {
host = item.cloneNode(true);
}
// ### clone original item to move it into fx-repeat-attributes
// const host = item.cloneNode(true);
// ### create wrapper element
const repeatFromAttr = new FxRepeatAttributes();
// const repeatFromAttr = document.createElement('fx-repeat-attributes');
// ### copy the value of 'data-ref' to 'ref' on fx-repeat-attributes
repeatFromAttr.setAttribute('ref', item.getAttribute('data-ref'));
// item.removeAttribute('data-ref');
// ### append the cloned original element to fx-repeat-attributes
repeatFromAttr.appendChild(host);
// ### insert fx-repeat-attributes element before element with the 'data-ref'
// repeats[0].parentNode.insertBefore(repeatFromAttr,repeats[0]);
if (table) {
table.parentNode.insertBefore(repeatFromAttr, table);
table.parentNode.removeChild(table);
} else {
item.parentNode.insertBefore(repeatFromAttr, item);
item.parentNode.removeChild(item);
}
// ### remove original item from DOM
item.setAttribute('insertPoint', '');
});
}
this.repeatsFromAttributesCreated = true;
}
}
if (!customElements.get('fx-fore')) {
customElements.define('fx-fore', FxFore);
}