@openhab-ui/setup-and-maintenance
Version:
Configuration and maintenance interface for openHAB
1,270 lines (1,154 loc) • 44.7 kB
JavaScript
import { store } from '../js/app.js';
import { OhListStatus } from './vue.js';
/**
* @category Data Components
* @customelement oh-login-status-link
* @description This is a tandem component for ui-context-help and alike.
*
* The target component is expected to have this API interface:
* .reset() // Optional: For reloading content
* .checkCacheAndLoad() // Optional: For displaying the original, cached content if that was temporarly overwritten
* .contenturl OR .url // property for setting a new url
* .contextdata // If this is existing it will be set to null before setting the url
*
* Attributes:
* - href // The destination url
* - toggle // If set will toggle a body class "showcontext"
* - reload // If set will call target.reset() if no "href" is also set
* - home // If set will call target.checkCacheAndLoad() if no "href" is also set
*
* Usage: <oh-doc-link href="some-link-to-markdown-or-html"><a href="#">Documentation</a></oh-doc-link>
*/
class OhLoginStatusLink extends HTMLElement {
constructor() {
super();
let shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `<style>.hidden{display:none;}</style><slot class="hidden" name="connected"></slot><slot class="hidden" name="disconnected"></slot>`;
this.connectionChangedBound = () => this.connectionChanged();
}
connectedCallback() {
this.items = [];
const slots = this.shadowRoot.querySelectorAll('slot');
for (let slot of slots) {
for (let node of slot.assignedNodes()) {
const items = node.querySelectorAll(".hostname");
for (let item of items) this.items.push(item);
}
}
store.addEventListener("connecting", this.connectionChangedBound, false);
store.addEventListener("connectionEstablished", this.connectionChangedBound, false);
store.addEventListener("connectionLost", this.connectionChangedBound, false);
this.connectionChangedBound();
}
disconnectedCallback() {
store.removeEventListener("connecting", this.connectionChangedBound, false);
store.removeEventListener("connectionEstablished", this.connectionChangedBound, false);
store.removeEventListener("connectionLost", this.connectionChangedBound, false);
}
connectionChanged() {
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => this.changeState(), 500);
}
changeState() {
this.timer = null;
for (let item of this.items) item.innerHTML = store.host;
if (store.connected) {
this.shadowRoot.querySelector('slot[name="disconnected"]').classList.add("hidden");
this.shadowRoot.querySelector('slot[name="connected"]').classList.remove("hidden");
} else {
this.shadowRoot.querySelector('slot[name="connected"]').classList.add("hidden");
this.shadowRoot.querySelector('slot[name="disconnected"]').classList.remove("hidden");
}
this.classList.remove("invisible");
}
}
customElements.define('oh-login-status-link', OhLoginStatusLink);
class FetchError extends Error {
constructor(message, status) {
super(message);
if (Error.captureStackTrace) {
Error.captureStackTrace(this, FetchError);
}
this.message = message;
this.status = status;
}
networkErrorMessage() {
return this.message + " (" + this.status + ")";
}
toString() {
return this.message + " (" + this.status + ")";
}
}
async function fetchWithTimeout(url, timeout = 5000) {
const controller = new AbortController();
const signal = controller.signal;
setTimeout(() => controller.abort(), timeout);
const response = await fetch(url, { signal: signal, validateHttpsCertificates: false, muteHttpExceptions: true }).catch(e => {
throw (e instanceof DOMException && e.name === "AbortError" ? "Timeout after " + (timeout / 1000) + "s." : e);
});
if (!response.ok) {
const body = await response.text();
throw new FetchError(response.statusText + " " + body, response.status);
}
return response;
}
/**
* @category Data Components
* @customelement oh-script-snippets
* @description This element renders a list of links (ul->li->a)
* with available script snippets from, that it fetches
* from "scriptsnippets/index.json". That file is expected
* to be a json list with {name,file} entries.
*
* A click on a link will dispatch a "loadscript" event
* with these "details": {filename}.
*/
class OhScriptSnippets extends HTMLElement {
constructor() {
super();
}
async connectedCallback() {
while (this.firstChild) { this.firstChild.remove(); }
const ul = document.createElement('ul');
this.target = this.hasAttribute("target") ? this.getAttribute("target") : null;
this.language = this.hasAttribute("language") ? this.getAttribute("language") : "javascript";
try {
const json = await fetchWithTimeout("scriptsnippets/" + this.language + "/index.json").then(response => response.json());
for (const entry of json) {
const li = document.createElement("li");
const a = document.createElement("a");
a.innerHTML = entry.name;
a.href = "#";
a.addEventListener("click", async (e) => {
e.preventDefault();
let targetEl = null;
if (this.target) targetEl = document.getElementById(this.target);
if (!targetEl) return;
targetEl.addAtCursorPosition(await fetchWithTimeout("scriptsnippets/" + this.language + "/" + entry.file).then(d => d.text()));
});
li.appendChild(a);
ul.appendChild(li);
}
} catch (e) {
ul.innerText = '';
const li = document.createElement("li");
li.innerText = e;
ul.appendChild(li);
}
this.appendChild(ul);
}
}
customElements.define('oh-script-snippets', OhScriptSnippets);
/**
* @category Data Components
* @customelement oh-doc-link
* @description This is a tandem component for ui-context-help and alike.
*
* The target component is expected to have this API interface:
* .reload() // Optional: For reloading content
* .checkCacheAndLoad() // Optional: For displaying the original, cached content if that was temporarly overwritten
* .contenturl OR .url // property for setting a new url
* .contextdata // If this is existing it will be set to null before setting the url
*
* Attributes:
* - href // The destination url
* - toggle // If set will toggle a body class "showcontext"
* - reload // If set will call target.reload() if no "href" is also set
* - home // If set will call target.checkCacheAndLoad() if no "href" is also set
*
* Usage: <oh-doc-link href="some-link-to-markdown-or-html"><a href="#">Documentation</a></oh-doc-link>
*/
class OhDocLink extends HTMLElement {
constructor() {
super();
let tmpl = document.createElement('template');
tmpl.innerHTML = `<slot></slot>`;
let shadow = this.attachShadow({ mode: 'open' });
shadow.appendChild(tmpl.content.cloneNode(true));
this.slotListenerBound = () => this.slotListener();
this.context = null;
}
static get observedAttributes() {
return ['href'];
}
attributeChangedCallback(name, oldValue, newValue) {
this.href = this.getAttribute("href");
this.target = this.hasAttribute("target") ? this.getAttribute("target") : "ui-context-help";
this.toggle = this.hasAttribute("toggle");
this.reload = this.hasAttribute("reload");
this.show = this.hasAttribute("show");
this.home = this.hasAttribute("home");
}
connectedCallback() {
this.setAttribute("tabindex", "0");
this.attributeChangedCallback();
const slot = this.shadowRoot.querySelector('slot');
slot.addEventListener('slotchange', this.slotListenerBound, { passive: true });
}
disconnectedCallback() {
const slot = this.shadowRoot.querySelector('slot');
slot.removeEventListener('slotchange', this.slotListenerBound, { passive: true });
}
/**
* As soon as the <slot> got a child, this is called.
* Add the on-click lister to all child nodes.
*/
slotListener() {
if (!this.shadowRoot) return;
const slot = this.shadowRoot.querySelector('slot');
const nodes = slot.assignedNodes();
if (!nodes.length) return;
for (const node of nodes) {
node.onclick = (e) => this.clickListener(e);
node.onkeyup = (e) => { if (e.keyCode === 13) this.clickListener(e); };
}
this.onclick = (e) => this.clickListener(e);
this.onkeyup = (e) => { if (e.keyCode === 13) this.clickListener(e); };
}
/**
* Add "showcontext" class to body and tell the target
* web component the new url and context data.
*/
clickListener(e) {
e.preventDefault();
e.stopPropagation();
if (this.toggle)
document.querySelector('body').classList.toggle('showcontext');
else if (this.show)
document.querySelector('body').classList.add('showcontext');
const el = document.querySelector(this.target);
if (!el) {
console.warn("Did not find target element: ", this.target);
return;
}
if (this.href) {
el.contextdata = this.context;
if (el.contenturl)
el.contenturl = this.href;
else
el.url = this.href;
} else if (this.home) {
el.home();
} else if (this.reload) {
el.reload();
}
}
}
customElements.define('oh-doc-link', OhDocLink);
/**
* @category Data Components
* @customelement oh-change-filter
* @description This is a tandem component for ui-filterbar and ui-vue-list-bind
*
*/
class OhChangeFilter extends HTMLElement {
constructor() {
super();
}
attributeChangedCallback(name, oldValue, newValue) {
this.target = this.hasAttribute("target") ? this.getAttribute("target") : null;
this.filter = this.hasAttribute("filter") ? this.getAttribute("filter") : null;
this.sort = this.hasAttribute("sort") ? this.getAttribute("sort") : null;
this.direction = this.hasAttribute("direction") ? this.getAttribute("direction") : null;
}
connectedCallback() {
this.classList.add("link");
this.attributeChangedCallback();
this.onclick = (e) => this.clickListener(e);
}
clickListener(e) {
e.preventDefault();
e.stopPropagation();
const el = document.querySelector(this.target);
if (!el) {
console.warn("Did not find target element: ", this.target);
return;
}
if (this.filter) el.setAttribute("value", this.filter);
if (this.sort) el.sort(this.sort, this.direction);
}
}
customElements.define('oh-change-filter', OhChangeFilter);
/**
* Firefox is the last evergreen browser that does not support dynamic import yet.
* It will with Firefox 67 (May 2019). Please remove this polyfill after
* the release.
*/
async function importModule(url) {
const vector = "import" + url.replace(/\./g, "").replace(/\//g, "_");
if (document.getElementById("id_" + vector)) {
return window[vector];
}
const loader = `
import * as m from "${url}";
window["${vector}"] = m;
document.getElementById("id_${vector}").dispatchEvent(new CustomEvent("loaded",{detail:m}))
`; // export Module
const script = document.createElement("script");
script.type = "module";
script.id = "id_" + vector;
script.async = 'async';
script.textContent = loader;
const promise = new Promise((resolve, reject) => {
script.onerror = (e) => {
console.warn(`Failed to import: ${url}`, e);
reject(new Error(`Failed to import: ${url}`));
};
script.addEventListener("loaded", (event) => {
resolve(event.detail);
}, { passive: true });
document.head.appendChild(script);
});
window[vector] = promise;
return promise;
}
/**
* @category Data Components
* @customelement oh-websocket-data
* @description Creates and maintains a websocket connection and provides the data via an event
* @attribute href The connection protocol, host and port like ws://127.0.0.1:8080
* @attribute [simulation] A simulation mixin that provides an exported "SimulationGenerator" class
* @attribute [run] If set, the connection is established immediately
* @property {Boolean} run A property to control the connection state.
* Set to false to disconnect and true to connect.
*
* @example <caption>A websocket connection to 127.0.0.1 on port 8080 and immediately started</caption>
* <oh-websocket-data href="ws://127.0.0.1:8080" run></oh-websocket-data>
*/
class OhWebsocketData extends HTMLElement {
constructor() {
super();
this._active = false;
}
static get observedAttributes() {
return ['href', 'run'];
}
async attributeChangedCallback(name, oldValue, newValue) {
this._active = this.hasAttribute("run");
if (name != "href") return;
this.href = this.getAttribute("href");
if (this.hasAttribute("simulation")) {
const adapter = this.getAttribute("simulation");
let module = await importModule('./js/' + adapter + '.js');
this.SimulationGenerator = module.SimulationGenerator;
if (this._active) this.run = true;
} else if (this.href) {
if (this._active) this.run = true;
}
}
get run() {
return this._active;
}
set run(val) {
this._active = val;
if (this._active)
this.setAttribute("run", "");
else
this.removeAttribute("run");
// Disable
if (!this._active) {
if (this.socket) this.socket.close();
delete this.socket;
if (this.sim) this.sim.dispose();
delete this.sim;
return;
}
// Enable
if (this.href) {
if (this.socket) this.socket.close();
this.socket = new WebSocket(this.href);
this.socket.onclose = () => this.onclose();
this.socket.onerror = (event) => this.onerror(event);
this.socket.onmessage = (event) => this.onmessage(event.data, event.origin, event.lastEventId);
this.socket.onopen = () => this.onopen();
} else if (this.hasAttribute("simulation")) {
if (this.sim) this.sim.dispose();
this.sim = new this.SimulationGenerator((data) => this.dispatchEvent(new CustomEvent("data", { detail: data })));
}
}
onclose() {
console.log("OhWebsocketData. Websocket closed", this.id);
}
onerror(event) {
console.log("OhWebsocketData. Websocket error", this.id, event);
}
onmessage(data, origin, lastEventId) {
console.debug("Received websocket message", this.id, data, origin);
this.dispatchEvent(new CustomEvent("data", { detail: data }));
}
onopen() {
console.debug("OhWebsocketData. Websocket opened", this.id);
}
connectedCallback() {
this.attributeChangedCallback("href");
}
disconnectedCallback() {
if (this.socket) this.socket.close();
delete this.socket;
if (this.sim) this.sim.dispose();
delete this.sim;
}
}
customElements.define('oh-websocket-data', OhWebsocketData);
/**
* Data event
*
* @category Data Components
* @event oh-websocket-data#data
* @type {Object|Array}
*/
/**
* @category Data Components
* @customelement oh-tutorial-starter
* @description Extracts a value from the URL query string ("queryParameter") and adds it as an attribute
* to the referenced destination via element ID given by "for" or by using the next sibling element.
*/
class OhTutorialStarter extends HTMLElement {
constructor() {
super();
this.clickBound = () => this.clicked();
}
connectedCallback() {
this.style.display = "none";
const forid = this.getAttribute("for");
this.target = document.getElementById(forid);
if (!this.target) {
this.target = this.nextElementSibling;
}
if (!this.target) {
console.warn("Failed to find target", forid);
return;
}
this.target.addEventListener("click", this.clickBound, { passive: true });
}
disconnectedCallback() {
if (this.target) this.target.removeEventListener("click", this.clickBound, { passive: true });
}
async clicked() {
let m = await importModule('./js/tutorial.js');
m.startTutorial(this.getAttribute("subject"));
}
}
customElements.define('oh-tutorial-starter', OhTutorialStarter);
/**
* @category Data Components
* @customelement oh-event-bind
* @description Extracts a value from an event ("eventName") of a "destination" and adds it as an attribute ("attribute")
* to the referenced target via element ID given by "for" or by using the next sibling element.
*
* Valid attributes:
* - from: The event sender. Optional. If not set, will be document
* - eventName: The eventname to listen to.
* - for: The target/recepient
* - attribute: The attribute name that will be set on the target.
* - setcontent: Boolean. Instead of setting an attribute, the targets content is set.
*/
class OhEventBind extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.style.display = "none";
const forid = this.getAttribute("for");
this.target = document.getElementById(forid);
if (!this.target) {
this.target = this.nextElementSibling;
}
if (!this.target) {
console.warn("Failed to find target", forid);
return;
}
if (this.hasAttribute("from")) {
this.from = this.getAttribute("from");
if (!document.querySelector(this.from)) this.from = null;
}
if (!this.from) this.from = document;
this.eventName = this.getAttribute("eventName");
this.boundMethod = (e) => this.eventMethod(e.detail);
this.from.addEventListener(this.eventName, this.boundMethod);
}
disconnectedCallback() {
if (this.eventName) this.from.removeEventListener(this.eventName, this.boundMethod);
}
eventMethod(e) {
if (this.hasAttribute("setcontent")) {
this.target.innerHTML = e;
} else {
const attribute = this.getAttribute("attribute");
this.target.setAttribute(attribute, e);
}
}
}
customElements.define('oh-event-bind', OhEventBind);
/**
* @category Data Components
* @customelement oh-prop-bind
* @description Extracts a value from the URL query string ("queryParameter") or a propery value of a source element
* and adds it to the referenced destination. It either adds it as an attribute or a property or to the content area
* of the referenced destination. The destination is given via element ID by "for" or by the next sibling element will be used.
* @attribute [for] The destination dom element [#exampleElement]
* @attribute [contextfrom] A source dom element
* @attribute [sourceproperty] A property that will be read from the source dom element and used. If not set, "contextdata" will be used.
* @attribute [queryParameter] The URL query part that should be used as source
* @attribute [regex] A regular expression to extract something out of the query
* @attribute [setcontent] If this is set the destination inner html content will be replaced.
* @attribute [attribute] The destination attribute.
* @attribute [property] The destination property.
*/
class OhPropBind extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.style.display = "none";
let data;
if (this.hasAttribute("contextfrom")) {
const targetNode = document.querySelector(this.getAttribute("contextfrom"));
const sourceProperty = this.getAttribute("sourceproperty") || "contextdata";
data = targetNode[sourceProperty];
} else if (this.hasAttribute("queryParameter")) {
const queryParameter = this.getAttribute("queryParameter");
const regex = this.getAttribute("regex");
data = new URL(window.location).searchParams.get(queryParameter);
if (regex && data) {
data = data.match(regex)[1];
}
}
if (!data) {
//throw new Error("No contextfrom or queryParameter set");
return;
}
const forid = this.getAttribute("for");
this.target = document.getElementById(forid);
if (!this.target) {
this.target = this.nextElementSibling;
}
if (!this.target) {
console.warn("Failed to find target", forid);
return;
}
if (this.hasAttribute("setcontent")) {
this.target.innerHTML = data;
}
if (this.hasAttribute("attribute")) {
const attribute = this.getAttribute("attribute");
if (this.hasAttribute("unwrap"))
this.target.setAttribute(attribute, data[this.getAttribute("unwrap")]);
else
this.target.setAttribute(attribute, data);
}
if (this.hasAttribute("property")) {
const property = this.getAttribute("property");
if (this.hasAttribute("unwrap"))
this.target[property] = data[this.getAttribute("unwrap")];
else
this.target[property] = data;
}
}
}
customElements.define('oh-prop-bind', OhPropBind);
/**
* @category Data Components
* @customelement oh-dropdown-bind
* @description This is a non-visible data binding component and serves as *Controller*
* in the MVA (Model-View-Adapter) concept.
*
* It waits for the target, identified by the "for" attribute
* to be ready and then loads the "adapter" es6 module.
* The helper module is expected to export:
* - mixins: A list of mixins to apply to list-item components
*/
class OhDropdownBind extends HTMLElement {
constructor() {
super();
this.connectedBound = (e) => this.connected(e.detail);
this.connectingBound = (e) => this.connecting(e.detail);
this.disconnectedBound = (e) => this.disconnected(e.detail);
this.listChangedBound = (e) => this.listChanged(e.detail);
}
connectedCallback() {
this.style.display = "none";
const forid = this.getAttribute("for");
this.target = document.getElementById(forid);
if (!this.target) {
this.target = this.nextElementSibling;
}
if (!this.target) {
console.warn("OhDropdownBind: Could not find target!");
return;
}
const adapter = this.getAttribute("adapter");
importModule('./js/' + adapter + '.js')
.then(this.startList.bind(this)).catch(e => {
console.log("list bind failed", e);
this.target.error = e;
});
}
disconnectedCallback() {
store.removeEventListener("connectionEstablished", this.connectedBound, false);
store.removeEventListener("connecting", this.connectingBound, false);
store.removeEventListener("connectionLost", this.disconnectedBound, false);
store.removeEventListener("storeChanged", this.listChangedBound, false);
if (!this.modeladapter) {
this.modeladapter.dispose();
delete this.modeladapter;
}
}
async startList(module) {
if (this.modeladapter) this.modeladapter.dispose();
this.modeladapter = new module.ModelAdapter();
store.addEventListener("connectionEstablished", this.connectedBound, false);
store.addEventListener("connecting", this.connectingBound, false);
store.addEventListener("connectionLost", this.disconnectedBound, false);
store.addEventListener("storeChanged", this.listChangedBound, false);
if (store.connected) this.connected(); else this.disconnected();
}
async connected() {
await this.modeladapter.getall();
this.target.options = this.modeladapter.items;
}
connecting() {
this.target.options = [];
this.target.label = "Connecting...";
}
disconnected() {
this.target.options = [];
this.target.label = "Not connected!";
}
listChanged(e) {
let adapterField = this.modeladapter.stores()[e.storename];
if (!adapterField) return;
this.target.options = e.value;
}
}
customElements.define('oh-dropdown-bind', OhDropdownBind);
/**
* @category Data Components
* @memberof module:ohcomponents
* @customelement oh-nav-auto-link
* @description Update the "active" class for sibling child links, depending on the current page url.
* @example <caption>Usage</caption>
* <nav>
* <oh-nav-auto-link></oh-nav-auto-link>
<div><a id="navmaintenance" href="maintenance.html">Maintenance</a></div>
<div><a id="navbindings" href="bindings.html">Add-ons</a></div>
* </nav>
*/
class OhNavAutoLink extends HTMLElement {
constructor() {
super();
this.pageChangedBound = () => this.checkLinks();
document.addEventListener("DOMContentLoaded", this.pageChangedBound);
}
disconnectedCallback() {
document.removeEventListener("DOMContentLoaded", this.pageChangedBound);
}
connectedCallback() {
this.style.display = "none";
this.checkLinks();
}
/**
* Checks the sibling links if they need the "active" class (depending on the 'href' attribute).
* This method is automatically called on every "DOMContentLoaded" event.
*/
checkLinks() {
let parentlink = document.querySelector('link[rel="parent"]');
if (parentlink) parentlink = parentlink.href;
const elems = this.parentNode.children;
for (let elem of elems) {
if (elem == this) continue;
const link = elem.children[0];
if (!link.href) continue;
const classlist = link.classList;
classlist.remove("active");
classlist.remove("semiactive");
const url = new URL(link.href);
if (url.pathname == window.location.pathname && url.search == window.location.search)
classlist.add("active");
else if (link.href == parentlink)
classlist.add("semiactive");
}
}
}
customElements.define('oh-nav-auto-link', OhNavAutoLink);
/**
* @category Data Components
* @customelement oh-vue-bind
* @description This is a non-visible data binding component and serves as *Controller*
* in the MVA (Model-View-Adapter) concept.
*
* It waits for the target, identified by the "for" attribute
* to be ready and then loads the "adapter" es6 module.
*
* The helper module is expected to export:
* - mixins: A list of mixins to apply to list-item components
* - components: An optional json-schema for the text-editor
*/
class OhVueBind extends HTMLElement {
constructor() {
super();
this.style.display = "none";
}
connectedCallback() {
const forid = this.getAttribute("for");
let target = document.getElementById(forid);
if (!target) {
target = this.nextElementSibling;
}
if (!target.ok) {
target.addEventListener("load", this.connectedCallback.bind(this), { once: true, passive: true });
return;
}
this.target = target;
let contextdata = {};
if (this.hasAttribute("contextfrom") && this.hasAttribute("sourceproperty")) {
const targetNode = document.querySelector(this.getAttribute("contextfrom"));
const sourceProperty = this.getAttribute("sourceproperty") || "contextdata";
contextdata = targetNode[sourceProperty];
}
if (this.hasAttribute("adapter")) {
const adapter = this.getAttribute("adapter");
importModule('./js/' + adapter + '.js')
.then((module) => {
target.start(module.mixins, contextdata);
})
.catch(e => console.log("adapter bind failed", e));
} else {
target.start([], contextdata);
}
}
disconnectedCallback() {
}
set context(data) {
if (this.target) this.target.updateContext(data);
}
}
customElements.define('oh-vue-bind', OhVueBind);
/**
* @category Data Components
* @description Takes a list adapter, the database store and optionally an objectid.
*
* Whenever the store changes, the list adapter content will be updated,
* which in turn will update the views.
*/
class UpdateAdapter {
constructor(modeladapter, store, objectid = null, viewOptions = null) {
this.modeladapter = modeladapter;
this.store = store;
this.objectid = objectid;
this.viewOptions = viewOptions;
this.listChangedBound = (e) => this.listChanged(e.detail);
this.listEntryChangedBound = (e) => this.listEntryChanged(e.detail);
this.listEntryRemovedBound = (e) => this.listEntryRemoved(e.detail);
this.listEntryAddedBound = (e) => this.listEntryAdded(e.detail);
store.addEventListener("storeChanged", this.listChangedBound, false);
store.addEventListener("storeItemChanged", this.listEntryChangedBound, false);
store.addEventListener("storeItemRemoved", this.listEntryRemovedBound, false);
store.addEventListener("storeItemAdded", this.listEntryAddedBound, false);
}
dispose() {
this.store.removeEventListener("storeChanged", this.listChangedBound, false);
this.store.removeEventListener("storeItemChanged", this.listEntryChangedBound, false);
this.store.removeEventListener("storeItemRemoved", this.listEntryRemovedBound, false);
this.store.removeEventListener("storeItemAdded", this.listEntryAddedBound, false);
this.store = null;
this.objectid = null;
this.modeladapter = null;
this.viewOptions = null;
}
/**
* The entire list changed. Request a model refresh.
*/
listChanged(e) {
let adapterField = this.modeladapter.stores()[e.storename];
if (!adapterField) return;
this.modeladapter.get(e.storename, this.objectid, this.viewOptions);
}
listEntryChanged(e) {
let adapterField = this.modeladapter.stores()[e.storename];
if (!adapterField) return;
let value = val(adapterField, this);
if (Array.isArray(value)) {
const id = e.value[this.modeladapter.STORE_ITEM_INDEX_PROP];
if (!id) {
console.warn(`listEntryChanged: Expected key property "${this.modeladapter.STORE_ITEM_INDEX_PROP} for ${e.storename}`, e.value);
return;
}
// Find entry in adapters list
for (let i = 0; i < value.length; ++i) {
let entry = value[i];
if (entry[this.modeladapter.STORE_ITEM_INDEX_PROP] == id) {
value.splice(i, 1, e.value);
return;
}
}
} else {
if (this.objectid === "") return;
if (e.value[this.modeladapter.STORE_ITEM_INDEX_PROP] == this.objectid) {
console.debug("listEntryChanged->update view", e.storename, e.value);
setval(adapterField, this, e.value);
}
}
}
listEntryRemoved(e) {
let adapterField = this.modeladapter.stores()[e.storename];
if (!adapterField) return;
let value = val(adapterField, this);
if (Array.isArray(value)) {
const id = e.value[this.modeladapter.STORE_ITEM_INDEX_PROP];
// Find entry in adapters list
for (let i = 0; i < value.length; ++i) {
const entry = value[i];
if (entry[this.modeladapter.STORE_ITEM_INDEX_PROP] == id) {
console.debug("listEntryRemoved->update view", e.storename, e.value);
value.splice(i, 1);
return;
}
}
} else {
if (this.objectid === "") return;
if (e.value[this.modeladapter.STORE_ITEM_INDEX_PROP] == this.objectid) {
console.debug("listEntryRemoved->update view", e.storename, e.value);
setval(adapterField, this, {});
}
}
}
listEntryAdded(e) {
let adapterField = this.modeladapter.stores()[e.storename];
if (!adapterField) return;
let value = val(adapterField, this);
if (Array.isArray(value)) {
const id = e.value[this.modeladapter.STORE_ITEM_INDEX_PROP];
// Find entry in adapters list
for (let i = 0; i < value.length; ++i) {
const entry = value[i];
if (entry[this.modeladapter.STORE_ITEM_INDEX_PROP] == id) {
console.debug("listEntryChanged->update view", e.storename, e.value);
value.splice(i, 1, e.value);
return;
}
}
// Not found in list -> add entry
console.debug("listEntryChanged->add to view", e.storename, e.value);
value.push(e.value);
} else {
if (this.objectid === "") return;
if (e.value[this.modeladapter.STORE_ITEM_INDEX_PROP] == this.objectid) {
console.debug("listEntryAdded->update view", e.storename, e.value);
setval(adapterField, this, e.value);
}
}
}
}
function val(adapterField, u) {
return u.modeladapter[adapterField];
}
function setval(adapterField, u, newval) {
u.modeladapter[adapterField] = newval;
}
/**
* @category Data Components
* @customelement oh-form-bind
* @description This is a non-visible data binding component and serves as *Controller*
* in the MVA (Model-View-Adapter) concept.
*
* It waits for the target, identified by the "for" attribute
* to be ready and then loads the "adapter" es6 module.
* The helper module is expected to export:
* - mixins: A list of mixins to apply to list-item components
* - schema: An optional json-schema for the text-editor
*/
class OhVueFormBind extends HTMLElement {
constructor() {
super();
this.createdBound = (e) => this.reInitDataFetch();
this.connectedBound = (e) => this.connected(e.detail);
this.connectingBound = (e) => this.connecting(e.detail);
this.disconnectedBound = (e) => this.disconnected(e.detail);
}
async connectedCallback() {
if (this.objectid) {
const oid = this.objectid;
delete this.objectid;
this.objectid = oid;
}
this.style.display = "none";
const forid = this.getAttribute("for");
this.target = document.getElementById(forid);
if (!this.target) {
this.target = this.nextElementSibling;
}
if (!this.target.ok) {
this.target.addEventListener("load", this.connectedCallback.bind(this), { once: true, passive: true });
return;
}
if (this.modeladapter) this.modeladapter.dispose();
const mixin = this.getAttribute("mixin");
try {
this.module = await importModule('./js/' + this.getAttribute("adapter") + '.js');
this.modeladapter = new this.module.ModelAdapter();
let mixins = this.module.mixins.slice(0);
if (mixin) {
const moreMixins = await importModule('./js/' + mixin + '.js');
mixins = mixins.concat(moreMixins.mixins);
}
if (this.modeladapter.events) {
this.modeladapter.events.addEventListener("created", this.createdBound, { passive: true });
}
this.target.start(this.modeladapter, mixins);
store.addEventListener("connectionEstablished", this.connectedBound, { passive: true });
store.addEventListener("connecting", this.connectingBound, { passive: true });
store.addEventListener("connectionLost", this.disconnectedBound, { passive: true });
if (store.connected) this.connected(); else this.disconnected();
} catch (e) {
console.warn("form bind failed", e);
this.target.error = e;
}
}
disconnectedCallback() {
store.removeEventListener("connectionEstablished", this.connectedBound, { passive: true });
store.removeEventListener("connecting", this.connectingBound, { passive: true });
store.removeEventListener("connectionLost", this.disconnectedBound, { passive: true });
if (this.modeladapter) {
if (this.modeladapter.events) this.modeladapter.events.removeEventListener("created", this.createdBound, { passive: true });
this.modeladapter.dispose();
delete this.modeladapter;
}
delete this.target;
this.disconnected();
}
/**
* A view must be able to tell the controller when a new "thing" has been created.
* This is done by a "created" event on the "this.modeladapter.events" EventTarget.
*
* This method will be called as a result. It will determine the objectid, by
* inspecting the current "this.modeladapter.value" and depending on the store connection
* state will initiate a call to "connected" or "disconnected".
*/
reInitDataFetch() {
this._objectid = (this.modeladapter.value ? this.modeladapter.value[this.modeladapter.STORE_ITEM_INDEX_PROP] : null);
if (this._objectid)
this.setAttribute("objectid", this._objectid);
else
this.removeAttribute("objectid");
if (store.connected) this.connected(); else this.disconnected();
}
/**
* The object id property can also be set / changed after the component has been loaded.
* The corresponding attribute will be set/unset and depending on the store connection
* state a call to "connected" or "disconnected" will follow.
*/
set objectid(objectid) {
console.log("SET PROPERTY", objectid);
this._objectid = objectid;
if (this._objectid)
this.setAttribute("objectid", this._objectid);
else
this.removeAttribute("objectid");
if (!this.modeladapter) return;
if (store.connected) this.connected(); else this.disconnected();
}
get objectid() {
console.log("GET OBJECT ID");
return this._objectid;
}
async connected() {
if (this.updateAdapter) this.updateAdapter.dispose();
this._objectid = this.hasAttribute("objectid") ? this.getAttribute("objectid") : this._objectid;
if (!this._objectid && this.hasAttribute("objectFromURL")) {
this._objectid = new URL(window.location).searchParams.get(this.modeladapter.STORE_ITEM_INDEX_PROP);
}
this.updateAdapter = new UpdateAdapter(this.modeladapter, store, this._objectid);
if (this._objectid !== undefined) {
await this.modeladapter.get(null, this._objectid, null);
this.target.vue.status = OhListStatus.READY;
} else if (!this.hasAttribute("allowNew")) {
this.error = "No id set and no attribute 'allowNew'";
}
}
connecting() {
this.target.connectionState(true, store.host);
}
disconnected() {
if (!this.updateAdapter) return;
this.updateAdapter.dispose();
delete this.updateAdapter;
if (this.target) this.target.connectionState(false, store.connectionErrorMessage);
}
set context(data) {
if (this.target && this.target.updateContext) this.target.updateContext(data);
}
}
customElements.define('oh-form-bind', OhVueFormBind);
/**
* @category Data Components
* @customelement oh-list-bind
* @description This is a non-visible data binding component and serves as *Controller*
* in the MVA (Model-View-Adapter) concept.
*
* It waits for the target, identified by the "for" attribute
* to be ready and then loads the es6 adapter module.
*
* The adapter module is expected to export:
* - mixins: A list of mixins to apply to list-item components
* - listmixins: A list of mixins applied to the list component
* - ModelAdapter: The actual adapter
*
* @attribute [for] The target dom element selector. If not set the next sibling will be used
* @attribute [fixedfilter] A fixed filter expression.
* Either in the form of property:expression like "label:abc" or just the expression. In that case filtercriteria will be used.
* @attribute [sort] A fixed sorting expression
* @attribute [maxItems] A maximum items amount
* @attribute [filtercriteria] A filter criteria, for example "label".
* @attribute adapter The adapter to load. I.e. "modeladapter_lists/items"
*
* @property {Object} [adapterParameters] Custom parameters for the ModelAdapter
*/
class OhListBind extends HTMLElement {
constructor() {
super();
this.viewOptions = {};
this.style.display = "none";
this.connectedBound = (e) => this.connected(e.detail);
this.connectingBound = (e) => this.connecting(e.detail);
this.disconnectedBound = (e) => this.disconnected(e.detail);
}
connectedCallback() {
// Lazy-load properties. A developer might attempt to set a property before its definition has been loaded.
if (this.hasOwnProperty("adapterparameters")) {
let value = this.adapterparameters;
delete this.adapterparameters;
this._adapterParameters = value;
}
const forid = this.getAttribute("for");
this.target = document.getElementById(forid);
if (!this.target) {
this.target = this.nextElementSibling;
}
if (!this.target.ok) {
this.target.addEventListener("load", this.connectedCallback.bind(this), { once: true, passive: true });
return;
}
const maxItems = this.hasAttribute("maxItems") ? parseInt(this.getAttribute("maxItems")) : null;
if (maxItems) {
this.viewOptions.limit = maxItems;
}
this.fixedfilter = this.hasAttribute("fixedfilter") ? this.getAttribute("fixedfilter") : null;
if (this.fixedfilter) {
this.viewOptions.filter = this.fixedfilter;
}
if (this.hasAttribute("sort")) {
this.viewOptions.sort = this.getAttribute("sort");
}
this.filtercriteria = this.hasAttribute("filtercriteria") ? this.getAttribute("filtercriteria") : null;
this.filterbar = document.querySelector("ui-filter");
if (this.filterbar) {
this.filterBound = (event) => this.filter(event.detail.value.trim());
this.increaseLimitBound = () => this.increaseLimit();
this.filterbar.addEventListener("filter", this.filterBound);
this.filterbar.addEventListener("showmore", this.increaseLimitBound);
}
const adapter = this.getAttribute("adapter");
importModule('./js/' + adapter + '.js')
.then(this.startList.bind(this)).catch(e => {
console.warn("list bind failed", adapter, e);
if (this.target.error) this.target.error = e;
});
}
disconnectedCallback() {
if (this.filterbar) {
this.filterbar.removeEventListener("filter", this.filterBound);
delete this.filterbar;
}
store.removeEventListener("connectionEstablished", this.connectedBound, false);
store.removeEventListener("connecting", this.connectingBound, false);
store.removeEventListener("connectionLost", this.disconnectedBound, false);
if (this.modeladapter) {
this.modeladapter.dispose();
delete this.modeladapter;
}
delete this.target;
this.disconnected();
}
set adapterparameters(val) {
console.log("adapterParameters", val, this.modeladapter);
this._adapterParameters = val;
if (this.modeladapter && this.modeladapter.parametersChanged)
this.modeladapter.parametersChanged(this._adapterParameters, true, this.viewOptions);
}
async startList(module) {
this.module = module;
if (this.modeladapter) this.modeladapter.dispose();
this.modeladapter = new module.ModelAdapter(this);
this.target.start(this.modeladapter, module.listmixins, module.mixins);
store.addEventListener("connectionEstablished", this.connectedBound, false);
store.addEventListener("connecting", this.connectingBound, false);
store.addEventListener("connectionLost", this.disconnectedBound, false);
if (store.connected) this.connected(); else this.disconnected();
}
async connected() {
if (this.updateAdapter) this.updateAdapter.dispose();
this.objectid = this.hasAttribute("objectid") ? this.getAttribute("objectid") : this.objectid;
if (!this.objectid && this.hasAttribute("objectFromURL")) {
this.objectid = new URL(window.location).searchParams.get(this.modeladapter.STORE_ITEM_INDEX_PROP);
}
this.updateAdapter = new UpdateAdapter(this.modeladapter, store, this.objectid, this.viewOptions);
if (this._adapterParameters && this.modeladapter.parametersChanged)
this.modeladapter.parametersChanged(this._adapterParameters, false, this.viewOptions);
await this.modeladapter.getall(this.viewOptions, this.objectid);
this.target.vue.status = OhListStatus.READY;
}
connecting() {
this.target.connectionState(true, store.host);
}
disconnected() {
if (!this.updateAdapter) return;
this.updateAdapter.dispose();
delete this.updateAdapter;
if (this.target) this.target.connectionState(false, store.connectionErrorMessage);
}
/**
*
* @param {String} criteria The sorting criteria.
* Need to match with a database entries property. E.g. "label" or "category".
* @param {Enumerator<String>} direction The sorting direction. Usually ↓ or ↑. If ↓↑ is set, the sorting will alternate.
*/
async sort(criteria, direction = "↓↑") {
if (!this.modeladapter) return;
this.viewOptions.sort = criteria;
if (direction == "↓↑") {
this.viewOptions.direction = this.viewOptions.direction == "↓" ? "↑" : "↓";
} else
this.viewOptions.direction = direction;
await this.modeladapter.getall(this.viewOptions, this.objectid);
}
async increaseLimit() {
if (!this.modeladapter) return;
if (!this.viewOptions.limit) return;
this.viewOptions.limit += 50;
await this.modeladapter.getall(this.viewOptions, this.objectid);
}
filter(filter) {
if (!this.modeladapter || !this.filtercriteria) return;
if (!filter.includes(":"))
filter = this.filtercriteria + ":" + filter;
if (this.filterThrottleTimer) {
clearTimeout(this.filterThrottleTimer);
}
this.filterThrottleTimer = setTimeout(async () => {
delete this.filterThrottleTimer;
if (this.fixedfilter) {
filter += "&&" + this.fixedfilter;
}
this.viewOptions.filter = filter;
await this.modeladapter.getall(this.viewOptions, this.objectid);
}, 120);
}
}
customElements.define('oh-list-bind', OhListBind);
/**
* Data components module.
*
* @category Data Components
* @module ohcomponents
*/