UNPKG

@jinntec/fore

Version:

Fore - declarative user interfaces in plain HTML

666 lines (584 loc) 21.9 kB
import { Fore } from './fore.js'; import { Relevance } from './relevance.js'; import { evaluateXPath } from './xpath-evaluation.js'; import ForeElementMixin from './ForeElementMixin.js'; /** * todo: validate='false' */ export class FxSubmission extends ForeElementMixin { constructor() { super(); this.attachShadow({ mode: 'open' }); this.credentials = ''; this.parameters = new Map(); } connectedCallback() { // this.style.display = 'none'; this.methods = ['get', 'put', 'post', 'delete', 'head', 'urlencoded-post']; this.model = this.parentNode; // ### initialize properties with defaults // if (!this.hasAttribute('id')) throw new Error('id is required'); if (!this.hasAttribute('id')) console.warn('id is required'); this.id = this.getAttribute('id'); /** if present should be a existing instance id */ this.instance = this.hasAttribute('instance') ? this.getAttribute('instance') : null; /** if present will determine XPath where to insert a response into when mode is 'replace' */ this.into = this.hasAttribute('into') ? this.getAttribute('into') : null; /** http method */ this.method = this.hasAttribute('method') ? this.getAttribute('method') : 'get'; /** relevance processing - one of 'remove, keep or empty' */ this.nonrelevant = this.hasAttribute('nonrelevant') ? this.getAttribute('nonrelevant') : 'remove'; /** replace might be 'all', 'instance' or 'none' */ this.replace = this.hasAttribute('replace') ? this.getAttribute('replace') : 'all'; this.serialization = this.hasAttribute('serialization') ? this.getAttribute('serialization') : 'xml'; this.mediatype = this.hasAttribute('mediatype') ? this.getAttribute('mediatype') : 'application/xml'; this.responseMediatype = this.hasAttribute('responsemediatype') ? this.getAttribute('responsemediatype') : this.mediatype; this.url = this.hasAttribute('url') ? this.getAttribute('url') : null; this.targetref = this.hasAttribute('targetref') ? this.getAttribute('targetref') : null; this.validate = this.getAttribute('validate') ? this.getAttribute('validate') : 'true'; this.credentials = this.hasAttribute('credentials') ? this.getAttribute('credentials') : 'same-origin'; if (!['same-origin', 'include', 'omit'].includes(this.credentials)) { console.error( `fx-submission: the value of credentials is not valid. Expected 'same-origin', 'include' or 'omit' but got '${this.credentials}'`, this, ); } this.shadowRoot.innerHTML = this.renderHTML(); } // eslint-disable-next-line class-methods-use-this renderHTML() { return ` <slot></slot> `; } async submit() { await Fore.dispatch(this, 'submit', { submission: this }); await this._submit(); } async _submit() { console.info(`🚀 #${this.id}`); this.evalInContext(); const model = this.getModel(); model.recalculate(); if (this.validate === 'true' && this.method !== 'get') { const valid = model.revalidate(); if (!valid) { console.log('validation failed. Submission stopped'); this.getOwnerForm().classList.add('submit-validation-failed'); // ### allow alerts to pop up // this.dispatch('submit-error', {}); Fore.dispatch(this, 'submit-error', { status: 0, message: 'validation failed' }); this.getModel().parentNode.refresh(true); return; } } await this._serializeAndSend(); } _getProperty(attrName) { if (this.parameters.has(attrName)) { return this.parameters.get(attrName); } return this.getAttribute(attrName); } /** * sends the data after evaluating * * @private */ async _serializeAndSend() { const url = this._getProperty('url'); const resolvedUrl = this.evaluateAttributeTemplateExpression(url, this); const instance = this.getInstance(); if (!instance) { Fore.dispatch(this, 'warn', { message: `instance not found ${instance?.getAttribute?.('id')}` }); } const instType = instance.getAttribute('type'); let serialized; if (this.serialization === 'none') { serialized = undefined; } else { const relevant = Relevance.selectRelevant(this, instType); serialized = this._serialize(instance, relevant); } if (this.method.toLowerCase() === 'get') { serialized = undefined; } // --- echo / localStore shortcuts --- if (resolvedUrl.startsWith('#echo')) { if (this.replace === 'download') { await this._handleResponse(serialized, resolvedUrl, 'application/xml'); } else { const data = this._parse(serialized, instance); await this._handleResponse(data, resolvedUrl, 'application/xml'); } console.log('### <<<<< submit-done >>>>>'); Fore.dispatch(this, 'submit-done', {}); this.parameters.clear(); return; } if (resolvedUrl.startsWith('localStore:')) { if (this.method === 'get' || this.method === 'consume') { this.replace = 'instance'; const key = resolvedUrl.substring(resolvedUrl.indexOf(':') + 1); const stored = localStorage.getItem(key); if (!stored) { Fore.dispatch(this, 'submit-error', { status: 400, message: `Error reading key ${key} from localstorage`, }); this.parameters.clear(); return; } const data = this._parse(stored, instance); await this._handleResponse(data); if (this.method === 'consume') { localStorage.removeItem(key); } console.log('### <<<<< submit-done >>>>>'); Fore.dispatch(this, 'submit-done', {}); } if (this.method === 'post') { const key = resolvedUrl.substring(resolvedUrl.indexOf(':') + 1); localStorage.setItem(key, serialized); await this._handleResponse(instance.instanceData); console.log('### <<<<< submit-done >>>>>'); Fore.dispatch(this, 'submit-done', {}); } if (this.method === 'delete') { const key = resolvedUrl.substring(resolvedUrl.indexOf(':') + 1); localStorage.removeItem(key); const newInst = new DOMParser().parseFromString('<data></data>', 'application/xml'); this.replace = 'instance'; await this._handleResponse(newInst); console.log('### <<<<< submit-done >>>>>'); Fore.dispatch(this, 'submit-done', {}); } return; } // --- network fetch --- const headers = this._getHeaders(); if (!this.methods.includes(this.method.toLowerCase())) { Fore.dispatch(this, 'error', { message: `Unknown method ${this.method}` }); return; } try { const response = await fetch(resolvedUrl, { method: this.method, credentials: this.credentials, mode: 'cors', headers, body: serialized, }); if (!response.ok || response.status > 400) { console.info( `%csubmit-error #${this.id}`, 'background:red; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;', ); Fore.dispatch(this, 'submit-error', { status: response.status, message: `Error during submit ${this.id}`, }); return; } const contentType = response.headers.get('content-type').split(';')[0].trim().toLowerCase(); if (contentType.endsWith('/xml') || contentType.endsWith('+xml')) { const text = await response.text(); const xml = new DOMParser().parseFromString(text, 'application/xml'); await this._handleResponse(xml, resolvedUrl, contentType); } else if (contentType.startsWith('text/')) { const text = await response.text(); await this._handleResponse(text, resolvedUrl, contentType); } else if (contentType.endsWith('/json') || contentType.endsWith('+json')) { const json = await response.json(); await this._handleResponse(json, resolvedUrl, contentType); } else { const blob = await response.blob(); await this._handleResponse(blob, resolvedUrl, contentType); } Fore.dispatch(this, 'submit-done', {}); } catch (error) { Fore.dispatch(this, 'submit-error', { status: 500, error: error.message }); } finally { this.parameters.clear(); const download = document.querySelector('[download]'); if (download) { document.body.removeChild(download); } } } _parse(serialized, instance) { let data = null; if (serialized && instance.getAttribute('type') === 'xml') { data = new DOMParser().parseFromString(serialized, 'application/xml'); } if (serialized && instance.getAttribute('type') === 'json') { data = JSON.parse(serialized); } if (serialized && instance.getAttribute('type') === 'text') { data = serialized; } return data; } /** * Serialize the submission payload depending on instance type. * * - XML instances => XML serialization (existing behavior) * - JSON instances => JSON serialization from plain JS (NOT from JSONNode lens objects) * * @param {import('./fx-instance.js').FxInstance | null} instanceEl * @param {any} data * @returns {string} */ _serialize(instanceEl, data) { // If the caller passed an explicit "data", prefer it; otherwise serialize the instance. let payload = data; // Resolve instance if not provided explicitly if (!instanceEl) { const model = this.getOwnerForm()?.getModel?.(); const instanceId = this.getAttribute('instance') || 'default'; instanceEl = model?.getInstance?.(instanceId) || null; } // If no payload was passed, derive it from instance default context/nodeset if (payload == null && instanceEl) { payload = (typeof instanceEl.getDefaultContext === 'function' && instanceEl.getDefaultContext()) || instanceEl.nodeset || null; } // Decide JSON vs XML by instance type (NOT by ref expression) const isJsonInstance = instanceEl?.getAttribute?.('type') === 'json' || instanceEl?.type === 'json'; if (isJsonInstance) { // Convert JSON lens nodes to plain JS before stringify const plain = this._toPlainJson(payload); // NOTE: you can pass spacing here if you want pretty output: // return JSON.stringify(plain, null, 2); return JSON.stringify(plain); } // --- XML / default path --- // Keep existing XML behavior: if payload is a DOM node/document, serialize as XML. // If payload is a string, return as-is. if (typeof payload === 'string') return payload; try { if (payload && payload.nodeType) { // Document => serialize documentElement, Node => serialize node const node = payload.nodeType === Node.DOCUMENT_NODE ? payload.documentElement : payload; return new XMLSerializer().serializeToString(node); } } catch (_e) { // fallthrough } // As a last resort for non-XML odd payloads: return String(payload ?? ''); } /** * Convert a JSON lens node (JSONNode) or other value into plain JSON (no circular refs). * This must NEVER return JSONNode objects. * * @param {any} v * @returns {any} plain JSON value */ _toPlainJson(v) { if (v == null) return null; // If it's already a plain primitive, keep it const t = typeof v; if (t === 'string' || t === 'number' || t === 'boolean') return v; // JSON lens node (your JSONNode objects) if (v.__jsonlens__ === true) { return this._jsonLensNodeToPlain(v); } // Arrays: convert elements if (Array.isArray(v)) { return v.map(x => this._toPlainJson(x)); } // Plain objects: best-effort convert (should be rare here) // Avoid circular refs by only copying own enumerable props. const out = {}; for (const [k, val] of Object.entries(v)) { out[k] = this._toPlainJson(val); } return out; } /** * Convert a single JSON lens node (JSONNode) into a plain JS value by traversing children. * * Assumptions (based on your JSON lens structure): * - node.value holds the underlying JS value for leaf nodes * - node.children is an array for arrays/objects * - node.get(keyOrIndex) returns child node for objects/arrays * * This function intentionally does NOT touch node.parent. * * @param {any} node JSONNode * @returns {any} */ _jsonLensNodeToPlain(node) { // If node.value is a primitive or null, return it // (Many JSON lens implementations store actual scalar in .value) const val = node.value; if ( val === null || val === undefined || typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean' ) { return val ?? null; } // If the node represents an array if (Array.isArray(val)) { // Prefer node.children if present; fall back to val (might be raw JS) const kids = Array.isArray(node.children) ? node.children : val; return kids.map(child => this._toPlainJson(child)); } // If the node represents an object if (typeof val === 'object') { // If this is already a plain JS object (not a JSONNode), convert it // but in lens setups val might be plain object while children are lens nodes. const result = {}; // Prefer iterating keys from val for (const key of Object.keys(val)) { // Try lens navigation first if (typeof node.get === 'function') { const child = node.get(key); result[key] = this._toPlainJson(child ?? val[key]); } else { result[key] = this._toPlainJson(val[key]); } } return result; } 1 // Fallback: last-resort scalar conversion try { if (typeof node.get === 'function') { // Some lens nodes return scalar via get() return this._toPlainJson(node.get()); } } catch (_e) { // ignore } return String(val); } _getHeaders() { const headers = new Headers(); // ### set content-type header according to type of instance const instance = this.getInstance(); const contentType = Fore.getContentType(instance, this.serialization); headers.append('Content-Type', contentType); // ### needed to overwrite browsers' setting of 'Accept' header if (headers.has('Accept')) { headers.delete('Accept'); } // headers.append('Accept', 'application/xml'); // ### add header defined by fx-header elements const headerElems = this.querySelectorAll('fx-header'); Array.from(headerElems).forEach(header => { const { name } = header; const val = header.getValue(); headers.append(name, val); }); return headers; } _getUrlExpr() { return this.storedTemplateExpressions.find(stored => stored.node.nodeName === 'url'); } _getTargetInstance() { let targetInstance; if (this.instance) { targetInstance = this.model.getInstance(this.instance); } else { targetInstance = this.model.getInstance('default'); } if (!targetInstance) { throw new Error(`target instance not found: ${targetInstance}`); } return targetInstance; } /** * handles replacement of instance data from response data. * * Please note that data might be * @param data * @private */ async _handleResponse(data, resolvedUrl, contentType) { const targetInstance = this._getTargetInstance(); if (this.replace === 'instance') { // ### contentType handling (HTML special-case) if (contentType && contentType.includes('html')) { let effectiveData = data; if (!data?.nodeType) { try { effectiveData = new DOMParser().parseFromString(data, 'text/html'); } catch { Fore.dispatch(this, 'error', { message: 'could not parse data as HTML' }); } } targetInstance.instanceData = effectiveData; } if (!targetInstance) { throw new Error(`target instance not found: ${targetInstance}`); } if (this.targetref) { const [theTarget] = evaluateXPath( this.targetref, targetInstance.instanceData.firstElementChild, this, ); if ( this.responseMediatype === 'application/xml' || this.responseMediatype === 'text/html' ) { const clone = data.firstElementChild; const parent = theTarget.parentNode; parent.replaceChild(clone, theTarget); } if (this.responseMediatype && this.responseMediatype.startsWith('text/')) { theTarget.textContent = data; } if (this.responseMediatype === 'application/json') { console.warn('targetref is not supported for application/json responses'); } } else if (this.into) { const [theTarget] = evaluateXPath( this.into, targetInstance.instanceData.firstElementChild, this, ); if (data?.nodeType === Node.DOCUMENT_NODE) { theTarget.appendChild(data.firstElementChild); } else { theTarget.innerHTML = data; } } else { // ✅ This is the critical replace="instance" case targetInstance.instanceData = data; } // Skip any refreshes if the model is not yet inited if (this.model.inited) { // Rebuild model items / binds against the new instance root this.model.updateModel(); // ✅ treat instance replacement as a structural change const fore = (typeof this.getOwnerForm === 'function' && this.getOwnerForm()) || this.closest('fx-fore') || this.getModel()?.parentNode; if (fore) { fore.someInstanceDataStructureChanged = true; if (typeof fore.scanForNewTemplateExpressionsNextRefresh === 'function') { fore.scanForNewTemplateExpressionsNextRefresh(); } // ✅ IMPORTANT: await, otherwise tests/action-pipeline can out-run the refresh await fore.refresh(true); } } return; } if (this.replace === 'download') { const target = this._getProperty('target'); if (!target) { throw new Error(`${this.id} needs to specify "target" attribute`); } const downloadLink = document.createElement('a'); downloadLink.setAttribute('download', target); downloadLink.setAttribute('href', `data:${contentType},${encodeURIComponent(data)}`); document.body.appendChild(downloadLink); downloadLink.click(); return; } if (this.replace === 'all') { const target = this._getProperty('target'); if (target && target === '_blank') { const win = window.open('', '_blank'); win.document.write(`<pre>${data}</pre>`); win.document.close(); } else { document.open(); document.write(data); document.close(); window.location.href = resolvedUrl; } return; } if (this.replace === 'target') { const target = this._getProperty('target'); const targetNode = document.querySelector(target); if (targetNode) { if (contentType && contentType.startsWith('text/html')) { targetNode.innerHTML = data; } if (this.responseMediatype && this.responseMediatype.startsWith('image/svg')) { const objectURL = URL.createObjectURL(data); targetNode.src = objectURL; } } else { Fore.dispatch(this, 'submit-error', { message: `targetNode for selector ${target} not found`, }); } return; } if (this.replace === 'redirect') { window.location.href = data; } } /* _handleError() { // this.dispatch('submit-error', {}); Fore.dispatch(this, 'submit-error', {}); /!* console.log('ERRRORRRRR'); this.dispatchEvent( new CustomEvent('submit-error', { composed: true, bubbles: true, detail: {}, }), ); *!/ } */ /* mergeNodes(node1, node2) { // Overwrite attributes in node1 with values from node2 for (const { name, value } of node2.attributes) { node1.setAttribute(name, value); } const childNodes1 = Array.from(node1.childNodes); const childNodes2 = Array.from(node2.childNodes); // Append all child nodes from node2 to node1 childNodes2.forEach(child2 => { if (child2.nodeType === 1) { // If it's an element node, check if a matching element exists in node1 const matchingElement = childNodes1.find( child1 => child1.nodeType === 1 && child1.tagName === child2.tagName ); if (matchingElement) { this.mergeNodes(matchingElement, child2); // Recursively merge matching elements } else { const clonedNode = child2.cloneNode(true); node1.appendChild(clonedNode); } } else { // For text nodes, simply append them to node1 const clonedNode = child2.cloneNode(true); node1.appendChild(clonedNode); } }); } */ } if (!customElements.get('fx-submission')) { customElements.define('fx-submission', FxSubmission); }