diff --git a/client/source/class/cv/io/Client.js b/client/source/class/cv/io/Client.js index 646c3ec3d42..bcff2b2e842 100644 --- a/client/source/class/cv/io/Client.js +++ b/client/source/class/cv/io/Client.js @@ -196,6 +196,14 @@ qx.Class.define('cv.io.Client', { */ currentTransport: { init: null + }, + + /** + * The server we are currently speaking to (read from the login response) + */ + server: { + check: "String", + nullable: true } }, @@ -247,12 +255,25 @@ qx.Class.define('cv.io.Client', { if (backend.baseURL && backend.baseURL.substr(-1) !== "/") { backend.baseURL += "/"; } + var currentTransport = this.getCurrentTransport(); switch(backend.transport) { case "long-polling": - this.setCurrentTransport(new cv.io.transport.LongPolling(this)); + if (!(currentTransport instanceof cv.io.transport.LongPolling)) { + // replace old transport + if (currentTransport) { + currentTransport.dispose(); + } + this.setCurrentTransport(new cv.io.transport.LongPolling(this)); + } break; case "sse": - this.setCurrentTransport(new cv.io.transport.Sse(this)); + if (!(currentTransport instanceof cv.io.transport.Sse)) { + // replace old transport + if (currentTransport) { + currentTransport.dispose(); + } + this.setCurrentTransport(new cv.io.transport.Sse(this)); + } break; } if (this.backend.name === "openHAB") { @@ -364,14 +385,16 @@ qx.Class.define('cv.io.Client', { * Get the json response from the parameter received from the used XHR transport */ getResponse: qx.core.Environment.select("cv.xhr", { - "jquery": function(data) { + "jquery": function(args) { + var data = args[0]; if (data && $.type(data) === "string") { data = cv.io.parser.Json.parse(data); } return data; }, - "qx": function(ev) { + "qx": function(args) { + var ev = args[0]; if (!ev) { return null; } var json = ev.getTarget().getResponse(); if (!json) { return null; } @@ -382,6 +405,18 @@ qx.Class.define('cv.io.Client', { } }), + getResponseHeader: qx.core.Environment.select("cv.xhr", { + "jquery": function (args, name) { + return args[2].getResponseHeader(name); + }, + "qx": function (args, name) { + if (!args[0]) { + return null; + } + return args[0].getTarget().getResponseHeader(name); + } + }), + getQueryString: function(data) { var prefix = ""; var suffix = ""; @@ -500,21 +535,30 @@ qx.Class.define('cv.io.Client', { * backend and forwards to the configurated transport handleSession * function * - * @param ev {Event} the 'success' event from the XHR request + * Parameter vary dependent from the XHR type used + * qx (Qooxdoo): + * ev {Event} the 'success' event from the XHR request + * + * jQuery: + * data {Object} The JSON data returned from the server + * textStatus {String} a string describing the status + * request {Object} the jqXHR object */ - handleLogin : function (ev) { - var json = this.getResponse(ev); + handleLogin : function () { + var args = Array.prototype.slice.call(arguments, 0); + var json = this.getResponse(args); // read backend configuration if send by backend if (json.c) { this.setBackend(qx.lang.Object.mergeWith(this.getBackend(), json.c)); } this.session = json.s || "SESSION"; + this.setServer(this.getResponseHeader(args, "Server")); this.setDataReceived(false); if (this.loginSettings.loginOnly) { - this.getCurrentTransport().handleSession(ev, false); + this.getCurrentTransport().handleSession(args, false); } else { - this.getCurrentTransport().handleSession(ev, true); + this.getCurrentTransport().handleSession(args, true); // once the connection is set up, start the watchdog this.watchdog.start(5); } diff --git a/client/source/class/cv/io/transport/LongPolling.js b/client/source/class/cv/io/transport/LongPolling.js index dfadc1632eb..288c489de16 100644 --- a/client/source/class/cv/io/transport/LongPolling.js +++ b/client/source/class/cv/io/transport/LongPolling.js @@ -53,11 +53,11 @@ qx.Class.define('cv.io.transport.LongPolling', { * This function gets called once the communication is established * and this.client information is available. * - * @param ev {Event|Object} qx event or json response + * @param args {Array} arguments from the XHR response callback * @param connect {Boolean} whether to start the connection or not */ - handleSession: function (ev, connect) { - var json = this.client.getResponse(ev); + handleSession: function (args, connect) { + var json = this.client.getResponse(args); this.sessionId = json.s; this.version = json.v.split('.', 3); @@ -103,11 +103,9 @@ qx.Class.define('cv.io.transport.LongPolling', { /** * This function gets called once the communication is established * and this.client information is available - * - * @param ev {Event} */ - handleRead: function (ev) { - var json = this.client.getResponse(ev); + handleRead: function () { + var json = this.client.getResponse(Array.prototype.slice.call(arguments, 0)); if (this.doRestart || (!json && (-1 === this.lastIndex))) { this.client.setDataReceived(false); if (this.running) { // retry initial request @@ -149,8 +147,8 @@ qx.Class.define('cv.io.transport.LongPolling', { } }, - handleReadStart: function (ev) { - var json = this.client.getResponse(ev); + handleReadStart: function () { + var json = this.client.getResponse(Array.prototype.slice.call(arguments, 0)); if (!json && (-1 === this.lastIndex)) { this.client.setDataReceived(false); if (this.running) { // retry initial request diff --git a/client/source/class/cv/io/transport/Sse.js b/client/source/class/cv/io/transport/Sse.js index a6f81da7ed0..e3ca47cf1a9 100644 --- a/client/source/class/cv/io/transport/Sse.js +++ b/client/source/class/cv/io/transport/Sse.js @@ -32,7 +32,7 @@ qx.Class.define('cv.io.transport.Sse', { */ construct: function(client) { this.client = client; - this.__additionalTopics = []; + this.__additionalTopics = {}; }, /* @@ -51,11 +51,11 @@ qx.Class.define('cv.io.transport.Sse', { * This function gets called once the communication is established * and session information is available * - * @param ev {Event} + * @param args {Array} arguments from the XHR response callback * @param connect {Boolean} whether to start the connection or not */ - handleSession: function (ev, connect) { - var json = this.client.getResponse(ev); + handleSession: function (args, connect) { + var json = this.client.getResponse(args); this.sessionId = json.s; this.version = json.v.split('.', 3); @@ -82,9 +82,7 @@ qx.Class.define('cv.io.transport.Sse', { this.eventSource.addEventListener('message', this.handleMessage.bind(this), false); this.eventSource.addEventListener('error', this.handleError.bind(this), false); // add additional listeners - this.__additionalTopics.forEach(function(entry) { - this.eventSource.addEventListener(entry[0], entry[1].bind(entry[2]), false); - }, this); + Object.getOwnPropertyNames(this.__additionalTopics).forEach(this.__addRecordedEventListener, this); this.eventSource.onerror = function () { this.error("connection lost"); this.client.setConnected(false); @@ -108,6 +106,15 @@ qx.Class.define('cv.io.transport.Sse', { this.client.setDataReceived(true); }, + dispatchTopicMessage: function(topic, message) { + this.client.record(topic, message); + if (this.__additionalTopics[topic]) { + this.__additionalTopics[topic].forEach(function(entry) { + entry[0].call(entry[1], message); + }); + } + }, + /** * Subscribe to SSE events of a certain topic * @param topic {String} @@ -115,12 +122,22 @@ qx.Class.define('cv.io.transport.Sse', { * @param context {Object} */ subscribe: function(topic, callback, context) { - this.__additionalTopics.push([topic, callback, context]); + if (!this.__additionalTopics[topic]) { + this.__additionalTopics[topic] = []; + } + this.__additionalTopics[topic].push([callback, context]); if (this.isConnectionRunning()) { - this.eventSource.addEventListener(topic, callback.bind(context, false)); + this.__addRecordedEventListener(topic); } }, + __addRecordedEventListener: function(topic) { + this.debug("subscribing to topic "+topic); + this.eventSource.addEventListener(topic, function(e) { + this.dispatchTopicMessage(topic, e); + }.bind(this), false); + }, + /** * Handle errors */ diff --git a/config.json b/config.json index 4e9b6a3ea8f..4ff6c4dd4cd 100644 --- a/config.json +++ b/config.json @@ -26,6 +26,7 @@ "source-all", "source-hybrid", "source-server", + "source-error", "source-server-reload", "source-httpd-config", "test", @@ -43,7 +44,8 @@ { "APPLICATION" : "cv", "QOOXDOO_PATH" : "./external/qooxdoo", - "API_EXCLUDE" : ["qx.test.*", "${APPLICATION}.test.*"], + "QXTHEME" : "cv.theme.Dark", + "API_EXCLUDE" : ["qx.test.*", "${APPLICATION}.test.*", "${QXTHEME}"], "LOCALES" : [ "en", "de" ], "CACHE" : "${TMPDIR}/qx${QOOXDOO_VERSION}/cache", "ROOT" : ".", @@ -82,7 +84,8 @@ "${APPLICATION}.transforms.*", "${APPLICATION}.plugins.*", "${APPLICATION}.parser.*", - "${APPLICATION}.core.*" + "${APPLICATION}.core.*", + "${QXTHEME}" ], "lint-check" : { @@ -262,7 +265,7 @@ }, "plugin-openhab" : { - "include" : [ "${APPLICATION}.plugins.openhab.*" ] + "include" : [ "${APPLICATION}.plugins.openhab.*", "${QXTHEME}" ] } } } @@ -283,6 +286,15 @@ ] }, + "source-error": { + "extend" : [ "source" ], + "environment" : + { + "cv.build": "source", + "qx.globalErrorHandling": true + }, + }, + "source-hybrid" : { "extend" : [ "parts-config" ] @@ -393,6 +405,7 @@ "Sunlight", "replayLog", "svg4everybody", + "EVENT_RECORDER", "Favico" ], "generate-widget-examples": true diff --git a/doc/manual/de/config/_static/nachrichten_zentrale.png b/doc/manual/de/config/_static/nachrichten_zentrale.png new file mode 100644 index 00000000000..9dd938f86e1 Binary files /dev/null and b/doc/manual/de/config/_static/nachrichten_zentrale.png differ diff --git a/doc/manual/de/config/notifications.rst b/doc/manual/de/config/notifications.rst index 3202d1f4796..96e1ba73885 100644 --- a/doc/manual/de/config/notifications.rst +++ b/doc/manual/de/config/notifications.rst @@ -19,16 +19,19 @@ sein, wird dies durch farbigen Hintergrund gekennzeichnet. .. code-block:: xml :caption: Einfaches Beispiel zeigt eine Nachricht in der Nachrichtenzentrale, wenn die Wohnzimmerlampe eingeschaltet ist. - - - Wohnzimmerlicht - eingeschaltet um {{ time }} Uhr - ON - -
Light_FF_Living
-
-
-
+ + + + Wohnzimmerlicht + eingeschaltet um {{ time }} Uhr + ON + +
Light_FF_Living
+
+
+
+ ... + **Erklärung:** @@ -74,30 +77,35 @@ Erklärung zu den Attributen im state-notification-Element Weitere Beispiele ----------------- +Komplexes Beispiel mit Mapping +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + .. code-block:: xml :caption: Komplexes Beispiel mit Benachrichtungen für Bewegungen inkl. Mapping - - - Flur OG - Küche - Esszimmer - + + + + Flur OG + Küche + Esszimmer + + ... + ... - - ... - - - Bewegungsalarm - Bewegung erkannt: {{ address }}, {{ time }} - ON - -
Motion_FF_Dining
-
Motion_FF_Corridor
-
Motion_FF_Kitchen
-
-
-
+ + + Bewegungsalarm + Bewegung erkannt: {{ address }}, {{ time }} + ON + +
Motion_FF_Dining
+
Motion_FF_Corridor
+
Motion_FF_Kitchen
+
+
+
+ Dieses Beispiel zeigt ein Benachrichtigung wenn einer der Bewegungsmelder eine Bewegung liefert mit hoher Priorität (``severity="high"``, wird orange markiert). @@ -106,8 +114,44 @@ Um den etwas kryptischen Adressennamen in ein lesbares Format zu bringen wird ei Wenn der Bewegungsmelder mit dem Namen *Motion_FF_Corridor* nun eine Bewegung signalisiert würde die Nachricht folgenden Inhalt haben: -.. code-block:: text +.. figure:: _static/nachrichten_zentrale.png + :align: center + + Beispiel einer Nachricht in der Nachrichtenzentrale - Bewegungsalarm - Bewegung erkannt: Flur OG, 12:45 +Sprachausgabe +^^^^^^^^^^^^^ + +.. code-block:: xml + :caption: Ausgabe der Nachricht über die Text-to-speech Engine des Browsers + + + + + Flur OG + Küche + Esszimmer + + ... + + ... + + + Bewegung im {{ address }} + ON + +
Motion_FF_Dining
+
Motion_FF_Corridor
+
Motion_FF_Kitchen
+
+
+
+ + +Dieses Beispiel erzeugt Sprachausgaben über die in modernen Browsers eingebaute Text-to-Speech Engine. +In diesem Fall wird, sofern einer der durch die drei ``address`` Einträge gekennzeichneten Bewegungsmelder +als Wert ``ON`` liefert folgende Nachricht ausgegeben. + +.. code-block:: text + Bewegung im Flur OG diff --git a/doc/manual/de/devel/_static/NotificationCenter.png b/doc/manual/de/devel/_static/NotificationCenter.png new file mode 100644 index 00000000000..12534fbf64e Binary files /dev/null and b/doc/manual/de/devel/_static/NotificationCenter.png differ diff --git a/doc/manual/de/devel/_static/noticenter_hidden.png b/doc/manual/de/devel/_static/noticenter_hidden.png new file mode 100644 index 00000000000..c0e7fefcc55 Binary files /dev/null and b/doc/manual/de/devel/_static/noticenter_hidden.png differ diff --git a/doc/manual/de/devel/index.rst b/doc/manual/de/devel/index.rst index 9ce47f843be..4bdb9de4d5f 100644 --- a/doc/manual/de/devel/index.rst +++ b/doc/manual/de/devel/index.rst @@ -170,7 +170,20 @@ in den Build-Versionen aktiv (also auch in den Releases), während der Entwicklu auf der Javascript-Konsole des Browsers angezeigt. Weitere Fehler werden, je nach Art, entweder als Popup oder als Nachricht in der Nachrichtenzentrale angezeigt. -Als ein wichtiges Beispiel wären hier noch Verbindungsprobleme mit dem Backend zu nennen. Diese werden ebenfalls -als Popup über der Visu angezeigt, solange das Problem besteht. +Als ein wichtiges Beispiel wären hier noch Verbindungsprobleme mit dem Backend zu nennen. Diese werden angezeigt, +solange das Problem besteht und verschwinden automatisch, sobald das Problem behoben wurde. + + +.. figure:: _static/noticenter_hidden.png + :scale: 70% + :align: center + + Geschlossene Nachrichtenzentrale mit einer kritischen Fehlermeldung + +.. figure:: _static/NotificationCenter.png + :scale: 70% + :align: center + + Geöffnete Nachrichtenzentrale mit einer kritischen Fehlermeldung Der ``NotificationRouter`` wird ebenfalls für die :ref:`Benachrichtigungen ` genutzt. diff --git a/source/class/cv/Application.js b/source/class/cv/Application.js index a1038031cbc..1590c4836db 100644 --- a/source/class/cv/Application.js +++ b/source/class/cv/Application.js @@ -49,6 +49,10 @@ qx.Class.define("cv.Application", */ createClient: function() { var args = Array.prototype.slice.call(arguments); + if (args[0] === "openhab2") { + // auto-load openhab plugin for this backend + cv.Config.configSettings.pluginsToLoad.push("plugin-openhab"); + } args.unshift(null); if (cv.Config.testMode === true) { return new (Function.prototype.bind.apply(cv.io.Mockup, args)); // jshint ignore:line @@ -72,6 +76,18 @@ qx.Class.define("cv.Application", } }, + /* + ****************************************************** + PROPERTIES + ****************************************************** + */ + properties: { + root: { + nullable: true + } + }, + + /* ***************************************************************************** MEMBERS @@ -188,8 +204,6 @@ qx.Class.define("cv.Application", exString += "\n Description: " + ex.description; } try { - console.log(qx.dev.StackTrace.getStackTraceFromError(ex).join("\n\t")); - console.log(ex.stack); exString += "\nStack: " + qx.dev.StackTrace.getStackTraceFromError(ex).join("\n\t")+"\n"; } catch(exc) { if (ex.stack) { @@ -198,24 +212,66 @@ qx.Class.define("cv.Application", } } body += "```\n"+exString+"\n```\n\n**Client-Data:**\n```\n"+qx.lang.Json.stringify(bugData, null, 2)+"\n```"; + var notification = { topic: "cv.error", + target: cv.ui.PopupHandler, title: qx.locale.Manager.tr("An error occured"), message: "
"+ex.stack+"
", severity: "urgent", deletable: false, actions: { - link: { - title: qx.locale.Manager.tr("Report Bug"), - url: "https://github.com/CometVisu/CometVisu/issues/new?" + qx.util.Uri.toParameter({ - labels: "bug / bugfix", - title: ex.toString(), - body: body - }), - needsConfirmation: false - } + link: [ + { + title: qx.locale.Manager.tr("Reload"), + action: function(ev) { + var parent = ev.getTarget().parentNode; + while (parent) { + if (parent.id === "notification-center" || qx.bom.element.Class.has(parent, "popup")) { + break; + } + parent = parent.parentNode; + } + var box = qx.bom.Selector.query(".enableReporting", parent)[0]; + if (box.checked) { + // reload with reporting enabled + var url = window.location.href.split("#").shift(); + cv.util.Location.setHref(qx.util.Uri.appendParamsToUrl(url, "reporting=true")); + } else { + cv.util.Location.reload(true); + } + }, + needsConfirmation: false + } + ] } }; + // reload with reporting checkbox + var reportAction = null; + if (cv.Config.reporting) { + // reporting is enabled -> download log and show hint how to append it to the ticket + body = '\n\n'+body; + reportAction = cv.report.Record.download; + } else { + var link = ""; + if (qx.locale.Manager.getInstance().getLocale() === "de") { + link = ' (?)'; + } + notification.message+='
'+qx.locale.Manager.tr("Enable reporting on reload")+link+'
'; + + } + notification.actions.link.push( + { + title: qx.locale.Manager.tr("Report Bug"), + url: "https://github.com/CometVisu/CometVisu/issues/new?" + qx.util.Uri.toParameter({ + labels: "bug / bugfix", + title: ex.toString(), + body: body + }), + action: reportAction, + needsConfirmation: false + } + ); cv.core.notifications.Router.dispatchMessage(notification.topic, notification); }, @@ -271,6 +327,7 @@ qx.Class.define("cv.Application", ajaxRequest.addListenerOnce("success", function (e) { this.block(false); var req = e.getTarget(); + cv.Config.configServer = req.getResponseHeader("Server"); // Response parsed according to the server's response content type var xml = req.getResponse(); if (xml && qx.lang.Type.isString(xml)) { diff --git a/source/class/cv/Config.js b/source/class/cv/Config.js index d763eeffbd6..bc7100b2033 100644 --- a/source/class/cv/Config.js +++ b/source/class/cv/Config.js @@ -169,6 +169,11 @@ qx.Class.define('cv.Config', { */ enableLogging: true, + /** + * The server that responded to the config request + */ + configServer: null, + /** * Get the structure that is related to this design * @param design {String?} name of the design @@ -189,6 +194,18 @@ qx.Class.define('cv.Config', { return "structure-pure"; }, + /** + * This method tries to guess if the CometVisu is running on a proxied webserver. + * (by comparing if the visu_config.xml-File has been delivered from another server than the + * loging response). As this is just an assumption, you should not treat this result as reliable. + */ + guessIfProxied: function() { + if (this.configServer === null || cv.TemplateEngine.getInstance().visu.getServer() === null) { + throw new Error("not ready yet"); + } + return this.configServer !== cv.TemplateEngine.getInstance().visu.getServer(); + }, + addMapping: function (name, mapping) { this.configSettings.mappings[name] = mapping; }, diff --git a/source/class/cv/TemplateEngine.js b/source/class/cv/TemplateEngine.js index b8e46734bb9..36f82a3ea08 100644 --- a/source/class/cv/TemplateEngine.js +++ b/source/class/cv/TemplateEngine.js @@ -30,6 +30,7 @@ qx.Class.define('cv.TemplateEngine', { this.pagePartsHandler = new cv.ui.PagePartsHandler(); this.__partQueue = new qx.data.Array(); + this._domFinishedQueue = []; this.__partQueue.addListener("changeLength", function(ev) { this.setPartsLoaded(ev.getData() === 0); }, this); @@ -111,6 +112,10 @@ qx.Class.define('cv.TemplateEngine', { xml : null, __partQueue: null, + _domFinishedQueue: null, + + // plugins that do not need to be loaded to proceed with the initial setup + lazyPlugins: ["plugin-openhab"], /** * Load parts (e.g. plugins, structure) @@ -121,6 +126,12 @@ qx.Class.define('cv.TemplateEngine', { if (!qx.lang.Type.isArray(parts)) { parts = [parts]; } + var loadLazyParts = this.lazyPlugins.filter(function(part) { + return parts.indexOf(part) >= 0; + }); + if (loadLazyParts.length) { + qx.lang.Array.exclude(parts, loadLazyParts); + } this.__partQueue.append(parts); qx.io.PartLoader.require(parts, function(states) { parts.forEach(function(part, idx) { @@ -132,6 +143,17 @@ qx.Class.define('cv.TemplateEngine', { } }, this); }, this); + + // load the lazy plugins no one needs to wait for + qx.io.PartLoader.require(loadLazyParts, function(states) { + loadLazyParts.forEach(function(part, idx) { + if (states[idx] === "complete") { + this.debug("successfully loaded part "+part); + } else { + this.error("error loading part "+part); + } + }, this); + }, this); }, // property apply @@ -153,6 +175,27 @@ qx.Class.define('cv.TemplateEngine', { _applyDomFinished: function(value) { if (value) { qx.event.message.Bus.dispatchByName("setup.dom.finished"); + // flush the queue + this._domFinishedQueue.forEach(function(entry) { + var callback = entry.shift(); + var context = entry.shift(); + callback.apply(context, entry); + }, this); + this._domFinishedQueue = []; + } + }, + + /** + * Adds a callback to a queue which is executed after DOM has been rendered + * @param callback {Function} + * @param context {Object} + */ + executeWhenDomFinished: function(callback, context) { + if (!this.isDomFinished()) { + // queue callback + this._domFinishedQueue.push(Array.prototype.slice.call(arguments)); + } else { + callback.apply(context, Array.prototype.slice.call(arguments, 2)); } }, @@ -184,8 +227,6 @@ qx.Class.define('cv.TemplateEngine', { } else if (backendName === "oh2") { this.visu = cv.Application.createClient('openhab2', cv.Config.backendUrl); - // auto-load openhab plugin for this backend - cv.Config.configSettings.pluginsToLoad.push("plugin-openhab"); } else { this.visu = cv.Application.createClient(backendName, cv.Config.backendUrl); } @@ -313,7 +354,7 @@ qx.Class.define('cv.TemplateEngine', { var metaParser = new cv.parser.MetaParser(); // start with the plugins - settings.pluginsToLoad = metaParser.parsePlugins(loaded_xml); + settings.pluginsToLoad = qx.lang.Array.append(settings.pluginsToLoad, metaParser.parsePlugins(loaded_xml)); // and then the rest metaParser.parse(loaded_xml); this.debug("parsed"); diff --git a/source/class/cv/core/notifications/ActionRegistry.js b/source/class/cv/core/notifications/ActionRegistry.js index 4484f3e8a82..b875a1e34e0 100644 --- a/source/class/cv/core/notifications/ActionRegistry.js +++ b/source/class/cv/core/notifications/ActionRegistry.js @@ -28,7 +28,6 @@ qx.Class.define("cv.core.notifications.ActionRegistry", { type: "static", - /* ****************************************************** STATICS @@ -37,13 +36,55 @@ qx.Class.define("cv.core.notifications.ActionRegistry", { statics: { __handlers: {}, + /** + * Register an action handler for an action type. + * + * Note: There can only be one action handler per type. If there is currently + * another handler registered for this type it will be replaced. + * + * @param type {String} action type + * @param handler {cv.core.notifications.IActionHandler} + */ registerActionHandler: function(type, handler) { if (this.__handlers[type]) { - qx.log.Logger.warning(this, "there is already an action handler registered for '%1' action. replacing now", type); + qx.log.Logger.warn(this, "there is already an action handler registered for '"+type+"' action. replacing now"); } this.__handlers[type] = handler; }, + /** + * Unregister an action handler for an action type. + * + * @param type {String} action type + */ + unregisterActionHandler: function(type) { + if (this.__handlers[type]) { + delete this.__handlers[type]; + } + }, + + /** + * Get an instance of the registered action handler for the requested action type. + * @param type {String} action type + * @param config {Map?} additional parameters that should be passed to the action handlers constructor + * @return {cv.core.notifications.IActionHandler|null} + */ + getActionHandler: function(type, config) { + if (this.__handlers[type]) { + return new (this.__handlers[type])(config); + } else { + return null; + } + }, + + /** + * Creates an action element for the given action type. Unsually this is a button or a similar DOMElement + * with a listener attached. + * + * @param type {String} action type + * @param config {Map} additional parameters that should be passed to the action handlers constructor + * @return {Element|null} + */ createActionElement: function(type, config) { if (!this.__handlers[type]) { qx.log.Logger.error(this, "no action handler registered for '%1' action type", type); diff --git a/source/class/cv/core/notifications/Router.js b/source/class/cv/core/notifications/Router.js index f4e6eecba9d..a57cad97741 100644 --- a/source/class/cv/core/notifications/Router.js +++ b/source/class/cv/core/notifications/Router.js @@ -67,9 +67,36 @@ qx.Class.define("cv.core.notifications.Router", { } }, - // shortcut + /** + * Shortcut to {@link cv.core.notifications.Router#dispatchMessage} + */ dispatchMessage: function(topic, message, target) { return this.getInstance().dispatchMessage(topic, message, target); + }, + + /** + * Converts a target name to the related target object/function. + * + * @param name {String} target name, e.g. popup, notificationCenter, etc. + * @return {Object|Function|null} the target that can handle messages + */ + getTarget: function(name) { + switch (name) { + case "popup": + return cv.ui.PopupHandler; + case "notificationCenter": + return cv.ui.NotificationCenter.getInstance(); + case "speech": + if (!window.speechSynthesis) { + // not supported + qx.log.Logger.warn(this, "this browser does not support the Web Speech API"); + return; + } + return cv.core.notifications.SpeechHandler.getInstance(); + case "toast": + return cv.ui.ToastManager.getInstance(); + } + return null; } }, @@ -82,6 +109,8 @@ qx.Class.define("cv.core.notifications.Router", { members: { __routes: null, __stateMessageConfig: null, + __dateFormat: null, + __timeFormat: null, /** * Register state update handler for one or more addresses. @@ -100,7 +129,7 @@ qx.Class.define("cv.core.notifications.Router", { * addressMapping: "mapping-name", // optional mapping name for address * titleTemplate: "Kitchen light on", // title template of the message * messageTemplate: "turned on at {{ time }} o'clock", // message content template - * condition: 1 // show only when the value equals the contition value + * condition: 1 // show only when the value equals the condition value * }] * } * @@ -114,6 +143,19 @@ qx.Class.define("cv.core.notifications.Router", { }, this); }, + /** + * Unregister state update listeners for a list of addresses + * @param addresses {Array} + */ + unregisterStateUpdatehandler: function(addresses) { + addresses.forEach(function(address) { + cv.data.Model.getInstance().removeUpdateListener(address, this._onIncomingData, this); + if (this.__stateMessageConfig[address]) { + delete this.__stateMessageConfig[address]; + } + },this); + }, + /** * Register a handler for a list of topics * @param handler {cv.core.notifications.IHandler} @@ -150,6 +192,10 @@ qx.Class.define("cv.core.notifications.Router", { * @protected */ _onIncomingData: function(address, state, initial) { + if (!this.__stateMessageConfig[address]) { + return; + } + var now = new Date(); var formattedDate = this.__dateFormat.format(now); var formattedTime =this.__timeFormat.format(now); @@ -227,14 +273,19 @@ qx.Class.define("cv.core.notifications.Router", { }, __collectAllFromSegment: function(segment, handlers) { + handlers.append(segment.__handlers__); Object.getOwnPropertyNames(segment).forEach(function(segmentName) { - handlers.append(segment[segmentName].__handlers__); - this.__collectAllFromSegment(segment[segmentName], handlers); + if (segmentName !== "__handlers__") { + this.__collectAllFromSegment(segment[segmentName], handlers); + } }, this); return handlers; }, dispatchMessage: function(topic, message, target) { + if (message.target && !target) { + target = cv.core.notifications.Router.getTarget(message.target); + } if (target && target.handleMessage) { this.debug("dispatching '" + topic + "' message to handler: " + target); target.handleMessage(message, {}); @@ -259,5 +310,6 @@ qx.Class.define("cv.core.notifications.Router", { */ destruct: function() { this.clear(); + this._disposeObjects("__dateFormat", "__timeFormat"); } }); diff --git a/source/class/cv/core/notifications/SpeechHandler.js b/source/class/cv/core/notifications/SpeechHandler.js new file mode 100644 index 00000000000..6add0911401 --- /dev/null +++ b/source/class/cv/core/notifications/SpeechHandler.js @@ -0,0 +1,108 @@ +/** + * SpeechHandler + * + * @author tobiasb + * @since 2017 + * + * @ignore(SpeechSynthesisUtterance) + */ + +qx.Class.define("cv.core.notifications.SpeechHandler", { + extend: qx.core.Object, + implement: cv.core.notifications.IHandler, + type: "singleton", + + /* + ****************************************************** + CONSTRUCTOR + ****************************************************** + */ + construct: function() { + this.base(arguments); + this.__lastSpeech = {}; + }, + + /* + ****************************************************** + MEMBERS + ****************************************************** + */ + members: { + __lastSpeech: null, + + handleMessage: function(message, config) { + var text = message.message || message.title; + if (config.skipInitial && !this.__lastSpeech[message.topic]) { + this.__lastSpeech[message.topic] = { + text: text, + time: Date.now() + }; + return; + } + if (cv.core.notifications.Router.evaluateCondition(message)) { + if (!text || text.length === 0) { + // nothing to say + this.debug("no text to speech given"); + return; + } + + if (text.substring(0,1) === "!") { + // override repeatTimeout, force saying this + text = text.substring(1); + } + else if (config.repeatTimeout >= 0) { + // do not repeat (within timeout when this.repeatTimeout > 0) + if (this.__lastSpeech[message.topic] && this.__lastSpeech[message.topic].text === text && (config.repeatTimeout === 0 || + config.repeatTimeout >= Math.round((Date.now()-this.__lastSpeech[message.topic].time)/1000))) { + // update time + this.__lastSpeech[message.topic].time = Date.now(); + // do not repeat + this.debug("skipping TTS because of repetition " + text); + return; + } + } + + this.__lastSpeech[message.topic] = { + text: text, + time: Date.now() + }; + + this.say(text); + } + }, + + say: /* istanbul ignore next [no need to text the browsers TTS capability] */ function(text, language) { + + if (!window.speechSynthesis) { + this.warn(this, "this browser does not support the Web Speech API"); + return; + } + var synth = window.speechSynthesis; + + if (!language) { + language = qx.locale.Manager.getInstance().getLocale(); + } + + // speak + var utterThis = new SpeechSynthesisUtterance(text); + + var selectedVoice, defaultVoice; + var voices = synth.getVoices(); + for (var i = 0, l = voices.length; i < l; i++) { + if (language && voices[i].lang.substr(0, 2).toLowerCase() === language) { + selectedVoice = voices[i]; + } + if (voices[i]["default"]) { + defaultVoice = voices[i]; + } + } + if (!selectedVoice) { + selectedVoice = defaultVoice; + } + utterThis.voice = selectedVoice; + this.debug("saying '"+text+"' in voice "+selectedVoice.name); + synth.speak(utterThis); + } + } + +}); \ No newline at end of file diff --git a/source/class/cv/core/notifications/actions/AbstractActionHandler.js b/source/class/cv/core/notifications/actions/AbstractActionHandler.js index 15fa84fa75b..8bfa9464101 100644 --- a/source/class/cv/core/notifications/actions/AbstractActionHandler.js +++ b/source/class/cv/core/notifications/actions/AbstractActionHandler.js @@ -37,6 +37,11 @@ qx.Class.define("cv.core.notifications.actions.AbstractActionHandler", { needsConfirmation: { check: "Boolean", init: false + }, + + deleteMessageAfterExecution: { + check: "Boolean", + init: false } } }); diff --git a/source/class/cv/core/notifications/actions/Link.js b/source/class/cv/core/notifications/actions/Link.js index 79d2ea449dc..6e2a9b83d9f 100644 --- a/source/class/cv/core/notifications/actions/Link.js +++ b/source/class/cv/core/notifications/actions/Link.js @@ -51,6 +51,15 @@ qx.Class.define("cv.core.notifications.actions.Link", { url: { check: "String", nullable: true + }, + action: { + check: "Function", + nullable: true, + transform: "_transformAction" + }, + hidden: { + check: "Boolean", + init: false } }, @@ -60,10 +69,37 @@ qx.Class.define("cv.core.notifications.actions.Link", { ***************************************************************************** */ members: { + + _transformAction: function(value) { + if (qx.lang.Type.isFunction(value)) { + return value; + } + switch(value) { + case "reload": + case "restart": + return cv.util.Location.reload; + } + this.error("Unknown action: "+value); + return null; + }, + handleAction: function(ev) { - ev.stopPropagation(); - ev.preventDefault(); - window.open(this.getUrl(), '_blank'); + if (ev) { + ev.stopPropagation(); + ev.preventDefault(); + } + if (this.getAction()) { + this.getAction()(ev); + } + if (this.getUrl()) { + if (this.isHidden()) { + // open link in background (fire and forget) + var req = new qx.io.request.Xhr(this.getUrl()); + req.send(); + } else { + cv.util.Location.open(this.getUrl(), '_blank'); + } + } }, getDomElement: function() { diff --git a/source/class/cv/data/Model.js b/source/class/cv/data/Model.js index a00b53bb759..23640521cdc 100644 --- a/source/class/cv/data/Model.js +++ b/source/class/cv/data/Model.js @@ -107,6 +107,31 @@ qx.Class.define('cv.data.Model', { this.__stateListeners[address].push([callback, context]); }, + /** + * Remove an address listener + * + * @param address {String} KNX-GA or openHAB item name + * @param callback {Function} called on updates + * @param context {Object} context of the callback + */ + removeUpdateListener: function(address, callback, context) { + if (this.__stateListeners[address]) { + var removeIndex = -1; + this.__stateListeners[address].some(function(entry, i) { + if (entry[0] === callback && entry[1] === context) { + removeIndex = i; + return true; + } + }); + if (removeIndex >= 0) { + qx.lang.Array.removeAt(this.__stateListeners[address], removeIndex); + if (this.__stateListeners[address].length === 0) { + delete this.__stateListeners[address]; + } + } + } + }, + /** * Add an Address -> Path mapping to the addressList * @param address {String} KNX-GA or openHAB item name diff --git a/source/class/cv/parser/MetaParser.js b/source/class/cv/parser/MetaParser.js index d6959172f04..31ec6a54d08 100644 --- a/source/class/cv/parser/MetaParser.js +++ b/source/class/cv/parser/MetaParser.js @@ -202,15 +202,7 @@ qx.Class.define("cv.parser.MetaParser", { parseStateNotifications: function(xml) { var stateConfig = {}; qx.bom.Selector.query('meta > notifications state-notification', xml).forEach(function (elem) { - var target = cv.ui.NotificationCenter.getInstance(); - switch (qx.bom.element.Attribute.get(elem, 'target')) { - case "popup": - target = cv.ui.PopupHandler; - break; - case "notificationCenter": - target = cv.ui.NotificationCenter.getInstance(); - break; - } + var target = cv.core.notifications.Router.getTarget(qx.bom.element.Attribute.get(elem, 'target')) || cv.ui.NotificationCenter.getInstance(); var addressContainer = qx.bom.Selector.query('addresses', elem)[0]; @@ -249,8 +241,7 @@ qx.Class.define("cv.parser.MetaParser", { } config.condition = condition; - // TODO parse complete address with transform etc. - var addresses = cv.parser.WidgetParser.makeAddressList(addressContainer, null, null, true); + var addresses = cv.parser.WidgetParser.makeAddressList(addressContainer); // addresses Object.getOwnPropertyNames(addresses).forEach(function(address) { if (!stateConfig.hasOwnProperty(address)) { diff --git a/source/class/cv/plugins/Speech.js b/source/class/cv/plugins/Speech.js index 2687996bad5..6daf6b3fc7f 100644 --- a/source/class/cv/plugins/Speech.js +++ b/source/class/cv/plugins/Speech.js @@ -54,7 +54,6 @@ * @author Tobias Bräutigam * @since 0.10.0 * - * @ignore(SpeechSynthesisUtterance) */ qx.Class.define('cv.plugins.Speech', { extend: qx.core.Object, @@ -173,27 +172,7 @@ qx.Class.define('cv.plugins.Speech', { time: Date.now() }; - var synth = window.speechSynthesis; - - // speak - var utterThis = new SpeechSynthesisUtterance(text); - - var selectedVoice, defaultVoice; - var voices = synth.getVoices(); - for (var i = 0, l = voices.length; i < l; i++) { - if (this.language && voices[i].lang.substr(0, 2).toLowerCase() === this.language) { - selectedVoice = voices[i]; - } - if (voices[i]["default"]) { - defaultVoice = voices[i]; - } - } - if (!selectedVoice) { - selectedVoice = defaultVoice; - } - utterThis.voice = selectedVoice; - this.debug("saying '%s' in voice %s", text, selectedVoice.name); - synth.speak(utterThis); + cv.core.notifications.SpeechHandler.getInstance().say(text, this.getLanguage()); } }, diff --git a/source/class/cv/plugins/openhab/Openhab.js b/source/class/cv/plugins/openhab/Openhab.js index 32cd6dc7b34..1d915fefbab 100644 --- a/source/class/cv/plugins/openhab/Openhab.js +++ b/source/class/cv/plugins/openhab/Openhab.js @@ -1,7 +1,7 @@ -/* Openhab.js - * +/* Openhab.js + * * copyright (c) 2010-2017, Christian Mayer and the CometVisu contributers. - * + * * This program is free software; you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the Free * Software Foundation; either version 3 of the License, or (at your option) @@ -22,11 +22,14 @@ * This Plugin provides some specials to improve the integration with openHAB backend. * * .. NOTE:: + * * This plugin gets automatically activated if the openHAB2 backend is used. * There is no need to add it to the ``plugins`` section of the ``visu_config.xml``. * * @author Tobias Bräutigam * @since 0.11.0 + * + * @require(qx.ui.root.Inline) */ qx.Class.define("cv.plugins.openhab.Openhab", { extend: qx.core.Object, @@ -46,22 +49,47 @@ qx.Class.define("cv.plugins.openhab.Openhab", { var client = cv.TemplateEngine.getInstance().visu; var sse = client.getCurrentTransport(); sse.subscribe("notifications", this._onNotification, this); - }, - /* - ***************************************************************************** - PROPERTIES - ***************************************************************************** - */ - properties: {}, + cv.TemplateEngine.getInstance().executeWhenDomFinished(this._createSettings, this); + }, /* -***************************************************************************** - MEMBERS -***************************************************************************** -*/ + ***************************************************************************** + MEMBERS + ***************************************************************************** + */ members: { __notificationRouter: null, + __settings: null, + + _createSettings: function() { + // add element structure to notification-center + var settingsRoot = qx.dom.Element.create("section", {"id": "qxsettings", "html": "
"}); + qx.dom.Element.insertAfter(settingsRoot, qx.bom.Selector.query("#"+cv.ui.NotificationCenter.getInstance().getRootElementId()+" section.messages")[0]); + + // add a settings button to trigger opening the settings + var button = qx.dom.Element.create("div", { + html: cv.util.IconTools.svgKUF("edit_settings")(null, "width: 22px; height: 22px;"), + style: "float: left;" + }); + qx.dom.Element.insertBegin(button, qx.bom.Selector.query("#notification-center footer")[0]); + qx.event.Registration.addListener(button, "tap", function() { + this.__settings.show(); + }, this); + + //add to DOM + qx.theme.manager.Meta.getInstance().setTheme(cv.theme.Dark); + + // Initialize tooltip manager (currently disable as it requires a root with basic layout + // and that breaks the inline container sizes) + // qx.ui.tooltip.Manager.getInstance(); + + this._inline = new qx.ui.root.Inline(qx.bom.Selector.query("#qxsettings > div")[0], true, false); + this._inline.setLayout(new qx.ui.layout.VBox()); + this.__settings = new cv.plugins.openhab.Settings(); + this.__settings.exclude(); + this._inline.add(this.__settings, {flex: 1}); + }, /** * Handles notification messages from backend @@ -70,7 +98,7 @@ qx.Class.define("cv.plugins.openhab.Openhab", { */ _onNotification: function(e) { if (!e.data) { - this.error("invalid content received from SSE: %o", e); + this.error("invalid content received from SSE: ", e); } var json = qx.lang.Type.isObject(e.data) ? e.data : qx.lang.Json.parse(e.data); this.__notificationRouter.dispatchMessage(json.topic || "cv.backend", json); @@ -78,11 +106,16 @@ qx.Class.define("cv.plugins.openhab.Openhab", { }, /* - ***************************************************************************** + ****************************************************** DESTRUCTOR - ***************************************************************************** - */ - destruct: function (statics) { + ****************************************************** + */ + destruct: function() { + this._disposeObjects("__settings"); + this.__notificationRouter = null; + }, + + defer: function(statics) { // initialize on load statics.getInstance(); } diff --git a/source/class/cv/plugins/openhab/Settings.js b/source/class/cv/plugins/openhab/Settings.js new file mode 100644 index 00000000000..9f355e3bdcd --- /dev/null +++ b/source/class/cv/plugins/openhab/Settings.js @@ -0,0 +1,251 @@ +/* Openhab.js + * + * copyright (c) 2010-2017, Christian Mayer and the CometVisu contributers. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 3 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA + */ + + +/** + * Show and edit openHAB CometVisu backends settings via openHAB api. + * + * @author Tobias Bräutigam + * @since 0.11.0 + * + */ +qx.Class.define("cv.plugins.openhab.Settings", { + extend: qx.ui.core.Widget, + + /* + ***************************************************************************** + CONSTRUCTOR + ***************************************************************************** + */ + construct: function () { + this.base(arguments); + this._setLayout(new qx.ui.layout.VBox()); + this.set({ + padding: 10, + backgroundColor: "rgba(216, 216, 216, 1.0)", + textColor: "rgb(61, 61, 61)" + }); + // override text-shadow setting + if (!this.getBounds()) { + this.addListenerOnce("appear", function() { + this.getContentElement().setStyle("text-shadow", "none"); + }, this); + } else { + this.getContentElement().setStyle("text-shadow", "none"); + } + + this.__servicePid = "org.openhab.cometvisu"; + this.__uri = "ui:cometvisu"; + + this._initConfigRestClient(); + }, + + /* + ****************************************************** + PROPERTIES + ****************************************************** + */ + properties: { + + modified: { + check: "Boolean", + init: false, + event: "changeModified" + } + }, + + + /* + ***************************************************************************** + MEMBERS + ***************************************************************************** + */ + members: { + __servicePid: null, + __uri: null, + __configDescriptionResource: null, + __service: null, + __configDescription: null, + __inDom: false, + _store: null, + __initialValues: null, + + _initStore: function(pid) { + var serviceDesc = { + "get": { method: "GET", url: "/rest/services/"+pid+"/config" }, + "delete": { method: "DELETE", url: "/rest/services/"+pid+"/config" }, + "put": { method: "PUT", url: "/rest/services/"+pid+"/config" } + }; + var service = this.__service = new qx.io.rest.Resource(serviceDesc); + this._store = new qx.data.store.Rest(service, "get", { + configureRequest: function(req) { + req.setRequestHeader("Content-Type", "application/json"); + }, + manipulateData: function(data) { + // normalize the keys (replace .> with _) for the marshaller + var n = {}; + Object.getOwnPropertyNames(data).forEach(function(key) { + n[key.replace(/[\.>]/g, "_")] = data[key]; + }); + if (!n.hasOwnProperty("autoDownload")) { + n.autoDownload = false; + } + return n; + } + }); + // load data + service.get(); + this._store.addListenerOnce("changeModel", function() { + this.__initialValues = qx.lang.Json.parse(qx.util.Serializer.toJson(this._store.getModel())); + }, this); + }, + + _saveConfig: function() { + var data = qx.util.Serializer.toJson(this._store.getModel()); + data = data.replace(/icons_mapping_/g, "icons.mapping>"); + data = qx.lang.Json.parse(data.replace("icons_enableMapping", "icons>enableMapping")); + this.__service.put(null, data); + this.__service.addListenerOnce("putSuccess", this.close, this); + }, + + _initConfigRestClient: function() { + var description = { + "get": { method: "GET", url: "/rest/config-descriptions/"+this.__uri } + }; + + var config = this.__configDescriptionResource = new qx.io.rest.Resource(description); + config.addListener("getSuccess", function(ev) { + this._createForm(ev.getRequest().getResponse()); + }, this); + config.configureRequest(function(req) { + req.setRequestHeader("Content-Type", "application/json"); + }); + config.get(); + + this._initStore(this.__servicePid); + }, + + _createForm: function(config) { + this._createChildControl("title"); + var form = this.getChildControl("form"); + config.parameters.forEach(function(param) { + var field; + switch(param.type) { + case "TEXT": + field = new qx.ui.form.TextField(); + field.setPlaceholder(param.defaultValue); + break; + case "BOOLEAN": + field = new qx.ui.form.CheckBox(); + field.setValue(param.defaultValue === "true"); + break; + } + if (param.readOnly) { + field.setReadOnly(true); + } + if (param.required) { + field.setRequired(true); + } + field.setToolTipText(param.description); + field.addListener("changeValue", this._onFormFieldChange, this); + form.add(field, param.label, null, param.name, null, param); + }, this); + + var renderer = new cv.plugins.openhab.renderer.Single(form); + if (cv.Config.guessIfProxied()) { + renderer.setBottomText(this.tr("The CometVisu seems to be delivered by a proxied webserver. Changing configuration values might not have the expected effect. Please proceed only if you know what you are doing.")); + renderer.getChildControl("bottom-text").set({ + padding: 10, + textAlign: "center", + font: "bold" + }); + } + renderer.addButton(this.getChildControl("cancel-button")); + renderer.addButton(this.getChildControl("save-button")); + + this._addAt(renderer, 1); + var controller = new qx.data.controller.Form(null, form); + + this._store.bind("model", controller, "model"); + + this.setModified(false); + }, + + _onFormFieldChange: function() { + var modified = false; + var items = this.getChildControl("form").getItems(); + Object.getOwnPropertyNames(items).some(function(name) { + // noinspection EqualityComparisonWithCoercionJS + if (this.__initialValues[name] != items[name].getValue()) { // jshint ignore:line + this.debug(name+" has changed from "+this.__initialValues[name]+" to "+items[name].getValue()); + modified = true; + return true; + } + }, this); + this.setModified(modified); + }, + + // overridden + _createChildControlImpl : function(id, hash) { + var control; + switch(id) { + + case "title": + control = new qx.ui.basic.Label(this.tr("openHAB backend settings")); + control.set({ + font: "bold", + marginBottom: 5, + allowGrowX: true, + decorator: "window-caption" + }); + this._addAt(control, 0); + break; + + case "form": + control = new qx.ui.form.Form(); + break; + + case "cancel-button": + control = new qx.ui.form.Button(qx.locale.Manager.tr("Cancel")); + control.addListener("execute", this.close, this); + break; + + case "save-button": + control = new qx.ui.form.Button(qx.locale.Manager.tr("Save")); + control.addListener("execute", this._saveConfig, this); + this.bind("modified", control, "enabled"); + break; + } + return control || this.base(arguments, id, hash); + }, + + close: function() { + this.setVisibility("excluded"); + } + }, + + /* + ****************************************************** + DESTRUCTOR + ****************************************************** + */ + destruct: function() { + this._disposeObjects("__configDescriptionResource", "__service", "__root", "_store", "_window"); + } +}); diff --git a/source/class/cv/plugins/openhab/renderer/Single.js b/source/class/cv/plugins/openhab/renderer/Single.js new file mode 100644 index 00000000000..784a3adb5a1 --- /dev/null +++ b/source/class/cv/plugins/openhab/renderer/Single.js @@ -0,0 +1,191 @@ +/** + * {@link qx.ui.form.renderer.Single} with right column flexed. + * + * @author Tobias Bräutigam + * @since 0.11.0 + */ + +qx.Class.define("cv.plugins.openhab.renderer.Single", { + extend: qx.ui.form.renderer.AbstractRenderer, + + /* + ****************************************************** + CONSTRUCTOR + ****************************************************** + */ + construct: function(form) { + var layout = new qx.ui.layout.VBox(6); + this._setLayout(layout); + + this.base(arguments, form); + }, + + /* + ****************************************************** + PROPERTIES + ****************************************************** + */ + properties: { + bottomText: { + check: "String", + nullable: true, + apply: "_applyBottomText" + } + }, + + /* + ****************************************************** + MEMBERS + ****************************************************** + */ + members: { + + // property apply + _applyBottomText: function(value) { + var control = this.getChildControl("bottom-text"); + if (value) { + control.setValue(value); + control.show(); + } else { + control.exclude(); + } + }, + + // overridden + _createChildControlImpl : function(id, hash) { + var control; + switch(id) { + + case "content": + control = new qx.ui.container.Composite(new qx.ui.layout.VBox(8)); + this._addAt(control, 1); + break; + + case "bottom-text": + control = new qx.ui.basic.Label(this.getBottomText()); + control.set({ + rich: true, + wrap: true + }); + this._addAt(control, 2); + if (this.getBottomText()) { + control.show(); + } else { + control.exclude(); + } + break; + + case "button-container": + var hbox = new qx.ui.layout.HBox(); + hbox.setAlignX("right"); + hbox.setSpacing(5); + control = new qx.ui.container.Composite(hbox); + this._addAt(control, 3); + break; + + } + return control || this.base(arguments, id, hash); + }, + + /** + * Add a group of form items with the corresponding names. The names are + * displayed as label. + * The title is optional and is used as grouping for the given form + * items. + * + * @param items {qx.ui.core.Widget[]} An array of form items to render. + * @param names {String[]} An array of names for the form items. + * @param title {String?} A title of the group you are adding. + */ + addItems : function(items, names, title) { + // add the header + if (title !== null) { + this.getChildControl("content").add(this._createHeader(title)); + } + + var container = this.getChildControl("content"); + + // add the items + for (var i = 0; i < items.length; i++) { + var label = this._createLabel(names[i], items[i]); + var item = items[i]; + label.setBuddy(item); + + if (item instanceof qx.ui.form.CheckBox) { + // label + checkbox in one line + var box = new qx.ui.container.Composite(new qx.ui.layout.HBox()); + box.add(label, {width: "50%"}); + box.add(item, {width: "50%"}); + container.add(box); + } + else { + container.add(label); + container.add(item); + } + + this._connectVisibility(item, label); + + // store the names for translation + if (qx.core.Environment.get("qx.dynlocale")) { + this._names.push({name: names[i], label: label, item: items[i]}); + } + } + }, + + /** + * Adds a button to the form renderer. All buttons will be added in a + * single row at the bottom of the form. + * + * @param button {qx.ui.form.Button} The button to add. + */ + addButton : function(button) { + // add the button + this.getChildControl("button-container").add(button); + }, + + /** + * Returns the set layout for configuration. + * + * @return {qx.ui.layout.Grid} The grid layout of the widget. + */ + getLayout : function() { + return this._getLayout(); + }, + + /** + * Creates a label for the given form item. + * + * @param name {String} The content of the label without the + * trailing * and : + * @param item {qx.ui.core.Widget} The item, which has the required state. + * @return {qx.ui.basic.Label} The label for the given item. + */ + _createLabel : function(name, item) { + var label = new qx.ui.basic.Label(this._createLabelText(name, item)); + // store labels for disposal + this._labels.push(label); + label.setRich(true); + label.setAppearance("form-renderer-label"); + return label; + }, + + + /** + * Creates a header label for the form groups. + * + * @param title {String} Creates a header label. + * @return {qx.ui.basic.Label} The header for the form groups. + */ + _createHeader : function(title) { + var header = new qx.ui.basic.Label(title); + // store labels for disposal + this._labels.push(header); + header.setFont("bold"); + if (this._row != 0) { + header.setMarginTop(10); + } + header.setAlignX("left"); + return header; + } + } +}); \ No newline at end of file diff --git a/source/class/cv/report/Record.js b/source/class/cv/report/Record.js index 2b8195add2b..7d0d98aa730 100644 --- a/source/class/cv/report/Record.js +++ b/source/class/cv/report/Record.js @@ -163,8 +163,9 @@ qx.Class.define('cv.report.Record', { download: function() { if (cv.Config.reporting === true && !cv.report.Record.REPLAYING) { - cv.report.Record.getInstance().download(); + return cv.report.Record.getInstance().download(); } + return null; } }, @@ -386,6 +387,7 @@ qx.Class.define('cv.report.Record', { // Remove anchor from body document.body.removeChild(a); + return a.download; } } }); \ No newline at end of file diff --git a/source/class/cv/report/Replay.js b/source/class/cv/report/Replay.js index 8e0124c5192..81be180e039 100644 --- a/source/class/cv/report/Replay.js +++ b/source/class/cv/report/Replay.js @@ -242,6 +242,8 @@ qx.Class.define('cv.report.Replay', { default: if (client[record.i]) { client[record.i].apply(client, record.d); + } else if (this.__client.getCurrentTransport() instanceof cv.io.transport.Sse) { + this.__client.getCurrentTransport().dispatchTopicMessage(record.i, record.d); } else { this.error("unhandled backend record of type "+record.i); } diff --git a/source/class/cv/theme/Dark.js b/source/class/cv/theme/Dark.js new file mode 100644 index 00000000000..aeee497dc3b --- /dev/null +++ b/source/class/cv/theme/Dark.js @@ -0,0 +1,15 @@ + + +/** + * Basic theme for QX-UI relevant parts (should be seen as equilavent to designglobals.css, not design specific + * but something like the common sense of all designs) + */ +qx.Theme.define("cv.theme.Dark", { + meta: { + color: cv.theme.dark.Color, + decoration: cv.theme.dark.Decoration, + font: cv.theme.dark.Font, + icon: cv.theme.dark.Icon, + appearance: cv.theme.dark.Appearance + } +}); diff --git a/source/class/cv/theme/dark/Appearance.js b/source/class/cv/theme/dark/Appearance.js new file mode 100644 index 00000000000..28e5b8cf401 --- /dev/null +++ b/source/class/cv/theme/dark/Appearance.js @@ -0,0 +1,9 @@ + + +qx.Theme.define("cv.theme.dark.Appearance", { + extend : qx.theme.simple.Appearance, + + appearances : { + + } +}); \ No newline at end of file diff --git a/source/class/cv/theme/dark/Color.js b/source/class/cv/theme/dark/Color.js new file mode 100644 index 00000000000..e9d5ba99dd3 --- /dev/null +++ b/source/class/cv/theme/dark/Color.js @@ -0,0 +1,9 @@ + + +qx.Theme.define("cv.theme.dark.Color", { + extend : qx.theme.simple.Color, + + colors : { + + } +}); \ No newline at end of file diff --git a/source/class/cv/theme/dark/Decoration.js b/source/class/cv/theme/dark/Decoration.js new file mode 100644 index 00000000000..e341fcf0cce --- /dev/null +++ b/source/class/cv/theme/dark/Decoration.js @@ -0,0 +1,11 @@ + + +qx.Theme.define("cv.theme.dark.Decoration", { + extend : qx.theme.simple.Decoration, + + decorations : { + "window-caption-active": { + + } + } +}); \ No newline at end of file diff --git a/source/class/cv/theme/dark/Font.js b/source/class/cv/theme/dark/Font.js new file mode 100644 index 00000000000..be7af4f876b --- /dev/null +++ b/source/class/cv/theme/dark/Font.js @@ -0,0 +1,37 @@ + +/** + * Font definitions + * + */ +qx.Theme.define("cv.theme.dark.Font", +{ + extend : qx.theme.simple.Font, + + fonts : { + "default" : + { + size : 13, + family : ['URW Gothic L','Century Gothic','Apple Gothic',"arial","sans-serif"] + }, + + "bold" : + { + size : 13, + family : ['URW Gothic L','Century Gothic','Apple Gothic',"arial","sans-serif"], + bold: true + }, + + "subtext" : + { + size : 12, + family : ['URW Gothic L','Century Gothic','Apple Gothic',"arial","sans-serif"] + }, + + "title" : + { + size : 18, + bold : true, + family : ['URW Gothic L','Century Gothic','Apple Gothic',"arial","sans-serif"] + } + } +}); \ No newline at end of file diff --git a/source/class/cv/theme/dark/Icon.js b/source/class/cv/theme/dark/Icon.js new file mode 100644 index 00000000000..64ba96454a9 --- /dev/null +++ b/source/class/cv/theme/dark/Icon.js @@ -0,0 +1,4 @@ + +qx.Theme.define("cv.theme.dark.Icon", { + +}); \ No newline at end of file diff --git a/source/class/cv/ui/MHandleMessage.js b/source/class/cv/ui/MHandleMessage.js new file mode 100644 index 00000000000..cc995def43f --- /dev/null +++ b/source/class/cv/ui/MHandleMessage.js @@ -0,0 +1,336 @@ +/** + * MHandleMessage mixin provides a handleMessage method for most common use cases in message handling. + * Holds a list of messages + * + * @author Tobias Bräutigam + * @since 0.11.0 + */ + +qx.Mixin.define("cv.ui.MHandleMessage", { + /* + ****************************************************** + CONSTRUCTOR + ****************************************************** + */ + construct: function() { + this._messages = new qx.data.Array(); + + + // severities in order of importance -> more important + this._severities = ["low", "normal", "high", "urgent"]; + }, + + /* + ****************************************************** + PROPERTIES + ****************************************************** + */ + properties: { + /** + * Maximum allowed messages + */ + maxEntries: { + check: "Number", + init: 50, + event: "_applyMaxEntries" + }, + + /** + * Current amount of messages + */ + counter: { + check: "Number", + init: 0, + event: "changedCounter" + }, + + /** + * Highest severity of the messages + */ + globalSeverity: { + check: ["low", "normal", "high", "urgent"], + init: "normal", + event: "changedGlobalSeverity" + }, + + /** + * ID of the root element of this message handler (HTML attribute 'id' value) + */ + rootElementId: { + check: "String", + nullable: true + }, + + /** + * Pattern id the message elements IDs (suffix without is, + * e.g. messages get mes_1, mes_2, ... mes_ is the messageElementId) + */ + messageElementId: { + check: "String", + nullable: true + }, + + delegate: { + check: "Object", + nullable: true + } + }, + + /* + ****************************************************** + MEMBERS + ****************************************************** + */ + members: { + _messages: null, + _severities: null, + __idCounter: 0, + + getSeverities: function() { + return this._severities; + }, + + _updateHighestSeverity: function() { + // get the highest severity + var severityRank = -1; + this._messages.forEach(function(message) { + if (message.severity && this._severities.indexOf(message.severity) > severityRank) { + severityRank = this._severities.indexOf(message.severity); + } + }, this); + if (severityRank >= 0) { + this.setGlobalSeverity(this._severities[severityRank]); + } else { + this.resetGlobalSeverity(); + } + }, + + getSeverityColor: function(severity) { + switch(severity) { + case "urgent": + return "#FF0000"; + case "high": + return "#FF7900"; + default: + return "#1C391C"; + } + }, + + // property apply + _applyMaxEntries: function(value) { + if (this.__messages.getLength() > value) { + this.__messages.splice(this.__messages.getLength() - value); + } + this.__messages.setMaxEntries(value); + }, + + /** + * Handle messages from {@link cv.core.notifications.Router} + * @param message {Map} + * @param config {Map?} optional configuration of this message for the handler + */ + handleMessage: function(message, config) { + var delegate = this.getDelegate() || {}; + if (delegate.prepareMessage) { + delegate.prepareMessage(message, config); + } + var found = null; + var postHookPayload = {}; + if (message.unique) { + // check if message is already shown + this._messages.some(function(msg, index) { + if (message.topic === msg.topic) { + // replace message + found = msg; + message.id = msg.id; + message.tooltip = this._getTooltip(message); + if (!message.hasOwnProperty("deletable")) { + message.deletable = true; + } + if (cv.core.notifications.Router.evaluateCondition(message)) { + var changed = msg.severity !== message.severity; + this._messages.setItem(index, message); + postHookPayload.action = "replaced"; + if (changed) { + this._updateHighestSeverity(); + } + } else{ + var removedMessage = this._messages.removeAt(index); + postHookPayload.action = "removed"; + postHookPayload.message = removedMessage; + if (removedMessage.severity === this.getGlobalSeverity()) { + this._updateHighestSeverity(); + } + } + // stop search + return true; + } + }, this); + } + if (!found) { + if (cv.core.notifications.Router.evaluateCondition(message)) { + message.id = this.__idCounter; + this.__idCounter++; + message.tooltip = this._getTooltip(message); + if (!message.hasOwnProperty("deletable")) { + message.deletable = true; + } + if (this.getMaxEntries() > 0) { + if (this._messages.getLength() >= this.getMaxEntries()) { + this._messages.splice(0, this._messages.getLength() - this.getMaxEntries() + 1).forEach(this._disposeMap); + } + } + postHookPayload.action = "added"; + this._messages.push(message); + this._updateHighestSeverity(); + } + } else { + // refresh list + this._list.update(); + } + this.setCounter(this._messages.getLength()); + if (delegate.postHandleMessage) { + delegate.postHandleMessage(message, config, postHookPayload); + } + }, + + /** + * Finds the message the tap event has been triggered on an returns + * an array [messageId, action], where action can be one of "delete", "action". + * + * @param ev {Event} + * @return {Array} [messageId, action] + */ + getMessageIdFromEvent: function(ev) { + // lets find the real target + var target = ev.getTarget(); + var deleteTarget = null; + var messageId = -1; + var id = qx.bom.element.Attribute.get(target, "id"); + var rootId = this.getRootElementId(); + var messageElementId = this.getMessageElementId(); + while (!id || !id.startsWith(rootId)) { + if (qx.bom.element.Class.has(target, "delete")) { + deleteTarget = target; + } + if (id && id.startsWith(messageElementId)) { + // found the message container, get message id and stop + messageId = parseInt(id.replace(messageElementId, "")); + break; + } + target = target.parentNode; + if (!target) { + break; + } + id = qx.bom.element.Attribute.get(target, "id"); + } + return [messageId, deleteTarget ? "delete" : "action"]; + }, + + _onListTap: function(ev) { + var result = this.getMessageIdFromEvent(ev); + if (result[0] >= 0) { + if (result[1] === "delete") { + this.deleteMessage(result[0], ev); + } else { + this.performAction(result[0], ev); + } + } + }, + + _getTooltip: function(message) { + var tooltip = message.severity; + if (message.actions) { + Object.getOwnPropertyNames(message.actions).forEach(function(type) { + if (message.actions[type].title) { + tooltip = message.actions[type].title; + } + }); + } + return tooltip; + }, + + /** + * Delete all messages. + * + * @param force {Boolean} if false: only delete "deletable" messages, if true: delete all messages + */ + clear: function(force) { + if (force) { + this._messages.removeAll(); + this.__idCounter = 0; + } else { + // collect all deletable messages + var deletable = this._messages.filter(function (message) { + return message.deletable === true; + }, this); + this._messages.exclude(deletable); + } + this._updateHighestSeverity(); + }, + + getMessage: function(index) { + return this._messages.getItem(index); + }, + + getMessages: function() { + return this._messages; + }, + + /** + * Delete a message by index + * @param ev {Event} + * @param index {Number} + */ + deleteMessage: function(index, ev) { + if (ev) { + ev.stopPropagation(); + ev.preventDefault(); + } + var message = this._messages.getItem(index); + if (message.deletable === true) { + this._messages.removeAt(index); + if (message.severity === this.getGlobalSeverity()) { + this._updateHighestSeverity(); + } + } + }, + + performAction: function(messageId, ev) { + var message = this.getMessage(messageId); + if (this._performAction && message) { + var res = this._performAction(message); + if (res === true) { + // skip + return; + } + } + if (!message || !message.actions) { + return; + } + Object.getOwnPropertyNames(message.actions).forEach(function(type) { + var typeActions = qx.lang.Type.isArray(message.actions[type]) ? message.actions[type] : [message.actions[type]]; + typeActions.forEach(function(action) { + if (!action.needsConfirmation) { + var handler = cv.core.notifications.ActionRegistry.getActionHandler(type, action); + if (handler) { + handler.handleAction(ev); + if (action.deleteMessageAfterExecution) { + this.deleteMessage(messageId); + } + } + } + }, this); + }, this); + } + }, + + /* + ****************************************************** + DESTRUCTOR + ****************************************************** + */ + destruct: function() { + this._disposeObjects("_messages"); + } +}); \ No newline at end of file diff --git a/source/class/cv/ui/NotificationCenter.js b/source/class/cv/ui/NotificationCenter.js index 139122e7670..92154009ed0 100644 --- a/source/class/cv/ui/NotificationCenter.js +++ b/source/class/cv/ui/NotificationCenter.js @@ -26,12 +26,18 @@ * topic: {String} Topic of the message * title: {String} Title of the message * message: {String} The message content + * icon: {String} icon name (KNX-UF icon) + * iconClasses: {String} CSS classes that should be added to the icon element * deletable: {Boolean} Flag to determine if the user can delete the message * severity: {String} one of "low", "normal", "high", "urgent" + * tooltip: {String} Tooltip for the message + * progress: {Integer} indicates a progress state in percent of some long running process. * action: { - * callback: {Function} Called when the action gets executed (when the user clicks on the message) - * params {Array?} Additional parameters for the callback - * needsConfirmation: {Boolean} If true the execution of the action must be confirmed by the user + * actionType {String}: { + * action: {Function} Called when the action gets executed (when the user clicks on the message) + * params: {Array?} Additional parameters for the callback + * needsConfirmation: {Boolean} If true the execution of the action must be confirmed by the user + * deleteMessageAfterExecution: {Boolean} If true the message gets deleted after action execution * } * unique: {Boolean} If true there can be only one message of that topic at once * condition: {Boolean|Function} if true this unique message gets removed @@ -45,6 +51,7 @@ qx.Class.define("cv.ui.NotificationCenter", { extend: qx.core.Object, implement: cv.core.notifications.IHandler, + include: cv.ui.MHandleMessage, type: "singleton", /* @@ -55,8 +62,10 @@ qx.Class.define("cv.ui.NotificationCenter", { construct: function () { this.base(arguments); - this.__messages = new qx.data.Array(); - + this.set({ + rootElementId: "notification-center", + messageElementId: "notification_" + }); // register to topics cv.core.notifications.Router.getInstance().registerMessageHandler(this, { 'cv.*': {} @@ -66,10 +75,10 @@ qx.Class.define("cv.ui.NotificationCenter", { this.debouncedHide = new qx.util.Function.debounce(this.hide.bind(this), 5000, false); - // severities in order of importance -> more important - this.__severities = ["low", "normal", "high", "urgent"]; - this._init(); + cv.TemplateEngine.getInstance().executeWhenDomFinished(this._init, this); + + this.addListener("changedGlobalSeverity", this._onSeverityChange, this); }, /* @@ -110,23 +119,40 @@ qx.Class.define("cv.ui.NotificationCenter", { }, /** - * Delete a message by index + * Shortcut to {@link cv.ui.NotificationCenter#deleteMessage} * @param index {Number} + * @param ev {Event} + * @see cv.ui.NotificationCenter#deleteMessage */ - deleteMessage: function(index) { - this.getInstance().deleteMessage(index); + deleteMessage: function(index, ev) { + this.getInstance().deleteMessage(index, ev); }, - clear: function() { - this.getInstance().clear(); + /** + * Shortcut to {@link cv.ui.NotificationCenter#clear} + * @param force {Boolean} + * @see cv.ui.NotificationCenter#clear + */ + clear: function(force) { + this.getInstance().clear(force); }, + /** + * Shortcut to {@link cv.ui.NotificationCenter#hide} + * @see cv.ui.NotificationCenter#hide + */ hide: function() { this.getInstance().hide(); }, - performAction: function(messageId) { - this.getInstance().performAction(messageId); + /** + * Shortcut to {@link cv.ui.NotificationCenter#performAction} + * @param messageId {Number} + * @param ev {Event} + * @see cv.ui.NotificationCenter#performAction + */ + performAction: function(messageId, ev) { + this.getInstance().performAction(messageId, ev); } }, @@ -137,32 +163,9 @@ qx.Class.define("cv.ui.NotificationCenter", { ***************************************************************************** */ properties: { - /** - * Maximum allowed messages - */ - maxEntries: { - check: "Number", - init: 50, - event: "_applyMaxEntries" - }, - /** - * Current amount of messages - */ - counter: { - check: "Number", - init: 0, - event: "changeCounter" - }, - /** - * Highest severity of the messages - */ - globalSeverity: { - check: ["low", "normal", "high", "urgent"], - init: "normal", - event: "changeGlobalSeverity" - } + }, /* @@ -171,20 +174,14 @@ qx.Class.define("cv.ui.NotificationCenter", { ***************************************************************************** */ members: { - __messages : null, + _list: null, __element: null, __messagesContainer: null, - __list: null, __visible: false, __blocker: null, __badge: null, - __severities: null, __favico: null, - getSeverities: function() { - return this.__severities; - }, - disableBadge: function(value) { if (value) { qx.bom.element.Class.add(this.__badge, "hidden"); @@ -223,12 +220,24 @@ qx.Class.define("cv.ui.NotificationCenter", { bgColor: "#1C391C" }); - // create new element - var elem = this.__element = qx.dom.Element.create("div", { - id: "notification-center", - html: '

'+qx.locale.Manager.tr("Message center")+'

' - }); - qx.dom.Element.insertEnd(elem, body); + // check if the element is already there (might have been cached) + var elem = this.__element = qx.bom.Selector.query(this.getRootElementId())[0]; + + if (!elem) { + // create new element + elem = this.__element = qx.dom.Element.create("div", { + id: this.getRootElementId(), + html: '

' + qx.locale.Manager.tr("Message center") + '

' + }); + qx.dom.Element.insertEnd(elem, body); + + var template = qx.dom.Element.create("script", { + id: "MessageTemplate", + type: "text/template", + html: '
{{#title}}

{{ title }}

{{/title}}{{#deletable}}
x
{{/deletable}}
{{&message}}
' + }); + qx.dom.Element.insertEnd(template, body); + } this.__messagesContainer = qx.bom.Selector.query("section.messages", elem)[0]; this.__badge = qx.bom.Selector.query(".badge", elem)[0]; @@ -236,16 +245,12 @@ qx.Class.define("cv.ui.NotificationCenter", { // add HTML template for messages to header - var template = qx.dom.Element.create("script", { - id: "MessageTemplate", - type: "text/template", - html: '
{{#title}}

{{ title }}

{{/title}}
{{&message}} {{#deletable}}{{/deletable}}
' - }); - qx.dom.Element.insertEnd(template, body); - this.__list = new qx.data.controller.website.List(this.__messages, this.__messagesContainer, "MessageTemplate"); + + this._list = new qx.data.controller.website.List(this._messages, this.__messagesContainer, "MessageTemplate"); + qx.event.Registration.addListener(this.__messagesContainer, "tap", this._onListTap, this); // connect badge content - this.__messages.addListener("changeLength", this.__updateBadge, this); + this._messages.addListener("changeLength", this.__updateBadge, this); // update dimensions new qx.util.DeferredCall(this._onResize, this).schedule(); @@ -253,46 +258,37 @@ qx.Class.define("cv.ui.NotificationCenter", { __updateBadge: function() { var currentContent = parseInt(qx.bom.element.Attribute.get(this.__badge, "html")); - this.setCounter(this.__messages.length); - if (currentContent < this.__messages.length) { + var messages = this.getMessages().getLength(); + if (this.getMessages().length === 0) { + // close center if empty + qx.event.Timer.once(function() { + // still empty + if (messages === 0) { + this.hide(); + } + }, this, 1000); + } + if (currentContent < messages) { // blink to get the users attention for the new message qx.bom.element.Animation.animate(this.__badge, cv.ui.NotificationCenter.BLINK); } - if (this.__messages.length) { - qx.bom.element.Attribute.set(this.__badge, "html", ""+this.__messages.length); + if (messages) { + qx.bom.element.Attribute.set(this.__badge, "html", ""+messages); } else{ qx.bom.element.Attribute.set(this.__badge, "html", ""); } - // get the highest severity - var severityRank = -1; - this.__messages.forEach(function(message) { - if (message.severity && this.__severities.indexOf(message.severity) > severityRank) { - severityRank = this.__severities.indexOf(message.severity); - } - }, this); - qx.bom.element.Class.removeClasses(this.__badge, this.__severities); - if (severityRank >= 0) { - this.setGlobalSeverity(this.__severities[severityRank]); - qx.bom.element.Class.add(this.__badge, this.__severities[severityRank]); - } else { - this.resetGlobalSeverity(); - } - // update favicon badge - this.__favico.badge(this.__messages.length, { - bgColor: this.__getSeverityColor(this.__severities[severityRank]) - }); + }, - __getSeverityColor: function(severity) { - switch(severity) { - case "urgent": - return "#FF0000"; - case "high": - return "#FF7900"; - default: - return "#1C391C"; - } + _onSeverityChange: function(ev) { + qx.bom.element.Class.removeClasses(this.__badge, this._severities); + qx.bom.element.Class.add(this.__badge, ev.getData()); + + // update favicon badge + this.__favico.badge(this.getMessages().getLength(), { + bgColor: this.getSeverityColor(ev.getData()) + }); }, /** @@ -334,81 +330,6 @@ qx.Class.define("cv.ui.NotificationCenter", { this.__blocker.unblock(); }, this); } - }, - - _applyMaxEntries: function(value) { - this.__messages.setMaxEntries(value); - }, - - handleMessage: function(message) { - var found = null; - if (message.unique) { - // check if message is already shown - this.__messages.some(function(msg, index) { - if (message.topic === msg.topic) { - // replace message - found = msg; - message.id = this.__messages.length; - if (!message.hasOwnProperty("deletable")) { - message.deletable = true; - } - if (cv.core.notifications.Router.evaluateCondition(message)) { - this.__messages.setItem(index, message); - } else{ - this.__messages.removeAt(index); - } - // stop search - return true; - } - }, this); - } - if (!found) { - if (cv.core.notifications.Router.evaluateCondition(message)) { - message.id = this.__messages.length; - if (!message.hasOwnProperty("deletable")) { - message.deletable = true; - } - this.__messages.push(message); - } - } else { - // refresh list - this.__list.update(); - } - }, - - clear: function() { - // collect all deletable messages - var deletable = this.__messages.filter(function(message) { - return message.deletable === true; - }, this); - this.__messages.exclude(deletable); - }, - - /** - * Delete a message by index - * @param index {Number} - */ - deleteMessage: function(index) { - var message = this.__messages.getItem(index); - if (message.deletable === true) { - this.__messages.removeAt(index); - } - }, - - performAction: function(messageId) { - var message = this.__messages.getItem(messageId); - var action = message.action; - if (action.needsConfirmation) { - // TODO: open confirm dialog - } else { - this.__performAction(action); - } - }, - - __performAction: function(action) { - if (action.callback) { - action.callback.call(action.callback || this, action.params); - } } }, @@ -417,9 +338,10 @@ qx.Class.define("cv.ui.NotificationCenter", { DESTRUCTOR ***************************************************************************** */ - destruct: function () { + destruct: /* istanbul ignore next [destructor not called in singleton] */ function () { qx.event.Registration.removeListener(window, "resize", this._onResize, this); qx.event.Registration.removeListener(this.__blocker.getBlockerElement(), "tap", this.hide, this); - this._disposeObjects("__blocker"); + qx.event.Registration.removeListener(this.__messagesContainer, "tap", this._onListTap, this); + this._disposeObjects("__blocker", "__messagesContainer"); } }); diff --git a/source/class/cv/ui/Popup.js b/source/class/cv/ui/Popup.js index fe06ea471f6..b4cfd0aafac 100644 --- a/source/class/cv/ui/Popup.js +++ b/source/class/cv/ui/Popup.js @@ -32,6 +32,7 @@ qx.Class.define('cv.ui.Popup', { this.setType(type); } this.__deactivateSelectors = ['#top', '#navbarTop', '#centerContainer', '#navbarBottom', '#bottom']; + this.__elementMap = {}; }, /* @@ -65,6 +66,7 @@ qx.Class.define('cv.ui.Popup', { __counter: 0, __deactivateSelectors: null, __domElement: null, + __elementMap: null, getCurrentDomElement: function() { return this.__domElement; @@ -80,49 +82,117 @@ qx.Class.define('cv.ui.Popup', { cv.ui.BodyBlocker.getInstance().block(); var closable = !attributes.hasOwnProperty("closable") || attributes.closable; var body = qx.bom.Selector.query('body')[0]; - var ret_val = this.__domElement = qx.dom.Element.create("div", { - id: "popup_"+this.__counter, - "class": "popup popup_background "+this.getType(), - style: "display:none", - html: closable ? '' : "" - }); - qx.dom.Element.insertEnd(ret_val, body); + var ret_val; + var classes = ["popup", "popup_background", this.getType()]; + var isNew = true; + var addCloseListeners = false; + if (attributes.type) { + classes.push(attributes.type); + } + + if (!this.__domElement) { + ret_val = this.__domElement = qx.dom.Element.create("div", { + id: "popup_" + this.__counter, + "class": classes.join(" "), + style: "display:none", + html: closable ? '' : "" + }); + qx.dom.Element.insertEnd(ret_val, body); + this.__elementMap.close = qx.bom.Selector.query("div.popup_close", ret_val); + addCloseListeners = true; + } else { + isNew = false; + ret_val = this.__domElement; + qx.bom.element.Attribute.set(ret_val, "class", classes.join(" ")); + if (closable && !this.__elementMap.close) { + this.__domElement.close = qx.dom.Element.create("div", {"class": "popup_close", "html": "X"}); + qx.dom.Element.insertBegin(this.__domElement.close, body); + addCloseListeners = true; + } else if (!closable) { + this.destroyElement("close"); + } + } if (attributes.title) { - var title = qx.dom.Element.create("div", { "class": "head"}); - qx.dom.Element.insertEnd(title, ret_val); + if (!this.__elementMap.title) { + this.__elementMap.title = qx.dom.Element.create("div", {"class": "head"}); + qx.dom.Element.insertEnd(this.__elementMap.title, ret_val); + } if (qx.lang.Type.isString(attributes.title)) { - qx.bom.element.Attribute.set(title, "html", ""+attributes.title); + qx.bom.element.Attribute.set(this.__elementMap.title, "html", "" + attributes.title); } else { - qx.dom.Element.insertEnd(attributes.title, title); + qx.dom.Element.insertEnd(attributes.title, this.__elementMap.title); } } - if (attributes.content) { - var content = qx.dom.Element.create("div", { "class": "main"}); - qx.dom.Element.insertEnd(content, ret_val); - if (qx.lang.Type.isString(attributes.content)) { - var html = ""+attributes.content; - if (attributes.icon) { - var icon = qx.util.ResourceManager.getInstance().toUri("icon/knx-uf-iconset.svg")+"#kuf-"+attributes.icon; - html = ''+html; + if (attributes.content || attributes.icon || attributes.progress) { + if (!this.__elementMap.content) { + this.__elementMap.content = qx.dom.Element.create("div", {"class": "main"}); + qx.dom.Element.insertEnd(this.__elementMap.content, ret_val); + } + + if (attributes.icon) { + if (!this.__elementMap.icon) { + this.__elementMap.icon = qx.dom.Element.create("div", {"html": cv.util.IconTools.svgKUF(attributes.icon)(null, null, "icon " + attributes.iconClasses)}); + qx.dom.Element.insertBegin(this.__elementMap.icon, this.__elementMap.content); + } else { + var use = qx.bom.Selector.query("use", this.__elementMap.icon)[0]; + var currentIconPath = qx.bom.element.Attribute.get(use, "xlink:href"); + if (!currentIconPath.endsWith("#kuf-"+attributes.icon)) { + var parts = currentIconPath.split("#"); + qx.bom.element.Attribute.set(use, "xlink:href", parts[0]+"#kuf-"+attributes.icon); + } + } + } else { + this.destroyElement("icon"); + } + if (attributes.content) { + if (!this.__elementMap.messageContent) { + this.__elementMap.messageContent = qx.dom.Element.create("div", {"class": "message"}); + qx.dom.Element.insertBegin(this.__elementMap.messageContent, this.__elementMap.content); + } + if (qx.lang.Type.isString(attributes.content)) { + qx.bom.element.Attribute.set(this.__elementMap.messageContent, "html", attributes.content); + } else { + qx.dom.Element.replaceChild(attributes.content, this.__elementMap.messageContent); + this.__elementMap.messageContent = attributes.content; + } + } else { + this.destroyElement("messageContent"); + } + + if (attributes.progress) { + if (!this.__elementMap.progress) { + var bar = new cv.ui.util.ProgressBar(); + this.__elementMap.progress = bar.getDomElement(); + qx.dom.Element.insertEnd(this.__elementMap.progress, this.__elementMap.content); } - qx.bom.element.Attribute.set(content, "html", html); + this.__elementMap.progress.$$widget.setValue(attributes.progress); } else { - qx.dom.Element.insertEnd(attributes.content, content); + this.destroyElement("progress"); } } if (attributes.actions && Object.getOwnPropertyNames(attributes.actions).length > 0) { - var actions = qx.dom.Element.create("div", {"class": "actions"}); + if (!this.__elementMap.actions) { + this.__elementMap.actions = qx.dom.Element.create("div", {"class": "actions"}); + qx.dom.Element.insertEnd(this.__elementMap.actions, ret_val); + } else { + // clear content + qx.bom.element.Attribute.set(this.__elementMap.actions, "html", ""); + } - Object.getOwnPropertyNames(attributes.actions).forEach(function(type) { - var actionButton = cv.core.notifications.ActionRegistry.createActionElement(type, attributes.actions[type]); - qx.dom.Element.insertEnd(actionButton, actions); - }); - qx.dom.Element.insertEnd(actions, ret_val); + Object.getOwnPropertyNames(attributes.actions).forEach(function (type) { + var typeActions = qx.lang.Type.isArray(attributes.actions[type]) ? attributes.actions[type] : [attributes.actions[type]]; + typeActions.forEach(function (action) { + var actionButton = cv.core.notifications.ActionRegistry.createActionElement(type, action); + qx.dom.Element.insertEnd(actionButton, this.__elementMap.actions); + }, this); + }, this); + } else { + this.destroyElement("actions"); } if (attributes.width) { @@ -173,7 +243,7 @@ qx.Class.define('cv.ui.Popup', { qx.bom.element.Style.set(ret_val, 'left', placement.x); qx.bom.element.Style.set(ret_val, 'top', placement.y); - if (closable) { + if (closable && addCloseListeners) { this.addListener('close', this.close, this); qx.event.Registration.addListener(ret_val, 'tap', function () { // note: this will call two events - one for the popup itself and @@ -186,12 +256,21 @@ qx.Class.define('cv.ui.Popup', { }, this); } - qx.bom.element.Style.set(ret_val, 'display', 'block'); attributes.id = this.__counter; - this.__counter++; + if (isNew) { + qx.bom.element.Style.set(ret_val, 'display', 'block'); + this.__counter++; + } return ret_val; }, + destroyElement: function(name) { + if (this.__elementMap[name]) { + qx.dom.Element.remove(this.__elementMap[name]); + delete this.__elementMap[name]; + } + }, + /** * Closes this popup */ @@ -200,11 +279,21 @@ qx.Class.define('cv.ui.Popup', { if (this.__domElement) { qx.dom.Element.remove(this.__domElement); this.__domElement = null; + this.__elementMap = {}; } }, isClosed: function(){ return this.__domElement === null; } + }, + + /* + ****************************************************** + DESTRUCTOR + ****************************************************** + */ + destruct: function() { + this.close(); } }); \ No newline at end of file diff --git a/source/class/cv/ui/PopupHandler.js b/source/class/cv/ui/PopupHandler.js index 39fd11d0e8c..342c50da6a9 100644 --- a/source/class/cv/ui/PopupHandler.js +++ b/source/class/cv/ui/PopupHandler.js @@ -57,8 +57,11 @@ qx.Class.define('cv.ui.PopupHandler', { title: message.title, content: message.message, closable: message.deletable, - icon: config.icon, - actions: message.actions + icon: message.icon || config.icon, + iconClasses: message.iconClasses, + actions: message.actions, + progress: message.progress, + type: "notification" }; // popups are always unique if (cv.core.notifications.Router.evaluateCondition(message)) { @@ -80,9 +83,9 @@ qx.Class.define('cv.ui.PopupHandler', { */ showPopup: function (type, attributes) { var popup = this.getPopup(type); - if (!popup.isClosed()) { - popup.close(); - } + // if (!popup.isClosed()) { + // popup.close(); + // } popup.create(attributes); return popup; }, diff --git a/source/class/cv/ui/ToastManager.js b/source/class/cv/ui/ToastManager.js new file mode 100644 index 00000000000..be268929325 --- /dev/null +++ b/source/class/cv/ui/ToastManager.js @@ -0,0 +1,123 @@ +/** + * Handles toast positioning in the gui. + */ +qx.Class.define("cv.ui.ToastManager", { + extend: qx.core.Object, + implement: cv.core.notifications.IHandler, + include: cv.ui.MHandleMessage, + type: "singleton", + + /* + ****************************************************** + CONSTRUCTOR + ****************************************************** + */ + construct: function() { + this.base(arguments); + this.set({ + rootElementId: "toast-list", + messageElementId: "toast_" + }); + + this.setDelegate({ + prepareMessage: function(message) { + // all toast messages need a duration + if (!message.hasOwnProperty("duration")) { + message.duration = this.getMessageDuration(); + } + }.bind(this), + postHandleMessage: function(message, config, payload) { + if (payload.action === "added" || payload.action === "replaced") { + // add removal listener + qx.event.Timer.once(function() { + this.getMessages().remove(message); + }, this, message.duration); + } + }.bind(this) + }); + + // as the Mixins constructor has not been called yet, the messages array has not been initialized + // so we defer this call here to make sure everything is in place + new qx.util.DeferredCall(function() { + cv.TemplateEngine.getInstance().executeWhenDomFinished(this._init, this); + }, this).schedule(); + }, + + /* + ****************************************************** + PROPERTIES + ****************************************************** + */ + properties: { + + /** + * Default time in MS a toast message is visible + */ + messageDuration: { + check: "Number", + init: 5000 + } + }, + + + /* + ****************************************************** + MEMBERS + ****************************************************** + */ + members: { + __domElement: null, + __timer: null, + __opened: false, + + /** + * Attach to dom element and style it + */ + _init: function() { + if (!this.__domElement) { + // check if there is one (might be restored from cache) + this.__domElement = qx.bom.Selector.query(this.getRootElementId())[0]; + if (!this.__domElement) { + this.__domElement = qx.dom.Element.create("div", {"id": this.getRootElementId()}); + } + } + if (qx.bom.Selector.query(this.getRootElementId()).length === 0) { + qx.dom.Element.insertEnd(this.__domElement, document.body); + } + if (qx.bom.Selector.query("#ToastTemplate").length === 0) { + var template = qx.dom.Element.create("script", { + id: "ToastTemplate", + type: "text/template", + html: '
{{&message}}
' + }); + qx.dom.Element.insertEnd(template, document.body); + } + this._list = new qx.data.controller.website.List(this._messages, this.__domElement, "ToastTemplate"); + qx.event.Registration.addListener(this.__domElement, "tap", this._onListTap, this); + }, + + _performAction: function(message) { + if (message.actions) { + return false; + } + // default is to delete the toast + this.deleteMessage(message.id); + } + }, + + /* + ****************************************************** + DESTRUCTOR + ****************************************************** + */ + destruct: function() { + if (this.__timer) { + this.__timer.stop(); + this.__timer = null; + } + if (this.__domElement) { + qx.dom.Element.remove(this.__domElement); + this.__domElement = null; + } + } +}); \ No newline at end of file diff --git a/source/class/cv/ui/util/ProgressBar.js b/source/class/cv/ui/util/ProgressBar.js new file mode 100644 index 00000000000..f6998d316c7 --- /dev/null +++ b/source/class/cv/ui/util/ProgressBar.js @@ -0,0 +1,80 @@ +/* NotificationCenter.js + * + * copyright (c) 2010-2017, Christian Mayer and the CometVisu contributers. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 3 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA + */ + + +/** + * Shows a progressbar to visualize a state that is completed by x % + * + * @author Tobias Bräutigam + * @since 0.11.0 + */ +qx.Class.define("cv.ui.util.ProgressBar", { + extend: qx.core.Object, + + /* + ****************************************************** + CONSTRUCTOR + ****************************************************** + */ + construct: function() { + this.base(arguments); + this._createDomElement(); + }, + + /* + ****************************************************** + PROPERTIES + ****************************************************** + */ + properties: { + value: { + check: "Integer", + init: 0, + apply: "_applyValue" + } + }, + + /* + ****************************************************** + MEMBERS + ****************************************************** + */ + members: { + __domElement: null, + __progressElement: null, + + _applyValue: function(value) { + var totalWidth = qx.bom.element.Dimension.getContentWidth(this.__domElement); + var progressWidth = Math.round(totalWidth*value/100)+"px"; + qx.bom.element.Style.set(this.__progressElement, "width", progressWidth); + }, + + getDomElement: function() { + return this.__domElement; + }, + + _createDomElement: function() { + var container = this.__domElement = qx.dom.Element.create("div", { "class": "progressbar" }); + this.__domElement.$$widget = this; + var progress = this.__progressElement = qx.dom.Element.create("div", { "class": "completed" }); + qx.dom.Element.insertEnd(progress, container); + return container; + } + } +}); \ No newline at end of file diff --git a/source/class/cv/util/IconTools.js b/source/class/cv/util/IconTools.js index 2dcd88e915a..f4b5349d061 100644 --- a/source/class/cv/util/IconTools.js +++ b/source/class/cv/util/IconTools.js @@ -245,9 +245,12 @@ qx.Class.define('cv.util.IconTools', { } var iconPath = qx.util.ResourceManager.getInstance().toUri('icon/knx-uf-iconset.svg'); - var style = ''; + var style = styling; if (color) { - style = 'style="color:' + color + '" '; + style += 'color:' + color + ';'; + } + if (style) { + style = ' style="'+style+'" '; } return ''; }; diff --git a/source/class/cv/util/Location.js b/source/class/cv/util/Location.js index e29b5072a8d..e204923377e 100644 --- a/source/class/cv/util/Location.js +++ b/source/class/cv/util/Location.js @@ -55,6 +55,16 @@ qx.Class.define('cv.util.Location', { */ reload: function(value) { window.location.reload(value); + }, + + /** + * Wrapper for calling window.open() + * + * @param url {String} url to open + * @param target {String} where to open the window + */ + open: function(url, target) { + window.open(url, target); } } diff --git a/source/resource/designs/designglobals.css b/source/resource/designs/designglobals.css index e5aadac11d4..68ed2b31875 100755 --- a/source/resource/designs/designglobals.css +++ b/source/resource/designs/designglobals.css @@ -293,19 +293,19 @@ button.ui-slider-handle { } /*severities*/ -#notification-center > .badge.urgent { +#notification-center > .badge.urgent, #toast-list .toast.urgent { background-color: rgba(255, 0, 0, 0.9); } -#notification-center > .badge.high { +#notification-center > .badge.high, #toast-list .toast.high { background-color: rgba(255, 121, 0, 0.9); } -#notification-center > .badge.normal { +#notification-center > .badge.normal, #toast-list .toast.normal { background-color: rgba(255, 244, 230, 0.9); } -#notification-center > .badge.low { +#notification-center > .badge.low, #toast-list .toast.low { background-color: rgba(61, 61, 61, 0.9); } @@ -349,11 +349,11 @@ button.ui-slider-handle { font-weight: bold; } -#notification-center footer:hover { +#notification-center footer .clear:hover { background-color: rgba(0, 0, 0, 0.2); } -#notification-center footer::before { +#notification-center footer .clear::before { content: "X "; } @@ -378,27 +378,28 @@ button.ui-slider-handle { min-height: 25px; } -#notification-center .message:last-child { - border-bottom: none; +#notification-center .message.selectable, #notification-center .message .delete { + cursor: pointer; } -#notification-center .message .content { - float: left; - width: 100%; +#notification-center .message:last-child { + border-bottom: none; } #notification-center .message .delete { float: right; + font-weight: bold; + height: 100%; + margin-left: 1em; + text-transform: uppercase; } #notification-center a { color: #fff; } -.popup.error, -.popup_background.error, -.popup.info, -.popup_background.info { +.popup.notification, +.popup_background.notification { position: absolute; padding: 30px; top: 50%; @@ -442,7 +443,7 @@ button.ui-slider-handle { border-bottom: 1px solid; } -.popup .main .icon { +.popup .main .icon:not(.spinner) { float: left; margin: -1em 1em 0 0; vertical-align: middle; @@ -450,15 +451,34 @@ button.ui-slider-handle { height: 100%; } +.popup .main .icon.spinner { + vertical-align: middle; + align: center; + width: 100%; + margin-top: 20px; + margin-bottom: 10px; + animation: spinner 2s linear infinite; +} + +@keyframes spinner { + from {transform: rotate(0deg);} + to {transform: rotate(360deg);} +} + +.popup.notification .main .message { + margin: 1em 0; +} + .popup .main pre { overflow: auto; font-size: 0.8em; } -.popup .actions { text-align: center; } +.popup .actions { text-align: center; white-space: nowrap; clear: both; } .popup .actions button { padding: 0.3em 1em; font-size: 0.9em; + margin: 0 0.5em; } .popup_background.error { @@ -469,4 +489,46 @@ button.ui-slider-handle { .popup_background.error a { color: #FFF; } -pre.inline { display: inline-block; } \ No newline at end of file +pre.inline { display: inline-block; } + +/* progressbar */ +.popup div.progressbar { + height: 10px; + background-color: white; + padding: 1px; + margin: 10px 5px; + position: relative; +} + +.popup div.progressbar div.completed { + position: absolute; + left: 0; + top: 0; + bottom: 0; + background-color: grey; + margin: 1px; +} +#qxsettings { + position: absolute; + top: revert; + bottom: 0; + left: 0; + right: 0; +} + +#toast-list { + position: absolute; + right: 100px; + bottom: 10px; +} + +#toast-list .toast { + background-color: rgba(61, 61, 61, 0.9); + padding: 8px 18px; + margin: 10px 0; + border-radius: 5px; + overflow: hidden; + width: 200px; + font-size: 14px; + text-align: center; +} \ No newline at end of file diff --git a/source/resource/visu_config.xsd b/source/resource/visu_config.xsd index e77c6011f54..0c21bbfbf7d 100644 --- a/source/resource/visu_config.xsd +++ b/source/resource/visu_config.xsd @@ -561,7 +561,7 @@ - + Template for message title Vorlage für Nachrichtentitel @@ -606,6 +606,12 @@ Nachricht im Notification-Center anzeigen + + + Say message with text-to-speech + Nachricht per Sprachausgabe ausgeben + + @@ -628,6 +634,10 @@ + + Messages with higher priority are marked with different colors + Nachrichten mit höherer Priorität werden farblich gekennzeichnet + diff --git a/source/test/karma/core/notifications/ActionRegistry-spec.js b/source/test/karma/core/notifications/ActionRegistry-spec.js new file mode 100644 index 00000000000..0529d2adbcd --- /dev/null +++ b/source/test/karma/core/notifications/ActionRegistry-spec.js @@ -0,0 +1,17 @@ + + +describe('test the ActionRegistry', function () { + + it("should register an action handler", function() { + var actionHandler = function() { + this.getDomElement = function() { + return "test"; + }; + }; + + cv.core.notifications.ActionRegistry.registerActionHandler("test", actionHandler); + expect(cv.core.notifications.ActionRegistry.createActionElement("unknown")).toBeNull(); + expect(cv.core.notifications.ActionRegistry.createActionElement("test"), {}).toEqual("test"); + cv.core.notifications.ActionRegistry.unregisterActionHandler("test"); + }); +}); \ No newline at end of file diff --git a/source/test/karma/core/notifications/router-spec.js b/source/test/karma/core/notifications/Router-spec.js similarity index 67% rename from source/test/karma/core/notifications/router-spec.js rename to source/test/karma/core/notifications/Router-spec.js index b026e1d20ba..851429c4cb8 100644 --- a/source/test/karma/core/notifications/router-spec.js +++ b/source/test/karma/core/notifications/Router-spec.js @@ -33,17 +33,20 @@ describe('test the notification router', function () { }); it("should test the routing", function() { + var callCounter = 0; qx.Class.define("cv.test.MessageHandler", { extend: qx.core.Object, implement: cv.core.notifications.IHandler, members: { - handleMessage: function() {} + handleMessage: function() { + callCounter++; + } } }); var handler = new cv.test.MessageHandler(); - var spiedHandleMessage = spyOn(handler, "handleMessage"); + var spiedHandleMessage = spyOn(handler, "handleMessage").and.callThrough(); router.registerMessageHandler(handler, { "test.message": {}, @@ -67,6 +70,23 @@ describe('test the notification router', function () { router.dispatchMessage("test.wildcard.anything.thats.possible", {}); expect(spiedHandleMessage).toHaveBeenCalled(); + + // get target from message + var spy = spyOn(cv.ui.PopupHandler, "handleMessage"); + router.dispatchMessage("test.message", {target: "popup"}); + expect(spy).toHaveBeenCalled(); + + spiedHandleMessage.calls.reset(); + // test unknown topic + router.dispatchMessage("unknown.message", {}); + expect(spiedHandleMessage).not.toHaveBeenCalled(); + + // for some reason the spy does not count the number of calls right, so we use our own counter + callCounter = 0; + // dispatch with wildcard + router.dispatchMessage("test.*", {}); + expect(spy).toHaveBeenCalled(); + expect(callCounter).toEqual(2); }); it("should test the state notification handling", function() { @@ -109,5 +129,22 @@ describe('test the notification router', function () { popup = qx.bom.Selector.query("#popup_0")[0]; // as the condition isn't met anymore the popup must be gone expect(popup).toBeUndefined(); + + qx.Class.undefine("cv.test.MessageHandler"); + router.unregisterStateUpdatehandler(["0/0/1"]); + }); + + it("should test the target mapping", function() { + expect(cv.core.notifications.Router.getTarget("popup")).toEqual(cv.ui.PopupHandler); + expect(cv.core.notifications.Router.getTarget("notificationCenter")).toEqual(cv.ui.NotificationCenter.getInstance()); + + // prevent speech target if no browser support + var speechSynthesis = window.speechSynthesis; + delete window.speechSynthesis; + expect(cv.core.notifications.Router.getTarget("speech")).toBeUndefined(); + window.speechSynthesis = speechSynthesis; + expect(cv.core.notifications.Router.getTarget("speech")).toEqual(cv.core.notifications.SpeechHandler.getInstance()); + + expect(cv.core.notifications.Router.getTarget("unknown")).toBeNull(); }); }); \ No newline at end of file diff --git a/source/test/karma/core/notifications/SpeechHandler-spec.js b/source/test/karma/core/notifications/SpeechHandler-spec.js new file mode 100644 index 00000000000..58cca6aef53 --- /dev/null +++ b/source/test/karma/core/notifications/SpeechHandler-spec.js @@ -0,0 +1,55 @@ + + +describe('test the SpeechHandler', function () { + var handler = null; + var spiedSay = null; + + beforeEach(function() { + handler = cv.core.notifications.SpeechHandler.getInstance(); + spiedSay = spyOn(handler, "say"); + }); + + it("should handle a message", function() { + var message = { + topic: "cv.test", + message: "test message" + }; + var config = { + skipInitial: true + }; + + handler.handleMessage(message, config); + // skip initial message + expect(spiedSay).not.toHaveBeenCalled(); + + // second call should work + handler.handleMessage(message, config); + expect(spiedSay).toHaveBeenCalled(); + spiedSay.calls.reset(); + + message.message = ""; + // nothing to say + handler.handleMessage(message, config); + expect(spiedSay).not.toHaveBeenCalled(); + message.message = "test message"; + + // test condition + message.condition = false; + handler.handleMessage(message, config); + expect(spiedSay).not.toHaveBeenCalled(); + message.condition = true; + handler.handleMessage(message, config); + expect(spiedSay).toHaveBeenCalled(); + spiedSay.calls.reset(); + + // test repeat timeout + config.repeatTimeout = 0; + handler.handleMessage(message, config); + expect(spiedSay).not.toHaveBeenCalled(); + // override by text + message.message = "!"+message.message; + handler.handleMessage(message, config); + expect(spiedSay).toHaveBeenCalled(); + spiedSay.calls.reset(); + }); +}); \ No newline at end of file diff --git a/source/test/karma/core/notifications/actions/Link-spec.js b/source/test/karma/core/notifications/actions/Link-spec.js new file mode 100644 index 00000000000..48cb7a7e0c6 --- /dev/null +++ b/source/test/karma/core/notifications/actions/Link-spec.js @@ -0,0 +1,86 @@ + + +describe("testing the Link action", function() { + + it("should create a button DOM element and open an url on click", function() { + var action = new cv.core.notifications.actions.Link({ + title: "Title", + url: "http://localhost/test", + needsConfirmation: false + }); + + var actionButton = action.getDomElement(); + expect(qx.bom.element.Attribute.get(actionButton, "text")).toBe("Title"); + expect(actionButton).toHaveClass("action"); + + var spy = spyOn(cv.util.Location, "open"); + var event = new qx.event.type.Event(); + event.init(true, true); + event.setType("tap"); + + qx.event.Registration.dispatchEvent(actionButton, event); + expect(spy).toHaveBeenCalledWith("http://localhost/test", "_blank"); + }); + + it("should transform string values of action property to functions", function() { + var action = new cv.core.notifications.actions.Link({ + title: "Title", + action: "reload", + needsConfirmation: false + }); + expect(qx.lang.Type.isFunction(action.getAction())).toBeTruthy(); + + action = new cv.core.notifications.actions.Link({ + title: "Title", + action: "unknown", + needsConfirmation: false + }); + expect(action.getAction()).toBeNull(); + + action = new cv.core.notifications.actions.Link({ + title: "Title", + action: function() {}, + needsConfirmation: false + }); + expect(qx.lang.Type.isFunction(action.getAction())).toBeTruthy(); + }); + + it("should execute the actions", function() { + spyOn(cv.util.Location, "reload"); + var action = new cv.core.notifications.actions.Link({ + title: "Title", + action: "reload", + needsConfirmation: false + }); + action.handleAction(); + expect(cv.util.Location.reload).toHaveBeenCalled(); + + spyOn(cv.util.Location, "open"); + action = new cv.core.notifications.actions.Link({ + title: "Title", + url: "/test", + hidden: false, + needsConfirmation: false + }); + action.handleAction(); + expect(cv.util.Location.open).toHaveBeenCalled(); + + var Con = qx.io.request.Xhr; + var spiedXhr; + spyOn(qx.io.request, 'Xhr').and.callFake(function(url) { + var obj = new Con(url); + spiedXhr = spyOn(obj, "send"); + return obj; + }); + + // open url in background + action = new cv.core.notifications.actions.Link({ + title: "Title", + url: "/test", + hidden: true, + needsConfirmation: false + }); + action.handleAction(); + expect(spiedXhr).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/source/test/karma/core/notifications/actions/link-spec.js b/source/test/karma/core/notifications/actions/link-spec.js deleted file mode 100644 index 2b022bab78b..00000000000 --- a/source/test/karma/core/notifications/actions/link-spec.js +++ /dev/null @@ -1,24 +0,0 @@ - - -describe("testing the Link action", function() { - - it("should create a button DOM element and open an url on click", function() { - var action = new cv.core.notifications.actions.Link({ - title: "Title", - url: "http://localhost/test", - needsConfirmation: false - }); - - var actionButton = action.getDomElement(); - expect(qx.bom.element.Attribute.get(actionButton, "text")).toBe("Title"); - expect(actionButton).toHaveClass("action"); - - var spy = spyOn(window, "open"); - var event = new qx.event.type.Event(); - event.init(true, true); - event.setType("tap"); - - qx.event.Registration.dispatchEvent(actionButton, event); - expect(spy).toHaveBeenCalledWith("http://localhost/test", "_blank"); - }); -}); \ No newline at end of file diff --git a/source/test/karma/ui/NotificationCenter-spec.js b/source/test/karma/ui/NotificationCenter-spec.js new file mode 100644 index 00000000000..fa0413c41c5 --- /dev/null +++ b/source/test/karma/ui/NotificationCenter-spec.js @@ -0,0 +1,241 @@ + +describe('test the NotificationCenter', function () { + + var center = cv.ui.NotificationCenter.getInstance(); + + beforeEach(function() { + // set animation time to 0 + cv.ui.NotificationCenter.SLIDE.duration = 0; + center._init(); + }); + + afterEach(function() { + cv.ui.NotificationCenter.clear(true); + cv.ui.NotificationCenter.SLIDE.duration = 350; + }); + + it("should test some basics", function () { + var severities = center.getSeverities(); + expect(severities.indexOf("low")).toBeGreaterThanOrEqual(0); + expect(severities.indexOf("normal")).toBeGreaterThanOrEqual(0); + expect(severities.indexOf("high")).toBeGreaterThanOrEqual(0); + expect(severities.indexOf("urgent")).toBeGreaterThanOrEqual(0); + }); + + it('should toggle the visibility', function(done) { + var element = qx.bom.Selector.query("#notification-center")[0]; + + expect(element).not.toBeUndefined(); + + expect(qx.bom.element.Style.get(element, "transform")).toEqual("none"); + center.toggleVisibility(); + setTimeout(function() { + expect(qx.bom.element.Style.get(element, "transform")).toEqual("matrix(1, 0, 0, 1, -300, 0)"); + center.toggleVisibility(); + setTimeout(function() { + expect(qx.bom.element.Style.get(element, "transform")).toEqual("matrix(1, 0, 0, 1, 0, 0)"); + done(); + }, 100); + }, 100); + }); + + it('should toggle the badge visibility', function(done) { + var element = qx.bom.Selector.query("#notification-center .badge")[0]; + + expect(element).not.toBeUndefined(); + + expect(qx.bom.element.Class.has(element, "hidden")).toBeFalsy(); + center.disableBadge(true); + setTimeout(function() { + expect(qx.bom.element.Class.has(element, "hidden")).toBeTruthy(); + center.disableBadge(false); + setTimeout(function() { + expect(qx.bom.element.Class.has(element, "hidden")).toBeFalsy(); + done(); + }, 10); + }, 10); + }); + + it('should handle messages', function() { + + var message = { + topic: "cv.test", + title: "Title", + message: "Test message", + severity: "normal" + }; + var badge = qx.bom.Selector.query("#notification-center .badge")[0]; + expect(badge).not.toBeUndefined(); + + center.handleMessage(qx.lang.Object.clone(message)); + expect(center.getMessages().getLength()).toBe(1); + + expect(qx.bom.element.Attribute.get(badge, "html")).toEqual("1"); + expect(qx.bom.element.Class.has(badge, "normal")).toBeTruthy(); + + // add message with higher severity + message.severity = "high"; + message.unique = true; + + center.handleMessage(qx.lang.Object.clone(message)); + // as the message was unique it replaces the old one + expect(center.getMessages().getLength()).toBe(1); + + expect(qx.bom.element.Attribute.get(badge, "html")).toEqual("1"); + expect(qx.bom.element.Class.has(badge, "high")).toBeTruthy(); + + // add message with higher severity + message.severity = "urgent"; + message.unique = false; + + center.handleMessage(qx.lang.Object.clone(message)); + // as the message was unique it replaces the old one + expect(center.getMessages().getLength()).toBe(2); + + expect(qx.bom.element.Attribute.get(badge, "html")).toEqual("2"); + expect(qx.bom.element.Class.has(badge, "urgent")).toBeTruthy(); + + // remove unique messages + message.condition = false; + message.unique = true; + + center.handleMessage(qx.lang.Object.clone(message)); + center.handleMessage(qx.lang.Object.clone(message)); + // as we had 2 messages with same topic both should be gone now + expect(center.getMessages().getLength()).toBe(0); + + expect(qx.bom.element.Attribute.get(badge, "html")).toBeNull(); + expect(qx.bom.element.Class.has(badge, "urgent")).toBeFalsy(); + }); + + it("should test the maxEntries limit", function() { + center.setMaxEntries(5); + var message = { + topic: "cv.test", + title: "Title", + message: "Test message", + severity: "normal" + }; + + for(var i=0; i< 10; i++) { + var msg = qx.lang.Object.clone(message); + msg.title = i; + center.handleMessage(msg); + } + + expect(center.getMessages().getLength()).toBe(5); + expect(center.getMessages().getItem(0).title).toBe(5); + + // delete a message by index + cv.ui.NotificationCenter.deleteMessage(0); + expect(center.getMessages().getLength()).toBe(4); + expect(center.getMessages().getItem(0).title).toBe(6); + + // delete a message by index which is not deletable + center.getMessages().getItem(0).deletable = false; + cv.ui.NotificationCenter.deleteMessage(0); + expect(center.getMessages().getLength()).toBe(4); + expect(center.getMessages().getItem(0).title).toBe(6); + }); + + it("should perform a message action", function() { + var spy = jasmine.createSpy(); + + qx.Class.define("cv.test.ActionHandler", { + extend: cv.core.notifications.actions.AbstractActionHandler, + implement: cv.core.notifications.IActionHandler, + + members: { + handleAction: function() { + spy(); + }, + getDomElement: function() { + return null; + } + } + }); + cv.core.notifications.ActionRegistry.registerActionHandler("test", cv.test.ActionHandler); + + var message = { + topic: "cv.test", + title: "Title", + message: "Test message", + severity: "normal", + actions: { + test: [{ + needsConfirmation: false, + deleteMessageAfterExecution: true + }] + } + }; + center.handleMessage(message); + cv.ui.NotificationCenter.performAction(center.getMessages().getLength()-1); + expect(spy).toHaveBeenCalled(); + cv.core.notifications.ActionRegistry.unregisterActionHandler("test"); + + // message should have been deleted by action execution + expect(center.getMessages().getLength()).toEqual(0); + + qx.Class.undefine("cv.test.ActionHandler"); + }); + + it("should test the interaction handling with list items", function() { + if (window.PointerEvent) { + + qx.Class.define("cv.test.ActionHandler", { + extend: cv.core.notifications.actions.AbstractActionHandler, + implement: cv.core.notifications.IActionHandler, + + members: { + handleAction: function () { + }, + getDomElement: function () { + return null; + } + } + }); + cv.core.notifications.ActionRegistry.registerActionHandler("test", cv.test.ActionHandler); + + var message = { + topic: "cv.test", + title: "Title", + message: "Test message", + severity: "normal", + actions: { + test: [{ + needsConfirmation: false, + deleteMessageAfterExecution: true + }] + } + }; + center.handleMessage(message); + + var messageElement = qx.bom.Selector.query("#notification_0")[0]; + spyOn(center, "deleteMessage"); + spyOn(center, "performAction"); + + // click on the message content + // qx.event.Registration.fireEvent(qx.bom.Selector.query(".content", messageElement)[0], "tap"); + var down = new PointerEvent("pointerdown", { + bubbles: true, + cancelable: true, + view: window + }); + var up = new PointerEvent("pointerup", { + bubbles: true, + cancelable: true, + view: window + }); + var element = qx.bom.Selector.query(".content", messageElement)[0]; + element.dispatchEvent(down); + element.dispatchEvent(up); + expect(center.performAction).toHaveBeenCalledWith(0, jasmine.any(qx.event.type.Event)); + + // click on the delete button + element = qx.bom.Selector.query(".delete", messageElement)[0]; + element.dispatchEvent(down); + element.dispatchEvent(up); + expect(center.deleteMessage).toHaveBeenCalledWith(0, jasmine.any(qx.event.type.Event)); + } + }); +}); \ No newline at end of file diff --git a/source/test/karma/ui/ToastManager-spec.js b/source/test/karma/ui/ToastManager-spec.js new file mode 100644 index 00000000000..4d0478d01f6 --- /dev/null +++ b/source/test/karma/ui/ToastManager-spec.js @@ -0,0 +1,217 @@ + +describe('test the NotificationCenter', function () { + + var center = cv.ui.ToastManager.getInstance(); + + beforeEach(function() { + center._init(); + }); + + afterEach(function() { + center.clear(true); + }); + + it("should test some basics", function () { + var severities = center.getSeverities(); + expect(severities.indexOf("low")).toBeGreaterThanOrEqual(0); + expect(severities.indexOf("normal")).toBeGreaterThanOrEqual(0); + expect(severities.indexOf("high")).toBeGreaterThanOrEqual(0); + expect(severities.indexOf("urgent")).toBeGreaterThanOrEqual(0); + }); + + it('should handle messages', function() { + + var message = { + topic: "cv.test", + title: "Title", + message: "Test message", + severity: "normal", + target: "toast" + }; + + center.handleMessage(qx.lang.Object.clone(message)); + expect(center.getMessages().getLength()).toBe(1); + + // add message with higher severity + message.severity = "high"; + message.unique = true; + + var messageId = center.__idCounter-1; + center.handleMessage(qx.lang.Object.clone(message)); + // as the message was unique it replaces the old one + expect(center.getMessages().getLength()).toBe(1); + + var messageElement = qx.bom.Selector.query("#"+center.getMessageElementId()+messageId)[0]; + expect(qx.bom.element.Class.has(messageElement, "high")).toBeTruthy(); + + // add message with higher severity + message.severity = "urgent"; + message.unique = false; + + messageId = center.__idCounter; + center.handleMessage(qx.lang.Object.clone(message)); + // as the message was unique it replaces the old one + expect(center.getMessages().getLength()).toBe(2); + + messageElement = qx.bom.Selector.query("#"+center.getMessageElementId()+messageId)[0]; + expect(qx.bom.element.Class.has(messageElement, "urgent")).toBeTruthy(); + + // remove unique messages + message.condition = false; + message.unique = true; + + center.handleMessage(qx.lang.Object.clone(message)); + center.handleMessage(qx.lang.Object.clone(message)); + // as we had 2 messages with same topic both should be gone now + expect(center.getMessages().getLength()).toBe(0); + + }); + + it("should test the maxEntries limit", function() { + center.setMaxEntries(5); + var message = { + topic: "cv.test", + title: "Title", + message: "Test message", + severity: "normal", + target: "toast" + }; + + for(var i=0; i< 10; i++) { + var msg = qx.lang.Object.clone(message); + msg.title = i; + center.handleMessage(msg); + } + + expect(center.getMessages().getLength()).toBe(5); + expect(center.getMessages().getItem(0).title).toBe(5); + + // delete a message by index + center.deleteMessage(0); + expect(center.getMessages().getLength()).toBe(4); + expect(center.getMessages().getItem(0).title).toBe(6); + + // delete a message by index which is not deletable + center.getMessages().getItem(0).deletable = false; + center.deleteMessage(0); + expect(center.getMessages().getLength()).toBe(4); + expect(center.getMessages().getItem(0).title).toBe(6); + }); + + it("should perform a message action", function() { + var spy = jasmine.createSpy(); + + qx.Class.define("cv.test.ActionHandler", { + extend: cv.core.notifications.actions.AbstractActionHandler, + implement: cv.core.notifications.IActionHandler, + + members: { + handleAction: function() { + spy(); + }, + getDomElement: function() { + return null; + } + } + }); + cv.core.notifications.ActionRegistry.registerActionHandler("test", cv.test.ActionHandler); + + var message = { + topic: "cv.test", + title: "Title", + message: "Test message", + severity: "normal", + actions: { + test: [{ + needsConfirmation: false, + deleteMessageAfterExecution: true + }] + }, + target: "toast" + }; + center.handleMessage(message); + center.performAction(center.getMessages().getLength()-1); + expect(spy).toHaveBeenCalled(); + cv.core.notifications.ActionRegistry.unregisterActionHandler("test"); + + // message should have been deleted by action execution + expect(center.getMessages().getLength()).toEqual(0); + + qx.Class.undefine("cv.test.ActionHandler"); + }); + + it("should test the interaction handling with list items", function() { + if (window.PointerEvent) { + // click on the message content + var down = new PointerEvent("pointerdown", { + bubbles: true, + cancelable: true, + view: window + }); + var up = new PointerEvent("pointerup", { + bubbles: true, + cancelable: true, + view: window + }); + + qx.Class.define("cv.test.ActionHandler", { + extend: cv.core.notifications.actions.AbstractActionHandler, + implement: cv.core.notifications.IActionHandler, + + members: { + handleAction: function () { + }, + getDomElement: function () { + return null; + } + } + }); + cv.core.notifications.ActionRegistry.registerActionHandler("test", cv.test.ActionHandler); + + spyOn(center, "deleteMessage"); + // test if message without action gets deleted + var message = { + topic: "cv.test", + title: "Title", + message: "Test message", + severity: "normal", + target: "toast" + }; + var messageId = center.__idCounter; + center.handleMessage(message); + + var element = qx.bom.Selector.query("#"+center.getMessageElementId()+messageId)[0]; + element.dispatchEvent(down); + element.dispatchEvent(up); + expect(center.deleteMessage).toHaveBeenCalledWith(messageId); + + message = { + topic: "cv.test", + title: "Title", + message: "Test message", + severity: "normal", + actions: { + test: [{ + needsConfirmation: false, + deleteMessageAfterExecution: true + }] + }, + target: "toast" + }; + + center.handleMessage(message); + + element = qx.bom.Selector.query("#"+center.getMessageElementId()+messageId)[0]; + + spyOn(center, "performAction"); + + element.dispatchEvent(down); + element.dispatchEvent(up); + expect(center.performAction).toHaveBeenCalledWith(messageId, jasmine.any(qx.event.type.Event)); + + center.deleteMessage(messageId); + + + } + }); +}); \ No newline at end of file diff --git a/source/test/karma/xml/parser/Meta-spec.js b/source/test/karma/xml/parser/Meta-spec.js index 3f232ec0e16..c57a187db9f 100644 --- a/source/test/karma/xml/parser/Meta-spec.js +++ b/source/test/karma/xml/parser/Meta-spec.js @@ -39,6 +39,11 @@ describe("testing the meta parser", function() { y = x/1000; + + Flur OG + Küche + Esszimmer + @@ -51,6 +56,18 @@ describe("testing the meta parser", function() { red + + + Bewegungsalarm + Bewegung erkannt: {{ address }}, {{ time }} + ON + +
Motion_FF_Dining
+
Motion_FF_Corridor
+
Motion_FF_Kitchen
+
+
+
by CometVisu.org @@ -77,6 +94,7 @@ describe("testing the meta parser", function() { expect(cv.Config.hasMapping('Sign')).toBeTruthy(); expect(cv.Config.hasMapping('KonnexHVAC')).toBeTruthy(); expect(cv.Config.hasMapping('One1000th')).toBeTruthy(); + expect(cv.Config.hasMapping('Motion_name')).toBeTruthy(); // check stylings expect(cv.Config.hasStyling('Red_Green')).toBeTruthy(); @@ -88,6 +106,21 @@ describe("testing the meta parser", function() { expect(plugins).toContain("plugin-diagram"); expect(plugins).toContain("plugin-strftime"); + // test notifications + var router = cv.core.notifications.Router.getInstance(); + var config = router.__stateMessageConfig; + + expect(config.hasOwnProperty("Motion_FF_Dining")).toBeTruthy(); + expect(config.hasOwnProperty("Motion_FF_Corridor")).toBeTruthy(); + expect(config.hasOwnProperty("Motion_FF_Kitchen")).toBeTruthy(); + + // state listeners must be set + var model = cv.data.Model.getInstance(); + ["Motion_FF_Dining", "Motion_FF_Corridor", "Motion_FF_Kitchen"].forEach(function(address) { + expect(model.__stateListeners.hasOwnProperty(address)).toBeTruthy(); + expect(model.__stateListeners[address].length).toEqual(1); + }); + qx.dom.Element.remove(footer); }); }); \ No newline at end of file diff --git a/source/translation/de.po b/source/translation/de.po index 591746bc208..07dfceae44e 100644 --- a/source/translation/de.po +++ b/source/translation/de.po @@ -11,49 +11,64 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -#: cv/Application.js:172 +#: cv/Application.js:175 msgid "Please describe what you have done until the error occured?" -msgstr "Bitte beschreiben, was Sie gemacht haben bis der Fehler aufgetreten ist." +msgstr "" +"Bitte beschreiben, was Sie gemacht haben bis der Fehler aufgetreten ist." -#: cv/Application.js:176 +#: cv/Application.js:207 msgid "An error occured" msgstr "Ein Fehler ist aufgetreten" -#: cv/Application.js:182 +#: cv/Application.js:214 +msgid "Reload" +msgstr "Neu laden" + +#: cv/Application.js:241 +msgid "Please do not forget to attach the downloaded Logfile to this ticket." +msgstr "" +"Bitte vergessen Sie nicht, die heruntergeladenen Log-Datei an dieses Ticket " +"zu hängen." + +#: cv/Application.js:248 +msgid "Enable reporting on reload" +msgstr "Neu laden mit aktivierter Aufzeichnung" + +#: cv/Application.js:253 msgid "Report Bug" msgstr "Fehler berichten" -#: cv/Application.js:489 +#: cv/Application.js:561 msgid "Config-File Error!" msgstr "Fehler in Konfigurations-Datei!" -#: cv/Application.js:493 +#: cv/Application.js:565 msgid "Invalid config file!" msgstr "Ungültige Konfigurations-Datei!" -#: cv/Application.js:493 +#: cv/Application.js:565 msgid "Please check!" msgstr "Bitte prüfen!" -#: cv/Application.js:501 +#: cv/Application.js:573 msgid "Config file has wrong library version!" msgstr "Konfigurations-Datei hat die falsche 'library' Version" -#: cv/Application.js:502 +#: cv/Application.js:574 msgid "This can cause problems with your configuration" msgstr "Das kann Probleme in Ihrer Konfiguration verursachen" -#: cv/Application.js:503 +#: cv/Application.js:575 msgid "You can run the %1Configuration Upgrader%2." msgstr "Sie können den %1Konfigurations-Upgrader%2 laufen lassen." -#: cv/Application.js:504 +#: cv/Application.js:576 msgid "" "Or you can start without upgrading %1with possible configuration problems%2" msgstr "" "Oder Sie starten ohne Upgrade %1mit möglichen Konfigurations-Problemen%2" -#: cv/Application.js:507 +#: cv/Application.js:579 msgid "" "404: Config file not found. Neither as normal config (%1) nor as demo config" " (%2)." @@ -61,27 +76,49 @@ msgstr "" "404: Konfigurations-Datei nicht gefunden. Weder als normale Konfiguration " "(%1) noch als Demo-Konfiguration (%2)" -#: cv/Application.js:510 +#: cv/Application.js:582 msgid "Unhandled error of type \"%1\"" msgstr "Unbekannter Fehler vom Typ \"%1\"" -#: cv/TemplateEngine.js:204 +#: cv/TemplateEngine.js:245 msgid "Connection error" msgstr "Verbindungsfehler" -#: cv/TemplateEngine.js:213 +#: cv/TemplateEngine.js:254 msgid "Error requesting %1: %2 - %3." msgstr "Fehler beim Laden von %1: %2 - %3." -#: cv/TemplateEngine.js:215 +#: cv/TemplateEngine.js:256 msgid "Connection to backend is lost." msgstr "Verbindung zum Backend verloren." -#: cv/ui/NotificationCenter.js:227 +#: cv/plugins/openhab/Settings.js:173 +msgid "" +"The CometVisu seems to be delivered by a proxied webserver. Changing " +"configuration values might not have the expected effect. Please proceed only" +" if you know what you are doing." +msgstr "" +"Die CometVisu scheint über einen Proxy ausgeliefert zu werden. Änderungen " +"an den Einstellungen könnten deshalb nicht den gewünschten Effekt habe." +"Bitte führen Sie daher nur Änderungen durch, wenn Sie wissen was Sie tun." + +#: cv/plugins/openhab/Settings.js:211 +msgid "openHAB backend settings" +msgstr "openHAB Backend Einstellungen" + +#: cv/plugins/openhab/Settings.js:226 +msgid "Cancel" +msgstr "Abbruch" + +#: cv/plugins/openhab/Settings.js:231 +msgid "Save" +msgstr "Speichern" + +#: cv/ui/NotificationCenter.js:257 msgid "Delete all" msgstr "Alle löschen" -#: cv/ui/NotificationCenter.js:227 +#: cv/ui/NotificationCenter.js:257 msgid "Message center" msgstr "Nachrichtenzentrale" diff --git a/source/translation/en.po b/source/translation/en.po index fe73f9bcbe3..32ce5f172da 100644 --- a/source/translation/en.po +++ b/source/translation/en.po @@ -11,74 +11,105 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -#: cv/Application.js:172 +#: cv/Application.js:175 msgid "Please describe what you have done until the error occured?" msgstr "" -#: cv/Application.js:176 +#: cv/Application.js:207 msgid "An error occured" msgstr "" -#: cv/Application.js:182 +#: cv/Application.js:214 +msgid "Reload" +msgstr "" + +#: cv/Application.js:241 +msgid "Please do not forget to attach the downloaded Logfile to this ticket." +msgstr "" + +#: cv/Application.js:248 +msgid "Enable reporting on reload" +msgstr "" + +#: cv/Application.js:253 msgid "Report Bug" msgstr "" -#: cv/Application.js:489 +#: cv/Application.js:561 msgid "Config-File Error!" msgstr "" -#: cv/Application.js:493 +#: cv/Application.js:565 msgid "Invalid config file!" msgstr "" -#: cv/Application.js:493 +#: cv/Application.js:565 msgid "Please check!" msgstr "" -#: cv/Application.js:501 +#: cv/Application.js:573 msgid "Config file has wrong library version!" msgstr "" -#: cv/Application.js:502 +#: cv/Application.js:574 msgid "This can cause problems with your configuration" msgstr "" -#: cv/Application.js:503 +#: cv/Application.js:575 msgid "You can run the %1Configuration Upgrader%2." msgstr "" -#: cv/Application.js:504 +#: cv/Application.js:576 msgid "" "Or you can start without upgrading %1with possible configuration problems%2" msgstr "" -#: cv/Application.js:507 +#: cv/Application.js:579 msgid "" "404: Config file not found. Neither as normal config (%1) nor as demo config" " (%2)." msgstr "" -#: cv/Application.js:510 +#: cv/Application.js:582 msgid "Unhandled error of type \"%1\"" msgstr "" -#: cv/TemplateEngine.js:204 +#: cv/TemplateEngine.js:245 msgid "Connection error" msgstr "" -#: cv/TemplateEngine.js:213 +#: cv/TemplateEngine.js:254 msgid "Error requesting %1: %2 - %3." msgstr "" -#: cv/TemplateEngine.js:215 +#: cv/TemplateEngine.js:256 msgid "Connection to backend is lost." msgstr "" -#: cv/ui/NotificationCenter.js:227 +#: cv/plugins/openhab/Settings.js:173 +msgid "" +"The CometVisu seems to be delivered by a proxied webserver. Changing " +"configuration values might not have the expected effect. Please proceed only" +" if you know what you are doing." +msgstr "" + +#: cv/plugins/openhab/Settings.js:211 +msgid "openHAB backend settings" +msgstr "" + +#: cv/plugins/openhab/Settings.js:226 +msgid "Cancel" +msgstr "" + +#: cv/plugins/openhab/Settings.js:231 +msgid "Save" +msgstr "" + +#: cv/ui/NotificationCenter.js:257 msgid "Delete all" msgstr "" -#: cv/ui/NotificationCenter.js:227 +#: cv/ui/NotificationCenter.js:257 msgid "Message center" msgstr "" diff --git a/utils/docutils/directives/common.py b/utils/docutils/directives/common.py index aa9fb73116e..ad4c9984b7c 100644 --- a/utils/docutils/directives/common.py +++ b/utils/docutils/directives/common.py @@ -91,16 +91,18 @@ def generate_table(self, element_name, include_name=False, mandatory=False): for attr in attributes: if 'name' in attr.attrib: name = attr.get('name') - atype, values = schema.get_attribute_type(attr) + atype, values, enums = schema.get_attribute_type(attr) description = schema.get_node_documentation(attr, self.locale) if description is not None: description = re.sub("\n\s+", " ", description.text).strip() + elif enums is not None: + description = self.get_description(enums) else: description = '' elif 'ref' in attr.attrib: name = attr.get('ref') type_def = schema.get_attribute(name) - atype, values = schema.get_attribute_type(type_def) + atype, values, enums = schema.get_attribute_type(type_def) # check if there is some documentation here description = schema.get_node_documentation(attr, self.locale) if description is None: @@ -108,6 +110,8 @@ def generate_table(self, element_name, include_name=False, mandatory=False): description = schema.get_node_documentation(type_def, self.locale) if description is not None: description = re.sub("\n\s+", " ", description.text).strip() + elif enums is not None: + description = self.get_description(enums) else: description = '' @@ -150,6 +154,19 @@ def generate_table(self, element_name, include_name=False, mandatory=False): return table_node + def get_description(self, enums): + tmp_doc = "" + enum_doc_found = False + for enum_node in enums: + ed = schema.get_node_documentation(enum_node, self.locale) + if ed is not None: + enum_doc_found = True + tmp_doc += "%s* *%s*: %s" % ("\n" if len(tmp_doc) else "", enum_node.get('value'), ed.text) + if enum_doc_found: + return tmp_doc + else: + return "" + def generate_complex_table(self, element_name, include_name=False, mandatory=False, table_body=None, sub_run=False, parent=None): """ needs to be fixed """ @@ -166,19 +183,23 @@ def generate_complex_table(self, element_name, include_name=False, mandatory=Fal for attr in attributes: if 'name' in attr.attrib: name = attr.get('name') - atype, values = schema.get_attribute_type(attr) + atype, values, enums = schema.get_attribute_type(attr) description = schema.get_node_documentation(attr, self.locale) if description is not None: description = description.text + elif enums is not None: + description = self.get_description(enums) else: description = '' elif 'ref' in attr.attrib: name = attr.get('ref') type_def = schema.get_attribute(name) - atype, values = schema.get_attribute_type(type_def) + atype, values, enums = schema.get_attribute_type(type_def) description = schema.get_node_documentation(type_def, self.locale) if description is not None: description = description.text + elif enums is not None: + description = self.get_description(enums) else: description = '' diff --git a/utils/docutils/directives/helper/schema.py b/utils/docutils/directives/helper/schema.py index 75a0b73a4b7..641bf3af28b 100644 --- a/utils/docutils/directives/helper/schema.py +++ b/utils/docutils/directives/helper/schema.py @@ -84,13 +84,14 @@ def get_node_documentation(self, node, locale): def get_attribute_type(self, node): type = None values = [] + enums = None if 'type' in node.attrib: type = node.get('type') elif len(node.findall("xs:simpleType/xs:restriction/xs:enumeration".replace("xs:", SCHEMA_SPACE))) > 0: enums = node.findall("xs:simpleType/xs:restriction/xs:enumeration".replace("xs:", SCHEMA_SPACE)) values = [enum.get('value') for enum in enums] type = node.find("xs:simpleType/xs:restriction".replace("xs:", SCHEMA_SPACE)).get("base") - return type, values + return type, values, enums def get_element_attributes(self, name):