diff --git a/ui-ngx/angular.json b/ui-ngx/angular.json index 0eab7d353c..113233b012 100644 --- a/ui-ngx/angular.json +++ b/ui-ngx/angular.json @@ -30,6 +30,8 @@ "src/styles.scss" ], "scripts": [ + "node_modules/javascript-detect-element-resize/detect-element-resize.js", + "node_modules/jquery/dist/jquery.min.js", "node_modules/ace-builds/src-min/ace.js", "node_modules/ace-builds/src-min/ext-language_tools.js", "node_modules/ace-builds/src-min/ext-searchbox.js", @@ -71,10 +73,10 @@ "sourceMap": false, "extractCss": true, "namedChunks": false, - "aot": true, + "aot": false, "extractLicenses": true, "vendorChunk": false, - "buildOptimizer": true, + "buildOptimizer": false, "budgets": [ { "type": "initial", diff --git a/ui-ngx/package-lock.json b/ui-ngx/package-lock.json index 26c4516940..71c03da869 100644 --- a/ui-ngx/package-lock.json +++ b/ui-ngx/package-lock.json @@ -1993,8 +1993,7 @@ "base64-js": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", - "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==", - "dev": true + "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==" }, "base64id": { "version": "1.0.0", @@ -6336,6 +6335,16 @@ "integrity": "sha1-43zwsX8ZnM4jvqcbIDk5Uka07E4=", "dev": true }, + "javascript-detect-element-resize": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/javascript-detect-element-resize/-/javascript-detect-element-resize-0.5.3.tgz", + "integrity": "sha1-GnHNUd/lZZB/KZAS/nOilBBAJd4=" + }, + "jquery": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.4.1.tgz", + "integrity": "sha512-36+AdBzCL+y6qjw5Tx7HgzeGCzC81MDDgaUP8ld2zhx58HdqXGoBd+tHdrBMiyjGQs0Hxs/MLZTu/eHNJJuWPw==" + }, "js-tokens": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 2d8a1a5d2b..5548100061 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -33,11 +33,14 @@ "@ngx-translate/http-loader": "^4.0.0", "ace-builds": "^1.4.5", "angular-gridster2": "^8.1.0", + "base64-js": "^1.3.1", "compass-sass-mixins": "^0.12.7", "core-js": "^3.1.4", "deep-equal": "^1.0.1", "font-awesome": "^4.7.0", "hammerjs": "^2.0.8", + "javascript-detect-element-resize": "^0.5.3", + "jquery": "^3.4.1", "material-design-icons": "^3.0.1", "messageformat": "^2.3.0", "ngx-clipboard": "^12.2.0", diff --git a/ui-ngx/src/app/core/api/widget-api.models.ts b/ui-ngx/src/app/core/api/widget-api.models.ts new file mode 100644 index 0000000000..0f035cd195 --- /dev/null +++ b/ui-ngx/src/app/core/api/widget-api.models.ts @@ -0,0 +1,123 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Observable } from 'rxjs'; +import { EntityId } from '@app/shared/models/id/entity-id'; +import { WidgetActionDescriptor, widgetType } from '@shared/models/widget.models'; +import { TimeService } from '../services/time.service'; +import { DeviceService } from '../http/device.service'; +import { AlarmService } from '../http/alarm.service'; +import { UtilsService } from '@core/services/utils.service'; + +export interface TimewindowFunctions { + onUpdateTimewindow: (startTimeMs: number, endTimeMs: number, interval: number) => void; + onResetTimewindow: () => void; +} + +export interface WidgetSubscriptionApi { + createSubscription: (options: WidgetSubscriptionOptions, subscribe: boolean) => Observable; + createSubscriptionFromInfo: (type: widgetType, subscriptionsInfo: Array, + options: WidgetSubscriptionOptions, useDefaultComponents: boolean, subscribe: boolean) + => Observable; + removeSubscription: (id: string) => void; +} + +export interface RpcApi { + sendOneWayCommand: (method: string, params?: any, timeout?: number) => Observable; + sendTwoWayCommand: (method: string, params?: any, timeout?: number) => Observable; +} + +export interface IWidgetUtils { + formatValue: (value: any, dec?: number, units?: string, showZeroDecimals?: boolean) => string | undefined; +} + +export interface WidgetActionsApi { + actionDescriptorsBySourceId: {[sourceId: string]: Array}; + getActionDescriptors: (actionSourceId: string) => Array; + handleWidgetAction: ($event: Event, descriptor: WidgetActionDescriptor, + entityId?: EntityId, entityName?: string, additionalParams?: any) => void; + elementClick: ($event: Event) => void; +} + +export interface IAliasController { + [key: string]: any | null; + // TODO: +} + +export interface StateObject { + id?: string; + params?: StateParams; +} + +export interface StateParams { + entityName?: string; + targetEntityParamName?: string; + entityId?: EntityId; + [key: string]: any | null; +} + +export interface IStateController { + getStateParams: () => StateParams; + openState: (id: string, params?: StateParams, openRightLayout?: boolean) => void; + updateState: (id?: string, params?: StateParams, openRightLayout?: boolean) => void; + // TODO: +} + +export interface EntityInfo { + entityId: EntityId; + entityName: string; +} + +export interface SubscriptionInfo { + [key: string]: any; + // TODO: +} + +export interface WidgetSubscriptionContext { + timeService: TimeService; + deviceService: DeviceService; + alarmService: AlarmService; + utils: UtilsService; + widgetUtils: IWidgetUtils; + dashboardTimewindowApi: TimewindowFunctions; + getServerTimeDiff: Observable; + aliasController: IAliasController; + [key: string]: any; + // TODO: +} + +export interface WidgetSubscriptionOptions { + [key: string]: any; + // TODO: +} + +export interface IWidgetSubscription { + + onUpdateTimewindow: (startTimeMs: number, endTimeMs: number, interval: number) => void; + onResetTimewindow: () => void; + + sendOneWayCommand: (method: string, params?: any, timeout?: number) => Observable; + sendTwoWayCommand: (method: string, params?: any, timeout?: number) => Observable; + + clearRpcError: () => void; + + getFirstEntityInfo: () => EntityInfo; + + destroy(): void; + + [key: string]: any; + // TODO: +} diff --git a/ui-ngx/src/app/core/css/css.js b/ui-ngx/src/app/core/css/css.js new file mode 100644 index 0000000000..d568f5e3b9 --- /dev/null +++ b/ui-ngx/src/app/core/css/css.js @@ -0,0 +1,688 @@ +/* + * Copyright © 2016-2019 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* eslint-disable */ + +/* jshint unused:false */ +/* global base64_decode, CSSWizardView, window, console, jQuery */ +var fi = function () { + + this.cssImportStatements = []; + this.cssKeyframeStatements = []; + + this.cssRegex = new RegExp('([\\s\\S]*?){([\\s\\S]*?)}', 'gi'); + this.cssMediaQueryRegex = '((@media [\\s\\S]*?){([\\s\\S]*?}\\s*?)})'; + this.cssKeyframeRegex = '((@.*?keyframes [\\s\\S]*?){([\\s\\S]*?}\\s*?)})'; + this.combinedCSSRegex = '((\\s*?@media[\\s\\S]*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})'; //to match css & media queries together + this.cssCommentsRegex = '(\\/\\*[\\s\\S]*?\\*\\/)'; + this.cssImportStatementRegex = new RegExp('@import .*?;', 'gi'); +}; + +/* + Strip outs css comments and returns cleaned css string + + @param css, the original css string to be stipped out of comments + + @return cleanedCSS contains no css comments + */ +fi.prototype.stripComments = function (cssString) { + var regex = new RegExp(this.cssCommentsRegex, 'gi'); + + return cssString.replace(regex, ''); +}; + +/* + Parses given css string, and returns css object + keys as selectors and values are css rules + eliminates all css comments before parsing + + @param source css string to be parsed + + @return object css + */ +fi.prototype.parseCSS = function (source) { + + if (source === undefined) { + return []; + } + + var css = []; + //strip out comments + //source = this.stripComments(source); + + //get import statements + + while (true) { + var imports = this.cssImportStatementRegex.exec(source); + if (imports !== null) { + this.cssImportStatements.push(imports[0]); + css.push({ + selector: '@imports', + type: 'imports', + styles: imports[0] + }); + } else { + break; + } + } + source = source.replace(this.cssImportStatementRegex, ''); + //get keyframe statements + var keyframesRegex = new RegExp(this.cssKeyframeRegex, 'gi'); + var arr; + while (true) { + arr = keyframesRegex.exec(source); + if (arr === null) { + break; + } + css.push({ + selector: '@keyframes', + type: 'keyframes', + styles: arr[0] + }); + } + source = source.replace(keyframesRegex, ''); + + //unified regex + var unified = new RegExp(this.combinedCSSRegex, 'gi'); + + while (true) { + arr = unified.exec(source); + if (arr === null) { + break; + } + var selector = ''; + if (arr[2] === undefined) { + selector = arr[5].split('\r\n').join('\n').trim(); + } else { + selector = arr[2].split('\r\n').join('\n').trim(); + } + + /* + fetch comments and associate it with current selector + */ + var commentsRegex = new RegExp(this.cssCommentsRegex, 'gi'); + var comments = commentsRegex.exec(selector); + if (comments !== null) { + selector = selector.replace(commentsRegex, '').trim(); + } + + //determine the type + if (selector.indexOf('@media') !== -1) { + //we have a media query + var cssObject = { + selector: selector, + type: 'media', + subStyles: this.parseCSS(arr[3] + '\n}') //recursively parse media query inner css + }; + if (comments !== null) { + cssObject.comments = comments[0]; + } + css.push(cssObject); + } else { + //we have standart css + var rules = this.parseRules(arr[6]); + var style = { + selector: selector, + rules: rules + }; + if (selector === '@font-face') { + style.type = 'font-face'; + } + if (comments !== null) { + style.comments = comments[0]; + } + css.push(style); + } + } + + return css; +}; + +/* + parses given string containing css directives + and returns an array of objects containing ruleName:ruleValue pairs + + @param rules, css directive string example + \n\ncolor:white;\n font-size:18px;\n + */ +fi.prototype.parseRules = function (rules) { + //convert all windows style line endings to unix style line endings + rules = rules.split('\r\n').join('\n'); + var ret = []; + + // Split all rules but keep semicolon for base64 url data + rules = rules.split(/;(?![^\(]*\))/); + + //proccess rules line by line + for (var i = 0; i < rules.length; i++) { + var line = rules[i]; + + //determine if line is a valid css directive, ie color:white; + line = line.trim(); + if (line.indexOf(':') !== -1) { + //line contains : + line = line.split(':'); + var cssDirective = line[0].trim(); + var cssValue = line.slice(1).join(':').trim(); + + //more checks + if (cssDirective.length < 1 || cssValue.length < 1) { + continue; //there is no css directive or value that is of length 1 or 0 + // PLAIN WRONG WHAT ABOUT margin:0; ? + } + + //push rule + ret.push({ + directive: cssDirective, + value: cssValue + }); + } else { + //if there is no ':', but what if it was mis splitted value which starts with base64 + if (line.trim().substr(0, 7) == 'base64,') { //hack :) + ret[ret.length - 1].value += line.trim(); + } else { + //add rule, even if it is defective + if (line.length > 0) { + ret.push({ + directive: '', + value: line, + defective: true + }); + } + } + } + } + + return ret; //we are done! +}; +/* + just returns the rule having given directive + if not found returns false; + */ +fi.prototype.findCorrespondingRule = function (rules, directive, value) { + if (value === undefined) { + value = false; + } + var ret = false; + for (var i = 0; i < rules.length; i++) { + if (rules[i].directive == directive) { + ret = rules[i]; + if (value === rules[i].value) { + break; + } + } + } + return ret; +}; + +/* + Finds styles that have given selector, compress them, + and returns them + */ +fi.prototype.findBySelector = function (cssObjectArray, selector, contains) { + if (contains === undefined) { + contains = false; + } + + var found = []; + for (var i = 0; i < cssObjectArray.length; i++) { + if (contains === false) { + if (cssObjectArray[i].selector === selector) { + found.push(cssObjectArray[i]); + } + } else { + if (cssObjectArray[i].selector.indexOf(selector) !== -1) { + found.push(cssObjectArray[i]); + } + } + + } + if (found.length < 2) { + return found; + } else { + var base = found[0]; + for (i = 1; i < found.length; i++) { + this.intelligentCSSPush([base], found[i]); + } + return [base]; //we are done!! all properties merged into base! + } +}; + +/* + deletes cssObjects having given selector, and returns new array + */ +fi.prototype.deleteBySelector = function (cssObjectArray, selector) { + var ret = []; + for (var i = 0; i < cssObjectArray.length; i++) { + if (cssObjectArray[i].selector !== selector) { + ret.push(cssObjectArray[i]); + } + } + return ret; +}; + +/* + Compresses given cssObjectArray and tries to minimize + selector redundence. + */ +fi.prototype.compressCSS = function (cssObjectArray) { + var compressed = []; + var done = {}; + for (var i = 0; i < cssObjectArray.length; i++) { + var obj = cssObjectArray[i]; + if (done[obj.selector] === true) { + continue; + } + + var found = this.findBySelector(cssObjectArray, obj.selector); //found compressed + if (found.length !== 0) { + compressed.push(found[0]); + done[obj.selector] = true; + } + } + return compressed; +}; + +/* + Received 2 css objects with following structure + { + rules : [{directive:"", value:""}, {directive:"", value:""}, ...] + selector : "SOMESELECTOR" + } + + returns the changed(new,removed,updated) values on css1 parameter, on same structure + + if two css objects are the same, then returns false + + if a css directive exists in css1 and css2, and its value is different, it is included in diff + if a css directive exists in css1 and not css2, it is then included in diff + if a css directive exists in css2 but not css1, then it is deleted in css1, it would be included in diff but will be marked as type='DELETED' + + @object css1 css object + @object css2 css object + + @return diff css object contains changed values in css1 in regards to css2 see test input output in /test/data/css.js + */ +fi.prototype.cssDiff = function (css1, css2) { + if (css1.selector !== css2.selector) { + return false; + } + + //if one of them is media query return false, because diff function can not operate on media queries + if ((css1.type === 'media' || css2.type === 'media')) { + return false; + } + + var diff = { + selector: css1.selector, + rules: [] + }; + var rule1, rule2; + for (var i = 0; i < css1.rules.length; i++) { + rule1 = css1.rules[i]; + //find rule2 which has the same directive as rule1 + rule2 = this.findCorrespondingRule(css2.rules, rule1.directive, rule1.value); + if (rule2 === false) { + //rule1 is a new rule in css1 + diff.rules.push(rule1); + } else { + //rule2 was found only push if its value is different too + if (rule1.value !== rule2.value) { + diff.rules.push(rule1); + } + } + } + + //now for rules exists in css2 but not in css1, which means deleted rules + for (var ii = 0; ii < css2.rules.length; ii++) { + rule2 = css2.rules[ii]; + //find rule2 which has the same directive as rule1 + rule1 = this.findCorrespondingRule(css1.rules, rule2.directive); + if (rule1 === false) { + //rule1 is a new rule + rule2.type = 'DELETED'; //mark it as a deleted rule, so that other merge operations could be true + diff.rules.push(rule2); + } + } + + + if (diff.rules.length === 0) { + return false; + } + return diff; +}; + +/* + Merges 2 different css objects together + using intelligentCSSPush, + + @param cssObjectArray, target css object array + @param newArray, source array that will be pushed into cssObjectArray parameter + @param reverse, [optional], if given true, first parameter will be traversed on reversed order + effectively giving priority to the styles in newArray + */ +fi.prototype.intelligentMerge = function (cssObjectArray, newArray, reverse) { + if (reverse === undefined) { + reverse = false; + } + + + for (var i = 0; i < newArray.length; i++) { + this.intelligentCSSPush(cssObjectArray, newArray[i], reverse); + } + for (i = 0; i < cssObjectArray.length; i++) { + var cobj = cssObjectArray[i]; + if (cobj.type === 'media' || (cobj.type === 'keyframes')) { + continue; + } + cobj.rules = this.compactRules(cobj.rules); + } +}; + +/* + inserts new css objects into a bigger css object + with same selectors groupped together + + @param cssObjectArray, array of bigger css object to be pushed into + @param minimalObject, single css object + @param reverse [optional] default is false, if given, cssObjectArray will be reversly traversed + resulting more priority in minimalObject's styles + */ +fi.prototype.intelligentCSSPush = function (cssObjectArray, minimalObject, reverse) { + var pushSelector = minimalObject.selector; + //find correct selector if not found just push minimalObject into cssObject + var cssObject = false; + + if (reverse === undefined) { + reverse = false; + } + + if (reverse === false) { + for (var i = 0; i < cssObjectArray.length; i++) { + if (cssObjectArray[i].selector === minimalObject.selector) { + cssObject = cssObjectArray[i]; + break; + } + } + } else { + for (var j = cssObjectArray.length - 1; j > -1; j--) { + if (cssObjectArray[j].selector === minimalObject.selector) { + cssObject = cssObjectArray[j]; + break; + } + } + } + + if (cssObject === false) { + cssObjectArray.push(minimalObject); //just push, because cssSelector is new + } else { + if (minimalObject.type !== 'media') { + for (var ii = 0; ii < minimalObject.rules.length; ii++) { + var rule = minimalObject.rules[ii]; + //find rule inside cssObject + var oldRule = this.findCorrespondingRule(cssObject.rules, rule.directive); + if (oldRule === false) { + cssObject.rules.push(rule); + } else if (rule.type == 'DELETED') { + oldRule.type = 'DELETED'; + } else { + //rule found just update value + + oldRule.value = rule.value; + } + } + } else { + cssObject.subStyles = minimalObject.subStyles; //TODO, make this intelligent too + } + + } +}; + +/* + filter outs rule objects whose type param equal to DELETED + + @param rules, array of rules + + @returns rules array, compacted by deleting all unneccessary rules + */ +fi.prototype.compactRules = function (rules) { + var newRules = []; + for (var i = 0; i < rules.length; i++) { + if (rules[i].type !== 'DELETED') { + newRules.push(rules[i]); + } + } + return newRules; +}; +/* + computes string for ace editor using this.css or given cssBase optional parameter + + @param [optional] cssBase, if given computes cssString from cssObject array + */ +fi.prototype.getCSSForEditor = function (cssBase, depth) { + if (depth === undefined) { + depth = 0; + } + var ret = ''; + if (cssBase === undefined) { + cssBase = this.css; + } + //append imports + for (var i = 0; i < cssBase.length; i++) { + if (cssBase[i].type == 'imports') { + ret += cssBase[i].styles + '\n\n'; + } + } + for (i = 0; i < cssBase.length; i++) { + var tmp = cssBase[i]; + if (tmp.selector === undefined) { //temporarily omit media queries + continue; + } + var comments = ""; + if (tmp.comments !== undefined) { + comments = tmp.comments + '\n'; + } + + if (tmp.type == 'media') { //also put media queries to output + ret += comments + tmp.selector + '{\n'; + ret += this.getCSSForEditor(tmp.subStyles, depth + 1); + ret += '}\n\n'; + } else if (tmp.type !== 'keyframes' && tmp.type !== 'imports') { + ret += this.getSpaces(depth) + comments + tmp.selector + ' {\n'; + ret += this.getCSSOfRules(tmp.rules, depth + 1); + ret += this.getSpaces(depth) + '}\n\n'; + } + } + + //append keyFrames + for (i = 0; i < cssBase.length; i++) { + if (cssBase[i].type == 'keyframes') { + ret += cssBase[i].styles + '\n\n'; + } + } + + return ret; +}; + +fi.prototype.getImports = function (cssObjectArray) { + var imps = []; + for (var i = 0; i < cssObjectArray.length; i++) { + if (cssObjectArray[i].type == 'imports') { + imps.push(cssObjectArray[i].styles); + } + } + return imps; +}; +/* + given rules array, returns visually formatted css string + to be used inside editor + */ +fi.prototype.getCSSOfRules = function (rules, depth) { + var ret = ''; + for (var i = 0; i < rules.length; i++) { + if (rules[i] === undefined) { + continue; + } + if (rules[i].defective === undefined) { + ret += this.getSpaces(depth) + rules[i].directive + ' : ' + rules[i].value + ';\n'; + } else { + ret += this.getSpaces(depth) + rules[i].value + ';\n'; + } + + } + return ret || '\n'; +}; + +/* + A very simple helper function returns number of spaces appended in a single string, + the number depends input parameter, namely input*2 + */ +fi.prototype.getSpaces = function (num) { + var ret = ''; + for (var i = 0; i < num * 4; i++) { + ret += ' '; + } + return ret; +}; + +/* + Given css string or objectArray, parses it and then for every selector, + prepends this.cssPreviewNamespace to prevent css collision issues + + @returns css string in which this.cssPreviewNamespace prepended + */ +fi.prototype.applyNamespacing = function (css, forcedNamespace) { + var cssObjectArray = css; + var namespaceClass = '.' + this.cssPreviewNamespace; + if (forcedNamespace !== undefined) { + namespaceClass = forcedNamespace; + } + + if (typeof css === 'string') { + cssObjectArray = this.parseCSS(css); + } + + for (var i = 0; i < cssObjectArray.length; i++) { + var obj = cssObjectArray[i]; + + //bypass namespacing for @font-face @keyframes @import + if (obj.selector.indexOf('@font-face') > -1 || obj.selector.indexOf('keyframes') > -1 || obj.selector.indexOf('@import') > -1 || obj.selector.indexOf('.form-all') > -1 || obj.selector.indexOf('#stage') > -1) { + continue; + } + + if (obj.type !== 'media') { + var selector = obj.selector.split(','); + var newSelector = []; + for (var j = 0; j < selector.length; j++) { + if (selector[j].indexOf('.supernova') === -1) { //do not apply namespacing to selectors including supernova + newSelector.push(namespaceClass + ' ' + selector[j]); + } else { + newSelector.push(selector[j]); + } + } + obj.selector = newSelector.join(','); + } else { + obj.subStyles = this.applyNamespacing(obj.subStyles, forcedNamespace); //handle media queries as well + } + } + + return cssObjectArray; +}; + +/* + given css string or object array, clears possible namespacing from + all of the selectors inside the css + */ +fi.prototype.clearNamespacing = function (css, returnObj) { + if (returnObj === undefined) { + returnObj = false; + } + var cssObjectArray = css; + var namespaceClass = '.' + this.cssPreviewNamespace; + if (typeof css === 'string') { + cssObjectArray = this.parseCSS(css); + } + + for (var i = 0; i < cssObjectArray.length; i++) { + var obj = cssObjectArray[i]; + + if (obj.type !== 'media') { + var selector = obj.selector.split(','); + var newSelector = []; + for (var j = 0; j < selector.length; j++) { + newSelector.push(selector[j].split(namespaceClass + ' ').join('')); + } + obj.selector = newSelector.join(','); + } else { + obj.subStyles = this.clearNamespacing(obj.subStyles, true); //handle media queries as well + } + } + if (returnObj === false) { + return this.getCSSForEditor(cssObjectArray); + } else { + return cssObjectArray; + } + +}; + +/* + creates a new style tag (also destroys the previous one) + and injects given css string into that css tag + */ +fi.prototype.createStyleElement = function (id, css, format) { + if (format === undefined) { + format = false; + } + + if (this.testMode === false && format !== 'nonamespace') { + //apply namespacing classes + css = this.applyNamespacing(css); + } + + if (typeof css != 'string') { + css = this.getCSSForEditor(css); + } + //apply formatting for css + if (format === true) { + css = this.getCSSForEditor(this.parseCSS(css)); + } + + if (this.testMode !== false) { + return this.testMode('create style #' + id, css); //if test mode, just pass result to callback + } + + var __el = document.getElementById(id); + if (__el) { + __el.parentNode.removeChild(__el); + } + + var head = document.head || document.getElementsByTagName('head')[0], + style = document.createElement('style'); + + style.id = id; + style.type = 'text/css'; + + head.appendChild(style); + + if (style.styleSheet && !style.sheet) { + style.styleSheet.cssText = css; + } else { + style.appendChild(document.createTextNode(css)); + } +}; + +export default fi; + +/* eslint-enable */ diff --git a/ui-ngx/src/app/core/http/widget.service.ts b/ui-ngx/src/app/core/http/widget.service.ts index b6b1ec008c..151a2865eb 100644 --- a/ui-ngx/src/app/core/http/widget.service.ts +++ b/ui-ngx/src/app/core/http/widget.service.ts @@ -16,21 +16,46 @@ import {Injectable} from '@angular/core'; import {defaultHttpOptions} from './http-utils'; -import {Observable} from 'rxjs/index'; +import { Observable, ReplaySubject, Subject, of, forkJoin, throwError } from 'rxjs/index'; import {HttpClient} from '@angular/common/http'; import {PageLink} from '@shared/models/page/page-link'; import {PageData} from '@shared/models/page/page-data'; import {WidgetsBundle} from '@shared/models/widgets-bundle.model'; -import { WidgetType } from '@shared/models/widget.models'; +import { + WidgetControllerDescriptor, + WidgetInfo, + WidgetType, + WidgetTypeInstance, + widgetActionSources, + MissingWidgetType, toWidgetInfo, ErrorWidgetType +} from '@shared/models/widget.models'; +import { UtilsService } from '@core/services/utils.service'; +import { isFunction, isUndefined } from '@core/utils'; +import { TranslateService } from '@ngx-translate/core'; +import { AuthPayload } from '@core/auth/auth.models'; +import cssjs from '@core/css/css'; +import { ResourcesService } from '../services/resources.service'; +import { catchError, map, switchMap } from 'rxjs/operators'; @Injectable({ providedIn: 'root' }) export class WidgetService { + private cssParser = new cssjs(); + + private widgetsInfoInMemoryCache = new Map(); + + private widgetsInfoFetchQueue = new Map>>(); + constructor( - private http: HttpClient - ) { } + private http: HttpClient, + private utils: UtilsService, + private resources: ResourcesService, + private translate: TranslateService + ) { + this.cssParser.testMode = false; + } public getWidgetBundles(pageLink: PageLink, ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable> { @@ -58,4 +83,266 @@ export class WidgetService { defaultHttpOptions(ignoreLoading, ignoreErrors)); } + public getWidgetType(bundleAlias: string, widgetTypeAlias: string, isSystem: boolean, + ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable { + return this.http.get(`/api/widgetType?isSystem=${isSystem}&bundleAlias=${bundleAlias}&alias=${widgetTypeAlias}`, + defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + + public getWidgetInfo(bundleAlias: string, widgetTypeAlias: string, isSystem: boolean): Observable { + const widgetInfoSubject = new ReplaySubject(); + const widgetInfo = this.getWidgetInfoFromCache(bundleAlias, widgetTypeAlias, isSystem); + if (widgetInfo) { + widgetInfoSubject.next(widgetInfo); + widgetInfoSubject.complete(); + } else { + if (this.utils.widgetEditMode) { + // TODO: + } else { + const key = this.createWidgetInfoCacheKey(bundleAlias, widgetTypeAlias, isSystem); + let fetchQueue = this.widgetsInfoFetchQueue.get(key); + if (fetchQueue) { + fetchQueue.push(widgetInfoSubject); + } else { + fetchQueue = new Array>(); + this.widgetsInfoFetchQueue.set(key, fetchQueue); + this.getWidgetType(bundleAlias, widgetTypeAlias, isSystem).subscribe( + (widgetType) => { + this.loadWidget(widgetType, bundleAlias, isSystem, widgetInfoSubject); + }, + () => { + widgetInfoSubject.next(MissingWidgetType); + widgetInfoSubject.complete(); + this.resolveWidgetsInfoFetchQueue(key, MissingWidgetType); + } + ); + } + } + } + return widgetInfoSubject.asObservable(); + } + + private loadWidget(widgetType: WidgetType, bundleAlias: string, isSystem: boolean, widgetInfoSubject: Subject) { + const widgetInfo = toWidgetInfo(widgetType); + const key = this.createWidgetInfoCacheKey(bundleAlias, widgetInfo.alias, isSystem); + this.loadWidgetResources(widgetInfo, bundleAlias, isSystem).subscribe( + () => { + let widgetControllerDescriptor: WidgetControllerDescriptor = null; + try { + widgetControllerDescriptor = this.createWidgetControllerDescriptor(widgetInfo, key); + } catch (e) { + const details = this.utils.parseException(e); + const errorMessage = `Failed to compile widget script. \n Error: ${details.message}`; + this.processWidgetLoadError([errorMessage], key, widgetInfoSubject); + } + if (widgetControllerDescriptor) { + if (widgetControllerDescriptor.settingsSchema) { + widgetInfo.typeSettingsSchema = widgetControllerDescriptor.settingsSchema; + } + if (widgetControllerDescriptor.dataKeySettingsSchema) { + widgetInfo.typeDataKeySettingsSchema = widgetControllerDescriptor.dataKeySettingsSchema; + } + widgetInfo.typeParameters = widgetControllerDescriptor.typeParameters; + widgetInfo.actionSources = widgetControllerDescriptor.actionSources; + widgetInfo.widgetTypeFunction = widgetControllerDescriptor.widgetTypeFunction; + this.putWidgetInfoToCache(widgetInfo, bundleAlias, widgetInfo.alias, isSystem); + if (widgetInfoSubject) { + widgetInfoSubject.next(widgetInfo); + widgetInfoSubject.complete(); + } + this.resolveWidgetsInfoFetchQueue(key, widgetInfo); + } + }, + (errorMessages: string[]) => { + this.processWidgetLoadError(errorMessages, key, widgetInfoSubject); + } + ); + } + + private loadWidgetResources(widgetInfo: WidgetInfo, bundleAlias: string, isSystem: boolean): Observable { + const widgetNamespace = `widget-type-${(isSystem ? 'sys-' : '')}${bundleAlias}-${widgetInfo.alias}`; + this.cssParser.cssPreviewNamespace = widgetNamespace; + this.cssParser.createStyleElement(widgetNamespace, widgetInfo.templateCss); + const resourceTasks: Observable[] = []; + if (widgetInfo.resources.length > 0) { + widgetInfo.resources.forEach((resource) => { + resourceTasks.push( + this.resources.loadResource(resource.url).pipe( + catchError(e => of(`Failed to load widget resource: '${resource.url}'`)) + ) + ); + }); + return forkJoin(resourceTasks).pipe( + switchMap(msgs => { + let errors: string[]; + if (msgs && msgs.length) { + errors = msgs.filter(msg => msg && msg.length > 0); + } + if (errors && errors.length) { + return throwError(errors); + } else { + return of(null); + } + } + )); + } else { + return of(null); + } + } + + private createWidgetControllerDescriptor(widgetInfo: WidgetInfo, name: string): WidgetControllerDescriptor { + let widgetTypeFunctionBody = `return function ${name} (ctx) {\n` + + ' var self = this;\n' + + ' self.ctx = ctx;\n\n'; /*+ + + ' self.onInit = function() {\n\n' + + + ' }\n\n' + + + ' self.onDataUpdated = function() {\n\n' + + + ' }\n\n' + + + ' self.useCustomDatasources = function() {\n\n' + + + ' }\n\n' + + + ' self.typeParameters = function() {\n\n' + + return { + useCustomDatasources: false, + maxDatasources: -1, //unlimited + maxDataKeys: -1, //unlimited + dataKeysOptional: false, + stateData: false + }; + ' }\n\n' + + + ' self.actionSources = function() {\n\n' + + return { + 'headerButton': { + name: 'Header button', + multiple: true + } + }; + }\n\n' + + ' self.onResize = function() {\n\n' + + + ' }\n\n' + + + ' self.onEditModeChanged = function() {\n\n' + + + ' }\n\n' + + + ' self.onMobileModeChanged = function() {\n\n' + + + ' }\n\n' + + + ' self.getSettingsSchema = function() {\n\n' + + + ' }\n\n' + + + ' self.getDataKeySettingsSchema = function() {\n\n' + + + ' }\n\n' + + + ' self.onDestroy = function() {\n\n' + + + ' }\n\n' + + '}';*/ + + widgetTypeFunctionBody += widgetInfo.controllerScript; + widgetTypeFunctionBody += '\n};\n'; + + try { + + const widgetTypeFunction = new Function(widgetTypeFunctionBody); + const widgetType = widgetTypeFunction.apply(this); + const widgetTypeInstance: WidgetTypeInstance = new widgetType(); + const result: WidgetControllerDescriptor = { + widgetTypeFunction: widgetType + }; + if (isFunction(widgetTypeInstance.getSettingsSchema)) { + result.settingsSchema = widgetTypeInstance.getSettingsSchema(); + } + if (isFunction(widgetTypeInstance.getDataKeySettingsSchema)) { + result.dataKeySettingsSchema = widgetTypeInstance.getDataKeySettingsSchema(); + } + if (isFunction(widgetTypeInstance.typeParameters)) { + result.typeParameters = widgetTypeInstance.typeParameters(); + } else { + result.typeParameters = {}; + } + if (isFunction(widgetTypeInstance.useCustomDatasources)) { + result.typeParameters.useCustomDatasources = widgetTypeInstance.useCustomDatasources(); + } else { + result.typeParameters.useCustomDatasources = false; + } + if (isUndefined(result.typeParameters.maxDatasources)) { + result.typeParameters.maxDatasources = -1; + } + if (isUndefined(result.typeParameters.maxDataKeys)) { + result.typeParameters.maxDataKeys = -1; + } + if (isUndefined(result.typeParameters.dataKeysOptional)) { + result.typeParameters.dataKeysOptional = false; + } + if (isUndefined(result.typeParameters.stateData)) { + result.typeParameters.stateData = false; + } + if (isFunction(widgetTypeInstance.actionSources)) { + result.actionSources = widgetTypeInstance.actionSources(); + } else { + result.actionSources = {}; + } + for (const actionSourceId of Object.keys(widgetActionSources)) { + result.actionSources[actionSourceId] = {...widgetActionSources[actionSourceId]}; + result.actionSources[actionSourceId].name = this.translate.instant(result.actionSources[actionSourceId].name); + } + return result; + } catch (e) { + this.utils.processWidgetException(e); + throw e; + } + } + + private processWidgetLoadError(errorMessages: string[], cacheKey: string, widgetInfoSubject: Subject) { + const widgetInfo = {...ErrorWidgetType}; + errorMessages.forEach(error => { + widgetInfo.templateHtml += `
${error}
`; + }); + widgetInfo.templateHtml += ''; + if (widgetInfoSubject) { + widgetInfoSubject.next(widgetInfo); + widgetInfoSubject.complete(); + } + this.resolveWidgetsInfoFetchQueue(cacheKey, widgetInfo); + } + + private resolveWidgetsInfoFetchQueue(key: string, widgetInfo: WidgetInfo) { + const fetchQueue = this.widgetsInfoFetchQueue.get(key); + if (fetchQueue) { + fetchQueue.forEach(subject => { + subject.next(widgetInfo); + subject.complete(); + }); + this.widgetsInfoFetchQueue.delete(key); + } + } + + // Cache functions + + private createWidgetInfoCacheKey(bundleAlias: string, widgetTypeAlias: string, isSystem: boolean): string { + return `${isSystem ? 'sys_' : ''}${bundleAlias}_${widgetTypeAlias}`; + } + + private getWidgetInfoFromCache(bundleAlias: string, widgetTypeAlias: string, isSystem: boolean): WidgetInfo | undefined { + const key = this.createWidgetInfoCacheKey(bundleAlias, widgetTypeAlias, isSystem); + return this.widgetsInfoInMemoryCache.get(key); + } + + private putWidgetInfoToCache(widgetInfo: WidgetInfo, bundleAlias: string, widgetTypeAlias: string, isSystem: boolean) { + const key = this.createWidgetInfoCacheKey(bundleAlias, widgetTypeAlias, isSystem); + this.widgetsInfoInMemoryCache.set(key, widgetInfo); + } + } diff --git a/ui-ngx/src/app/core/services/resources.service.ts b/ui-ngx/src/app/core/services/resources.service.ts new file mode 100644 index 0000000000..8b1b94d227 --- /dev/null +++ b/ui-ngx/src/app/core/services/resources.service.ts @@ -0,0 +1,83 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable, Inject } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { ReplaySubject, Observable, throwError } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class ResourcesService { + + private loadedResources: { [url: string]: ReplaySubject } = {}; + + private anchor = this.document.getElementsByTagName('head')[0] || this.document.getElementsByTagName('body')[0]; + + constructor(@Inject(DOCUMENT) private readonly document: any) {} + + public loadResource(url: string): Observable { + if (this.loadedResources[url]) { + return this.loadedResources[url].asObservable(); + } + + let fileType; + const match = /[.](css|less|html|htm|js)?((\?|#).*)?$/.exec(url); + if (match !== null) { + fileType = match[1]; + } + if (!fileType) { + return throwError(new Error(`Unable to detect file type from url: ${url}`)); + } else if (fileType !== 'css' && fileType !== 'js') { + return throwError(new Error(`Unsupported file type: ${fileType}`)); + } + return this.loadResourceByType(fileType, url); + } + + private loadResourceByType(type: 'css' | 'js', url: string): Observable { + const subject = new ReplaySubject(); + this.loadedResources[url] = subject; + let el; + let loaded = false; + switch (type) { + case 'js': + el = this.document.createElement('script'); + el.type = 'text/javascript'; + el.async = true; + el.src = url; + break; + case 'css': + el = this.document.createElement('link'); + el.type = 'text/css'; + el.rel = 'stylesheet'; + el.href = url; + break; + } + el.onload = el.onreadystatechange = (e) => { + if (el.readyState && !/^c|loade/.test(el.readyState) || loaded) { return; } + el.onload = el.onreadystatechange = null; + loaded = true; + this.loadedResources[url].next(); + this.loadedResources[url].complete(); + }; + el.onerror = () => { + this.loadedResources[url].error(new Error(`Unable to load ${url}`)); + delete this.loadedResources[url]; + }; + this.anchor.appendChild(el); + return subject.asObservable(); + } +} diff --git a/ui-ngx/src/app/core/services/utils.service.ts b/ui-ngx/src/app/core/services/utils.service.ts new file mode 100644 index 0000000000..44f2a25fd0 --- /dev/null +++ b/ui-ngx/src/app/core/services/utils.service.ts @@ -0,0 +1,116 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Inject, Injectable } from '@angular/core'; +import { WINDOW } from '@core/services/window.service'; +import { WidgetInfo } from '@shared/models/widget.models'; +import { ExceptionData } from '@app/shared/models/error.models'; +import { isUndefined } from '@core/utils'; +import { WindowMessage } from '@shared/models/window-message.model'; +import { TranslateService } from '@ngx-translate/core'; +import { customTranslationsPrefix } from '@app/shared/models/constants'; + +@Injectable({ + providedIn: 'root' +}) +export class UtilsService { + + iframeMode = false; + widgetEditMode = false; + editWidgetInfo: WidgetInfo = null; + + constructor(@Inject(WINDOW) private window: Window, + private translate: TranslateService) { + let frame: Element = null; + try { + frame = window.frameElement; + } catch (e) { + // ie11 fix + } + if (frame) { + this.iframeMode = true; + const dataWidgetAttr = frame.getAttribute('data-widget'); + if (dataWidgetAttr && dataWidgetAttr.length) { + this.editWidgetInfo = JSON.parse(dataWidgetAttr); + this.widgetEditMode = true; + } + } + } + + public processWidgetException(exception: any): ExceptionData { + const data = this.parseException(exception, -5); + if (this.widgetEditMode) { + const message: WindowMessage = { + type: 'widgetException', + data + }; + this.window.parent.postMessage(message, '*'); + } + return data; + } + + public parseException(exception: any, lineOffset?: number): ExceptionData { + const data: ExceptionData = {}; + if (exception) { + if (typeof exception === 'string') { + data.message = exception; + } else if (exception instanceof String) { + data.message = exception.toString(); + } else { + if (exception.name) { + data.name = exception.name; + } else { + data.name = 'UnknownError'; + } + if (exception.message) { + data.message = exception.message; + } + if (exception.lineNumber) { + data.lineNumber = exception.lineNumber; + if (exception.columnNumber) { + data.columnNumber = exception.columnNumber; + } + } else if (exception.stack) { + const lineInfoRegexp = /(.*):(\d*)(:)?(\d*)?/g; + const lineInfoGroups = lineInfoRegexp.exec(exception.stack); + if (lineInfoGroups != null && lineInfoGroups.length >= 3) { + if (isUndefined(lineOffset)) { + lineOffset = -2; + } + data.lineNumber = Number(lineInfoGroups[2]) + lineOffset; + if (lineInfoGroups.length >= 5) { + data.columnNumber = Number(lineInfoGroups[4]); + } + } + } + } + } + return data; + } + + public customTranslation(translationValue: string, defaultValue: string): string { + let result = ''; + const translationId = customTranslationsPrefix + translationValue; + const translation = this.translate.instant(translationId); + if (translation !== translationId) { + result = translation + ''; + } else { + result = defaultValue; + } + return result; + } + +} diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index 6df5484c7d..e6b60dde17 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -16,6 +16,7 @@ import { BehaviorSubject, Observable, Subject } from 'rxjs'; import { finalize, share } from 'rxjs/operators'; +import base64js from 'base64-js'; export function onParentScrollOrWindowResize(el: Node): Observable { const scrollSubject = new Subject(); @@ -78,6 +79,38 @@ export function isDefined(value: any): boolean { return typeof value !== 'undefined'; } +export function isFunction(value: any): boolean { + return typeof value === 'function'; +} + +export function objToBase64(obj: any): string { + const json = JSON.stringify(obj); + const encoded = utf8Encode(json); + const b64Encoded: string = base64js.fromByteArray(encoded); + return b64Encoded; +} + +export function base64toObj(b64Encoded: string): any { + const encoded: Uint8Array | number[] = base64js.toByteArray(b64Encoded); + const json = utf8Decode(encoded); + const obj = JSON.parse(json); + return obj; +} + +function utf8Encode(str: string): Uint8Array | number[] { + let result: Uint8Array | number[]; + if (isUndefined(Uint8Array)) { + result = utf8ToBytes(str); + } else { + result = new Uint8Array(utf8ToBytes(str)); + } + return result; +} + +function utf8Decode(bytes: Uint8Array | number[]): string { + return utf8Slice(bytes, 0, bytes.length); +} + const scrollRegex = /(auto|scroll)/; function parentNodes(node: Node, nodes: Node[]): Node[] { @@ -138,3 +171,126 @@ function easeInOut( (-remainingTime / 2) * (currentTime * (currentTime - 2) - 1) + startTime ); } + +function utf8Slice(buf: Uint8Array | number[], start: number, end: number): string { + let res = ''; + let tmp = ''; + end = Math.min(buf.length, end || Infinity); + start = start || 0; + + for (let i = start; i < end; i++) { + if (buf[i] <= 0x7F) { + res += decodeUtf8Char(tmp) + String.fromCharCode(buf[i]); + tmp = ''; + } else { + tmp += '%' + buf[i].toString(16); + } + } + return res + decodeUtf8Char(tmp); +} + +function decodeUtf8Char(str: string): string { + try { + return decodeURIComponent(str); + } catch (err) { + return String.fromCharCode(0xFFFD); // UTF 8 invalid char + } +} + +function utf8ToBytes(input: string, units?: number): number[] { + units = units || Infinity; + let codePoint: number; + const length = input.length; + let leadSurrogate: number = null; + const bytes: number[] = []; + let i = 0; + + for (; i < length; i++) { + codePoint = input.charCodeAt(i); + + // is surrogate component + if (codePoint > 0xD7FF && codePoint < 0xE000) { + // last char was a lead + if (leadSurrogate) { + // 2 leads in a row + if (codePoint < 0xDC00) { + units -= 3; + if (units > -1) { bytes.push(0xEF, 0xBF, 0xBD); } + leadSurrogate = codePoint; + continue; + } else { + // valid surrogate pair + // tslint:disable-next-line:no-bitwise + codePoint = leadSurrogate - 0xD800 << 10 | codePoint - 0xDC00 | 0x10000; + leadSurrogate = null; + } + } else { + // no lead yet + + if (codePoint > 0xDBFF) { + // unexpected trail + units -= 3; + if (units > -1) { bytes.push(0xEF, 0xBF, 0xBD); } + continue; + } else if (i + 1 === length) { + // unpaired lead + units -= 3; + if (units > -1) { bytes.push(0xEF, 0xBF, 0xBD); } + continue; + } else { + // valid lead + leadSurrogate = codePoint; + continue; + } + } + } else if (leadSurrogate) { + // valid bmp char, but last char was a lead + units -= 3; + if (units > -1) { bytes.push(0xEF, 0xBF, 0xBD); } + leadSurrogate = null; + } + + // encode utf8 + if (codePoint < 0x80) { + units -= 1; + if (units < 0) { break; } + bytes.push(codePoint); + } else if (codePoint < 0x800) { + units -= 2; + if (units < 0) { break; } + bytes.push( + // tslint:disable-next-line:no-bitwise + codePoint >> 0x6 | 0xC0, + // tslint:disable-next-line:no-bitwise + codePoint & 0x3F | 0x80 + ); + } else if (codePoint < 0x10000) { + units -= 3; + if (units < 0) { break; } + bytes.push( + // tslint:disable-next-line:no-bitwise + codePoint >> 0xC | 0xE0, + // tslint:disable-next-line:no-bitwise + codePoint >> 0x6 & 0x3F | 0x80, + // tslint:disable-next-line:no-bitwise + codePoint & 0x3F | 0x80 + ); + } else if (codePoint < 0x200000) { + units -= 4; + if (units < 0) { break; } + bytes.push( + // tslint:disable-next-line:no-bitwise + codePoint >> 0x12 | 0xF0, + // tslint:disable-next-line:no-bitwise + codePoint >> 0xC & 0x3F | 0x80, + // tslint:disable-next-line:no-bitwise + codePoint >> 0x6 & 0x3F | 0x80, + // tslint:disable-next-line:no-bitwise + codePoint & 0x3F | 0x80 + ); + } else { + throw new Error('Invalid code point'); + } + } + return bytes; +} diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html index 6017068f6a..c554621a85 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html @@ -16,15 +16,15 @@ -->
+ [ngStyle]="dashboardStyle" + [fxShow]="(((isLoading$ | async) && !this.ignoreLoading) || this.dashboardLoading) && !isEdit">
-
+
- + +
diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts index 9c60de90e4..f81707aa5f 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts @@ -14,40 +14,108 @@ /// limitations under the License. /// -import { Component, OnInit, Input, ViewChild, AfterViewInit, ViewChildren, QueryList, ElementRef } from '@angular/core'; +import { + AfterViewInit, + Component, + Input, + OnChanges, + OnInit, + QueryList, + SimpleChanges, + ViewChild, + ViewChildren +} from '@angular/core'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { PageComponent } from '@shared/components/page.component'; import { AuthUser } from '@shared/models/user.model'; import { getCurrentAuthUser } from '@core/auth/auth.selectors'; -import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { Timewindow } from '@shared/models/time/time.models'; import { TimeService } from '@core/services/time.service'; import { GridsterComponent, GridsterConfig, GridsterItemComponent } from 'angular-gridster2'; -import { GridsterResizable } from 'angular-gridster2/lib/gridsterResizable.service'; -import { IDashboardComponent, DashboardConfig, DashboardWidget } from '../../models/dashboard-component.models'; -import { MatSort } from '@angular/material/sort'; -import { Observable, ReplaySubject, merge } from 'rxjs'; +import { + DashboardCallbacks, + DashboardWidget, + IDashboardComponent, + WidgetsData +} from '../../models/dashboard-component.models'; +import { merge, Observable } from 'rxjs'; import { map, share, tap } from 'rxjs/operators'; import { WidgetLayout } from '@shared/models/dashboard.models'; import { DialogService } from '@core/services/dialog.service'; -import { Widget } from '@app/shared/models/widget.models'; -import { MatTab } from '@angular/material/tabs'; import { animatedScroll, isDefined } from '@app/core/utils'; -import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout'; +import { BreakpointObserver } from '@angular/cdk/layout'; import { MediaBreakpoints } from '@shared/models/constants'; +import { IAliasController, IStateController } from '@app/core/api/widget-api.models'; @Component({ selector: 'tb-dashboard', templateUrl: './dashboard.component.html', styleUrls: ['./dashboard.component.scss'] }) -export class DashboardComponent extends PageComponent implements IDashboardComponent, OnInit, AfterViewInit { +export class DashboardComponent extends PageComponent implements IDashboardComponent, OnInit, AfterViewInit, OnChanges { authUser: AuthUser; @Input() - options: DashboardConfig; + widgetsData: Observable; + + @Input() + callbacks: DashboardCallbacks; + + @Input() + aliasController: IAliasController; + + @Input() + stateController: IStateController; + + @Input() + columns: number; + + @Input() + horizontalMargin: number; + + @Input() + verticalMargin: number; + + @Input() + isEdit: boolean; + + @Input() + autofillHeight: boolean; + + @Input() + mobileAutofillHeight: boolean; + + @Input() + mobileRowHeight: number; + + @Input() + isMobile: boolean; + + @Input() + isMobileDisabled: boolean; + + @Input() + isEditActionEnabled: boolean; + + @Input() + isExportActionEnabled: boolean; + + @Input() + isRemoveActionEnabled: boolean; + + @Input() + dashboardStyle: {[klass: string]: any}; + + @Input() + dashboardClass: string; + + @Input() + ignoreLoading: boolean; + + @Input() + dashboardTimewindow: Timewindow; gridsterOpts: GridsterConfig; @@ -77,8 +145,8 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo } ngOnInit(): void { - if (!this.options.dashboardTimewindow) { - this.options.dashboardTimewindow = this.timeService.defaultTimewindow(); + if (!this.dashboardTimewindow) { + this.dashboardTimewindow = this.timeService.defaultTimewindow(); } this.gridsterOpts = { gridType: 'scrollVertical', @@ -86,35 +154,65 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo pushItems: false, swap: false, maxRows: 100, - minCols: this.options.columns ? this.options.columns : 24, + minCols: this.columns ? this.columns : 24, outerMargin: true, - outerMarginLeft: this.options.margins ? this.options.margins[0] : 10, - outerMarginRight: this.options.margins ? this.options.margins[0] : 10, - outerMarginTop: this.options.margins ? this.options.margins[1] : 10, - outerMarginBottom: this.options.margins ? this.options.margins[1] : 10, + outerMarginLeft: this.horizontalMargin ? this.horizontalMargin : 10, + outerMarginRight: this.horizontalMargin ? this.horizontalMargin : 10, + outerMarginTop: this.verticalMargin ? this.verticalMargin : 10, + outerMarginBottom: this.horizontalMargin ? this.horizontalMargin : 10, minItemCols: 1, minItemRows: 1, defaultItemCols: 8, defaultItemRows: 6, - resizable: {enabled: this.options.isEdit}, - draggable: {enabled: this.options.isEdit} + resizable: {enabled: this.isEdit}, + draggable: {enabled: this.isEdit}, + itemChangeCallback: item => this.sortWidgets(this.widgets) }; - this.updateGridsterOpts(); + this.updateMobileOpts(); this.loadDashboard(); - merge(this.breakpointObserver - .observe(MediaBreakpoints['gt-sm']), this.options.layoutChange$).subscribe( + this.breakpointObserver + .observe(MediaBreakpoints['gt-sm']).subscribe( () => { - this.updateGridsterOpts(); - this.sortWidgets(this.widgets); + this.updateMobileOpts(); } ); } + ngOnChanges(changes: SimpleChanges): void { + let updateMobileOpts = false; + let updateLayoutOpts = false; + let updateEditingOpts = false; + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (['isMobile', 'isMobileDisabled', 'autofillHeight', 'mobileAutofillHeight', 'mobileRowHeight'].includes(propName)) { + updateMobileOpts = true; + } else if (['horizontalMargin', 'verticalMargin'].includes(propName)) { + updateLayoutOpts = true; + } else if (propName === 'isEdit') { + updateEditingOpts = true; + } + } + } + if (updateMobileOpts) { + this.updateMobileOpts(); + } + if (updateLayoutOpts) { + this.updateLayoutOpts(); + } + if (updateEditingOpts) { + this.updateEditingOpts(); + } + if (updateMobileOpts || updateLayoutOpts || updateEditingOpts) { + this.notifyGridsterOptionsChanged(); + } + } + loadDashboard() { - this.widgets$ = this.options.widgetsData.pipe( + this.widgets$ = this.widgetsData.pipe( map(widgetsData => { const dashboardWidgets = new Array(); let maxRows = this.gridsterOpts.maxRows; @@ -164,19 +262,12 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo isAutofillHeight(): boolean { if (this.isMobileSize) { - return isDefined(this.options.mobileAutofillHeight) ? this.options.mobileAutofillHeight : false; + return isDefined(this.mobileAutofillHeight) ? this.mobileAutofillHeight : false; } else { - return isDefined(this.options.autofillHeight) ? this.options.autofillHeight : false; + return isDefined(this.autofillHeight) ? this.autofillHeight : false; } } - loading(): Observable { - return this.isLoading$.pipe( - map(loading => (!this.options.ignoreLoading && loading) || this.dashboardLoading), - share() - ); - } - openDashboardContextMenu($event: Event) { // TODO: // this.dialogService.todo(); @@ -192,14 +283,14 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo } widgetMouseDown($event: Event, widget: DashboardWidget) { - if (this.options.onWidgetMouseDown) { - this.options.onWidgetMouseDown($event, widget.widget); + if (this.callbacks && this.callbacks.onWidgetMouseDown) { + this.callbacks.onWidgetMouseDown($event, widget.widget); } } widgetClicked($event: Event, widget: DashboardWidget) { - if (this.options.onWidgetClicked) { - this.options.onWidgetClicked($event, widget.widget); + if (this.callbacks && this.callbacks.onWidgetClicked) { + this.callbacks.onWidgetClicked($event, widget.widget); } } @@ -207,8 +298,8 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo if ($event) { $event.stopPropagation(); } - if (this.options.isEditActionEnabled && this.options.onEditWidget) { - this.options.onEditWidget($event, widget.widget); + if (this.isEditActionEnabled && this.callbacks && this.callbacks.onEditWidget) { + this.callbacks.onEditWidget($event, widget.widget); } } @@ -216,8 +307,8 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo if ($event) { $event.stopPropagation(); } - if (this.options.isExportActionEnabled && this.options.onExportWidget) { - this.options.onExportWidget($event, widget.widget); + if (this.isExportActionEnabled && this.callbacks && this.callbacks.onExportWidget) { + this.callbacks.onExportWidget($event, widget.widget); } } @@ -225,8 +316,8 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo if ($event) { $event.stopPropagation(); } - if (this.options.isRemoveActionEnabled && this.options.onRemoveWidget) { - this.options.onRemoveWidget($event, widget.widget); + if (this.isRemoveActionEnabled && this.callbacks && this.callbacks.onRemoveWidget) { + this.callbacks.onRemoveWidget($event, widget.widget); } } @@ -272,7 +363,7 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo } } - private updateGridsterOpts() { + private updateMobileOpts() { this.isMobileSize = this.checkIsMobileSize(); const mobileBreakPoint = this.isMobileSize ? 20000 : 0; this.gridsterOpts.mobileBreakpoint = mobileBreakPoint; @@ -285,6 +376,21 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo } else { this.gridsterOpts.gridType = this.isMobileSize ? 'fixed' : 'scrollVertical'; } + } + + private updateLayoutOpts() { + this.gridsterOpts.outerMarginLeft = this.horizontalMargin ? this.horizontalMargin : 10; + this.gridsterOpts.outerMarginRight = this.horizontalMargin ? this.horizontalMargin : 10; + this.gridsterOpts.outerMarginTop = this.verticalMargin ? this.verticalMargin : 10; + this.gridsterOpts.outerMarginBottom = this.horizontalMargin ? this.horizontalMargin : 10; + } + + private updateEditingOpts() { + this.gridsterOpts.resizable.enabled = this.isEdit; + this.gridsterOpts.draggable.enabled = this.isEdit; + } + + private notifyGridsterOptionsChanged() { if (this.gridster && this.gridster.options) { this.gridster.optionsChanged(); } @@ -294,15 +400,15 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo let rowHeight = null; if (!this.isAutofillHeight()) { if (isMobile) { - rowHeight = isDefined(this.options.mobileRowHeight) ? this.options.mobileRowHeight : 70; + rowHeight = isDefined(this.mobileRowHeight) ? this.mobileRowHeight : 70; } } return rowHeight; } private checkIsMobileSize(): boolean { - const isMobileDisabled = this.options.isMobileDisabled === true; - let isMobileSize = this.options.isMobile === true && !isMobileDisabled; + const isMobileDisabled = this.isMobileDisabled === true; + let isMobileSize = this.isMobile === true && !isMobileDisabled; if (!isMobileSize && !isMobileDisabled) { isMobileSize = !this.breakpointObserver.isMatched(MediaBreakpoints['gt-sm']); } diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index 18970ea7ad..9418afc161 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -14,14 +14,14 @@ /// limitations under the License. /// -import {NgModule} from '@angular/core'; -import {CommonModule} from '@angular/common'; -import {SharedModule} from '@app/shared/shared.module'; -import {AddEntityDialogComponent} from './entity/add-entity-dialog.component'; -import {EntitiesTableComponent} from './entity/entities-table.component'; -import {DetailsPanelComponent} from './details-panel.component'; -import {EntityDetailsPanelComponent} from './entity/entity-details-panel.component'; -import {ContactComponent} from './contact.component'; +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@app/shared/shared.module'; +import { AddEntityDialogComponent } from './entity/add-entity-dialog.component'; +import { EntitiesTableComponent } from './entity/entities-table.component'; +import { DetailsPanelComponent } from './details-panel.component'; +import { EntityDetailsPanelComponent } from './entity/entity-details-panel.component'; +import { ContactComponent } from './contact.component'; import { AuditLogDetailsDialogComponent } from './audit-log/audit-log-details-dialog.component'; import { AuditLogTableComponent } from './audit-log/audit-log-table.component'; import { EventTableHeaderComponent } from '@home/components/event/event-table-header.component'; @@ -35,6 +35,8 @@ import { AttributeTableComponent } from '@home/components/attribute/attribute-ta import { AddAttributeDialogComponent } from './attribute/add-attribute-dialog.component'; import { EditAttributeValuePanelComponent } from './attribute/edit-attribute-value-panel.component'; import { DashboardComponent } from '@home/components/dashboard/dashboard.component'; +import { WidgetComponent } from '@home/components/widget/widget.component'; +import { DynamicWidgetComponentFactoryService } from './widget/dynamic-widget-component-factory.service'; @NgModule({ entryComponents: [ @@ -66,7 +68,8 @@ import { DashboardComponent } from '@home/components/dashboard/dashboard.compone AttributeTableComponent, AddAttributeDialogComponent, EditAttributeValuePanelComponent, - DashboardComponent + DashboardComponent, + WidgetComponent ], imports: [ CommonModule, @@ -84,7 +87,11 @@ import { DashboardComponent } from '@home/components/dashboard/dashboard.compone AlarmTableComponent, AlarmDetailsDialogComponent, AttributeTableComponent, - DashboardComponent + DashboardComponent, + WidgetComponent + ], + providers: [ + DynamicWidgetComponentFactoryService ] }) export class HomeComponentsModule { } diff --git a/ui-ngx/src/app/modules/home/components/widget/dynamic-widget-component-factory.service.ts b/ui-ngx/src/app/modules/home/components/widget/dynamic-widget-component-factory.service.ts new file mode 100644 index 0000000000..a6d39a1d45 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/dynamic-widget-component-factory.service.ts @@ -0,0 +1,100 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + Compiler, + Component, + ComponentFactory, + Injectable, + Injector, + NgModule, + NgModuleRef, + Type, + ViewEncapsulation +} from '@angular/core'; +import { + DynamicWidgetComponent, + DynamicWidgetComponentModule +} from '@home/components/widget/dynamic-widget.component'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { Observable, ReplaySubject } from 'rxjs'; +import { HomeComponentsModule } from '../home-components.module'; +import { WidgetComponentsModule } from './widget-components.module'; + +interface DynamicWidgetComponentModuleData { + moduleRef: NgModuleRef; + moduleType: Type; +} + +@Injectable() +export class DynamicWidgetComponentFactoryService { + + private dynamicComponentModulesMap = new Map, DynamicWidgetComponentModuleData>(); + + constructor(private compiler: Compiler, + private injector: Injector) { + } + + public createDynamicWidgetComponentFactory(template: string): Observable> { + const dymamicWidgetComponentFactorySubject = new ReplaySubject>(); + const comp = this.createDynamicWidgetComponent(template); + // noinspection AngularInvalidImportedOrDeclaredSymbol,AngularInvalidEntryComponent + @NgModule({ + declarations: [comp], + entryComponents: [comp], + imports: [CommonModule, SharedModule, WidgetComponentsModule], + }) + class DynamicWidgetComponentInstanceModule extends DynamicWidgetComponentModule {} + this.compiler.compileModuleAsync(DynamicWidgetComponentInstanceModule).then( + (module) => { + const moduleRef = module.create(this.injector); + const factory = moduleRef.componentFactoryResolver.resolveComponentFactory(comp); + this.dynamicComponentModulesMap.set(factory, { + moduleRef, + moduleType: module.moduleType + }); + dymamicWidgetComponentFactorySubject.next(factory); + dymamicWidgetComponentFactorySubject.complete(); + } + ).catch( + (e) => { + dymamicWidgetComponentFactorySubject.error(`Failed to create dynamic widget component factory: ${e}`); + } + ); + return dymamicWidgetComponentFactorySubject.asObservable(); + } + + public destroyDynamicWidgetComponentFactory(factory: ComponentFactory) { + const moduleData = this.dynamicComponentModulesMap.get(factory); + if (moduleData) { + moduleData.moduleRef.destroy(); + this.compiler.clearCacheFor(moduleData.moduleType); + this.dynamicComponentModulesMap.delete(factory); + } + } + + private createDynamicWidgetComponent(template: string): Type { + // noinspection AngularMissingOrInvalidDeclarationInModule + @Component({ + template + }) + class DynamicWidgetInstanceComponent extends DynamicWidgetComponent { } + + return DynamicWidgetInstanceComponent; + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/dynamic-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/dynamic-widget.component.ts new file mode 100644 index 0000000000..baa2972456 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/dynamic-widget.component.ts @@ -0,0 +1,63 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { PageComponent } from '@shared/components/page.component'; +import { Input, OnDestroy, OnInit } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { WidgetContext, IDynamicWidgetComponent } from '@home/models/widget-component.models'; +import { ExceptionData } from '@shared/models/error.models'; + +export abstract class DynamicWidgetComponentModule implements OnDestroy { + + ngOnDestroy(): void { + console.log('Module destroyed!'); + } + +} + +export abstract class DynamicWidgetComponent extends PageComponent implements IDynamicWidgetComponent, OnInit, OnDestroy { + + @Input() + widgetContext: WidgetContext; + + @Input() + widgetErrorData: ExceptionData; + + @Input() + loadingData: boolean; + + [key: string]: any; + + constructor(protected store: Store) { + super(store); + } + + ngOnInit() { + + } + + ngOnDestroy(): void { + console.log('Component destroyed!'); + } + + clearRpcError() { + if (this.widgetContext.defaultSubscription) { + this.widgetContext.defaultSubscription.clearRpcError(); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/legend.component.html b/ui-ngx/src/app/modules/home/components/widget/legend.component.html new file mode 100644 index 0000000000..5d538936ef --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/legend.component.html @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + +
{{ 'legend.min' | translate }}{{ 'legend.max' | translate }}{{ 'legend.avg' | translate }}{{ 'legend.total' | translate }}
+ {{ legendKey.dataKey.label }} + {{ legendData.data[legendKey.dataIndex].min }}{{ legendData.data[legendKey.dataIndex].max }}{{ legendData.data[legendKey.dataIndex].avg }}{{ legendData.data[legendKey.dataIndex].total }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/legend.component.scss b/ui-ngx/src/app/modules/home/components/widget/legend.component.scss new file mode 100644 index 0000000000..8df6fc5077 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/legend.component.scss @@ -0,0 +1,68 @@ +/** + * Copyright © 2016-2019 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +:host { + table.tb-legend { + width: 100%; + font-size: 12px; + + .tb-legend-header, + .tb-legend-value { + text-align: right; + } + + .tb-legend-header { + th { + padding: 0 10px 1px 0; + color: rgb(255, 110, 64); + white-space: nowrap; + } + } + + .tb-legend-keys { + td.tb-legend-label, + td.tb-legend-value { + padding: 2px 10px; + white-space: nowrap; + } + + .tb-legend-line { + display: inline-block; + width: 15px; + height: 3px; + vertical-align: middle; + } + + .tb-legend-label { + text-align: left; + outline: none; + + &.tb-horizontal { + width: 95%; + } + + &.tb-hidden-label { + text-decoration: line-through; + opacity: .6; + } + } + + &.tb-row-direction { + display: inline-block; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/legend.component.ts b/ui-ngx/src/app/modules/home/components/widget/legend.component.ts new file mode 100644 index 0000000000..aab873e419 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/legend.component.ts @@ -0,0 +1,55 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Input, OnInit } from '@angular/core'; +import { LegendConfig, LegendData, LegendDirection, LegendPosition } from '@shared/models/widget.models'; + +@Component({ + selector: 'tb-legend', + templateUrl: './legend.component.html', + styleUrls: ['./legend.component.scss'] +}) +export class LegendComponent implements OnInit { + + @Input() + legendConfig: LegendConfig; + + @Input() + legendData: LegendData; + + displayHeader: boolean; + + isHorizontal: boolean; + + isRowDirection: boolean; + + ngOnInit(): void { + this.displayHeader = this.legendConfig.showMin === true || + this.legendConfig.showMax === true || + this.legendConfig.showAvg === true || + this.legendConfig.showTotal === true; + + this.isHorizontal = this.legendConfig.position === LegendPosition.bottom || + this.legendConfig.position === LegendPosition.top; + + this.isRowDirection = this.legendConfig.direction === LegendDirection.row; + } + + toggleHideData(index: number) { + this.legendData.keys[index].dataKey.hidden = !this.legendData.keys[index].dataKey.hidden; + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts new file mode 100644 index 0000000000..06e30fab0a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -0,0 +1,38 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@app/shared/shared.module'; +import { AlarmDetailsDialogComponent } from '@home/components/alarm/alarm-details-dialog.component'; +import { LegendComponent } from '@home/components/widget/legend.component'; + +@NgModule({ + entryComponents: [ + ], + declarations: + [ + LegendComponent + ], + imports: [ + CommonModule, + SharedModule + ], + exports: [ + LegendComponent + ] +}) +export class WidgetComponentsModule { } diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.html b/ui-ngx/src/app/modules/home/components/widget/widget.component.html new file mode 100644 index 0000000000..2ec95ac4c8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.html @@ -0,0 +1,18 @@ + + diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/widget.component.scss new file mode 100644 index 0000000000..251c070267 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.scss @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2019 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.tb-widget { + .tb-widget-error { + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, .5); + + span { + color: #f00; + } + } + + .tb-widget-loading { + z-index: 3; + background: rgba(255, 255, 255, .15); + } + + .tb-widget-error-container { + position: absolute; + width: 100%; + height: 100%; + background-color: #fff; + } + + .tb-widget-error-msg { + padding: 5px; + font-size: 16px; + color: #f00; + word-wrap: break-word; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts new file mode 100644 index 0000000000..99005a25f4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts @@ -0,0 +1,758 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + AfterViewInit, + Component, + ComponentFactory, + ComponentFactoryResolver, + ComponentRef, + ElementRef, + Injector, + Input, + OnChanges, + OnDestroy, + OnInit, + SimpleChanges, + ViewChild, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { DashboardWidget, IDashboardComponent } from '@home/models/dashboard-component.models'; +import { + LegendConfig, + LegendData, + LegendPosition, + Widget, + WidgetActionDescriptor, + WidgetActionType, + WidgetInfo, WidgetResource, + widgetType, + WidgetTypeInstance, + widgetActionSources +} from '@shared/models/widget.models'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { WidgetService } from '@core/http/widget.service'; +import { UtilsService } from '@core/services/utils.service'; +import { DynamicWidgetComponent } from '@home/components/widget/dynamic-widget.component'; +import { forkJoin, Observable, of, ReplaySubject, throwError } from 'rxjs'; +import { DynamicWidgetComponentFactoryService } from '@home/components/widget/dynamic-widget-component-factory.service'; +import { isDefined, objToBase64 } from '@core/utils'; +import * as $ from 'jquery'; +import { WidgetContext, WidgetHeaderAction } from '@home/models/widget-component.models'; +import { + EntityInfo, + IWidgetSubscription, + SubscriptionInfo, + WidgetSubscriptionOptions, + StateObject, + StateParams, + WidgetSubscriptionContext +} from '@core/api/widget-api.models'; +import { EntityId } from '@shared/models/id/entity-id'; +import { ActivatedRoute, Router, UrlSegment } from '@angular/router'; +import cssjs from '@core/css/css'; +import { ResourcesService } from '@core/services/resources.service'; +import { catchError, switchMap } from 'rxjs/operators'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { TimeService } from '@core/services/time.service'; +import { DeviceService } from '@app/core/http/device.service'; +import { AlarmService } from '@app/core/http/alarm.service'; +import { ExceptionData } from '@shared/models/error.models'; + +@Component({ + selector: 'tb-widget', + templateUrl: './widget.component.html', + styleUrls: ['./widget.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class WidgetComponent extends PageComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy { + + @Input() + isEdit: boolean; + + @Input() + isMobile: boolean; + + @Input() + dashboard: IDashboardComponent; + + @Input() + dashboardWidget: DashboardWidget; + + @ViewChild('widgetContent', {read: ViewContainerRef, static: true}) widgetContentContainer: ViewContainerRef; + + widget: Widget; + widgetInfo: WidgetInfo; + widgetContext: WidgetContext; + widgetType: any; + widgetTypeInstance: WidgetTypeInstance; + widgetErrorData: ExceptionData; + + dynamicWidgetComponentFactory: ComponentFactory; + dynamicWidgetComponentRef: ComponentRef; + dynamicWidgetComponent: DynamicWidgetComponent; + + subscriptionContext: WidgetSubscriptionContext; + + subscriptionInited = false; + widgetSizeDetected = false; + + onResizeListener = this.onResize.bind(this); + + private cssParser = new cssjs(); + + constructor(protected store: Store, + private route: ActivatedRoute, + private router: Router, + private dynamicWidgetComponentFactoryService: DynamicWidgetComponentFactoryService, + private componentFactoryResolver: ComponentFactoryResolver, + private elementRef: ElementRef, + private injector: Injector, + private widgetService: WidgetService, + private resources: ResourcesService, + private timeService: TimeService, + private deviceService: DeviceService, + private alarmService: AlarmService, + private utils: UtilsService) { + super(store); + } + + ngOnInit(): void { + this.widget = this.dashboardWidget.widget; + + const actionDescriptorsBySourceId: {[actionSourceId: string]: Array} = {}; + if (this.widget.config.actions) { + for (const actionSourceId of Object.keys(this.widget.config.actions)) { + const descriptors = this.widget.config.actions[actionSourceId]; + const actionDescriptors: Array = []; + descriptors.forEach((descriptor) => { + const actionDescriptor: WidgetActionDescriptor = {...descriptor}; + actionDescriptor.displayName = this.utils.customTranslation(descriptor.name, descriptor.name); + actionDescriptors.push(actionDescriptor); + }); + actionDescriptorsBySourceId[actionSourceId] = actionDescriptors; + } + } + + this.widgetContext = this.dashboardWidget.widgetContext; + this.widgetContext.inited = false; + this.widgetContext.hideTitlePanel = false; + this.widgetContext.isEdit = this.isEdit; + this.widgetContext.isMobile = this.isMobile; + this.widgetContext.dashboard = this.dashboard; + this.widgetContext.widgetConfig = this.widget.config; + this.widgetContext.settings = this.widget.config.settings; + this.widgetContext.units = this.widget.config.units || ''; + this.widgetContext.decimals = isDefined(this.widget.config.decimals) ? this.widget.config.decimals : 2; + this.widgetContext.subscriptions = {}; + this.widgetContext.defaultSubscription = null; + this.widgetContext.dashboardTimewindow = this.dashboard.dashboardTimewindow; + this.widgetContext.timewindowFunctions = { + onUpdateTimewindow: (startTimeMs, endTimeMs, interval) => { + if (this.widgetContext.defaultSubscription) { + this.widgetContext.defaultSubscription.onUpdateTimewindow(startTimeMs, endTimeMs, interval); + } + }, + onResetTimewindow: () => { + if (this.widgetContext.defaultSubscription) { + this.widgetContext.defaultSubscription.onResetTimewindow(); + } + } + }; + this.widgetContext.subscriptionApi = { + createSubscription: this.createSubscription.bind(this), + createSubscriptionFromInfo: this.createSubscriptionFromInfo.bind(this), + removeSubscription: (id) => { + const subscription = this.widgetContext.subscriptions[id]; + if (subscription) { + subscription.destroy(); + delete this.widgetContext.subscriptions[id]; + } + } + }; + this.widgetContext.controlApi = { + sendOneWayCommand: (method, params, timeout) => { + if (this.widgetContext.defaultSubscription) { + return this.widgetContext.defaultSubscription.sendOneWayCommand(method, params, timeout); + } else { + return of(null); + } + }, + sendTwoWayCommand: (method, params, timeout) => { + if (this.widgetContext.defaultSubscription) { + return this.widgetContext.defaultSubscription.sendTwoWayCommand(method, params, timeout); + } else { + return of(null); + } + } + }; + this.widgetContext.utils = { + formatValue: this.formatValue + }; + this.widgetContext.actionsApi = { + actionDescriptorsBySourceId, + getActionDescriptors: this.getActionDescriptors.bind(this), + handleWidgetAction: this.handleWidgetAction.bind(this), + elementClick: this.elementClick.bind(this) + }; + this.widgetContext.stateController = this.dashboard.stateController; + this.widgetContext.aliasController = this.dashboard.aliasController; + + this.widgetContext.customHeaderActions = []; + const headerActionsDescriptors = this.getActionDescriptors(widgetActionSources.headerButton.value); + headerActionsDescriptors.forEach((descriptor) => { + const headerAction: WidgetHeaderAction = { + name: descriptor.name, + displayName: descriptor.displayName, + icon: descriptor.icon, + descriptor, + onAction: $event => { + const entityInfo = this.getActiveEntityInfo(); + const entityId = entityInfo ? entityInfo.entityId : null; + const entityName = entityInfo ? entityInfo.entityName : null; + this.handleWidgetAction($event, descriptor, entityId, entityName); + } + }; + this.widgetContext.customHeaderActions.push(headerAction); + }); + + + this.subscriptionContext = { + timeService: this.timeService, + deviceService: this.deviceService, + alarmService: this.alarmService, + utils: this.utils, + widgetUtils: this.widgetContext.utils, + dashboardTimewindowApi: null, // TODO: + getServerTimeDiff: null, // TODO: + aliasController: this.dashboard.aliasController + }; + + this.widgetService.getWidgetInfo(this.widget.bundleAlias, this.widget.typeAlias, this.widget.isSystemType).subscribe( + (widgetInfo) => { + this.widgetInfo = widgetInfo; + this.loadFromWidgetInfo(); + } + ); + + } + + ngAfterViewInit(): void { + } + + ngOnDestroy(): void { + + for (const id of Object.keys(this.widgetContext.subscriptions)) { + const subscription = this.widgetContext.subscriptions[id]; + subscription.destroy(); + } + this.subscriptionInited = false; + this.widgetContext.subscriptions = {}; + if (this.widgetContext.inited) { + this.widgetContext.inited = false; + // TODO: + try { + this.widgetTypeInstance.onDestroy(); + } catch (e) { + this.handleWidgetException(e); + } + } + this.destroyDynamicWidgetComponent(); + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (propName === 'isEdit') { + console.log(`isEdit changed: ${this.isEdit}`); + this.onEditModeChanged(); + } else if (propName === 'isMobile') { + console.log(`isMobile changed: ${this.isMobile}`); + this.onMobileModeChanged(); + } + } + } + } + + private onEditModeChanged() { + if (this.widgetContext.isEdit !== this.isEdit) { + this.widgetContext.isEdit = this.isEdit; + if (this.widgetContext.inited) { + // TODO: + } + } + } + + private onMobileModeChanged() { + if (this.widgetContext.isMobile !== this.isMobile) { + this.widgetContext.isMobile = this.isMobile; + if (this.widgetContext.inited) { + // TODO: + } + } + } + + private onResize() { + if (this.checkSize()) { + if (this.widgetContext.inited) { + // TODO: + } + } + } + + private loadFromWidgetInfo() { + const widgetNamespace = `widget-type-${(this.widget.isSystemType ? 'sys-' : '')}${this.widget.bundleAlias}-${this.widget.typeAlias}`; + const elem = this.elementRef.nativeElement; + elem.classList.add('tb-widget'); + elem.classList.add(widgetNamespace); + this.widgetType = this.widgetInfo.widgetTypeFunction; + + if (!this.widgetType) { + this.widgetTypeInstance = {}; + } else { + try { + this.widgetTypeInstance = new this.widgetType(this.widgetContext); + } catch (e) { + this.handleWidgetException(e); + this.widgetTypeInstance = {}; + } + } + if (!this.widgetTypeInstance.onInit) { + this.widgetTypeInstance.onInit = () => {}; + } + if (!this.widgetTypeInstance.onDataUpdated) { + this.widgetTypeInstance.onDataUpdated = () => {}; + } + if (!this.widgetTypeInstance.onResize) { + this.widgetTypeInstance.onResize = () => {}; + } + if (!this.widgetTypeInstance.onEditModeChanged) { + this.widgetTypeInstance.onEditModeChanged = () => {}; + } + if (!this.widgetTypeInstance.onMobileModeChanged) { + this.widgetTypeInstance.onMobileModeChanged = () => {}; + } + if (!this.widgetTypeInstance.onDestroy) { + this.widgetTypeInstance.onDestroy = () => {}; + } + + this.initialize(); + } + + private reInit() { + this.ngOnDestroy(); + this.initialize(); + // TODO: + } + + private initialize() { + this.configureDynamicWidgetComponent().subscribe( + () => { + this.dynamicWidgetComponent.loadingData = false; + }, + (error) => { + // TODO: + } + ); + } + + private destroyDynamicWidgetComponent() { + if (this.widgetContext.$containerParent) { + // @ts-ignore + removeResizeListener(this.widgetContext.$containerParent[0], this.onResizeListener); + } + if (this.dynamicWidgetComponentRef) { + this.dynamicWidgetComponentRef.destroy(); + } + if (this.dynamicWidgetComponentFactory) { + this.dynamicWidgetComponentFactoryService.destroyDynamicWidgetComponentFactory(this.dynamicWidgetComponentFactory); + } + } + + private handleWidgetException(e) { + console.error(e); + this.widgetErrorData = this.utils.processWidgetException(e); + if (this.dynamicWidgetComponent) { + this.dynamicWidgetComponent.widgetErrorData = this.widgetErrorData; + } + } + + private configureDynamicWidgetComponent(): Observable { + + const dynamicWidgetComponentSubject = new ReplaySubject(); + + let html = '
' + + 'Widget Error: {{ widgetErrorData.name + ": " + widgetErrorData.message}}' + + '
' + + '
' + + '' + + '
'; + + let containerHtml = `
${this.widgetInfo.templateHtml}
`; + + const displayLegend = isDefined(this.widget.config.showLegend) ? this.widget.config.showLegend + : this.widget.type === widgetType.timeseries; + + let legendConfig: LegendConfig; + let legendData: LegendData; + if (displayLegend) { + legendConfig = this.widget.config.legendConfig || + { + position: LegendPosition.bottom, + showMin: false, + showMax: false, + showAvg: this.widget.type === widgetType.timeseries, + showTotal: false + }; + legendData = { + keys: [], + data: [] + }; + let layoutType; + if (legendConfig.position === LegendPosition.top || + legendConfig.position === LegendPosition.bottom) { + layoutType = 'column'; + } else { + layoutType = 'row'; + } + let legendStyle; + switch (legendConfig.position) { + case LegendPosition.top: + legendStyle = 'padding-bottom: 8px; max-height: 50%; overflow-y: auto;'; + break; + case LegendPosition.bottom: + legendStyle = 'padding-top: 8px; max-height: 50%; overflow-y: auto;'; + break; + case LegendPosition.left: + legendStyle = 'padding-right: 0px; max-width: 50%; overflow-y: auto;'; + break; + case LegendPosition.right: + legendStyle = 'padding-left: 0px; max-width: 50%; overflow-y: auto;'; + break; + } + + const legendHtml = ``; + containerHtml = `
${containerHtml}
`; + html += `
`; + if (legendConfig.position === LegendPosition.top || + legendConfig.position === LegendPosition.left) { + html += legendHtml; + html += containerHtml; + } else { + html += containerHtml; + html += legendHtml; + } + html += '
'; + } else { + html += containerHtml; + } + + this.dynamicWidgetComponentFactoryService.createDynamicWidgetComponentFactory(html).subscribe( + (componentFactory) => { + this.dynamicWidgetComponentFactory = componentFactory; + this.widgetContentContainer.clear(); + this.dynamicWidgetComponentRef = this.widgetContentContainer.createComponent(this.dynamicWidgetComponentFactory); + this.dynamicWidgetComponent = this.dynamicWidgetComponentRef.instance; + + this.dynamicWidgetComponent.loadingData = true; + this.dynamicWidgetComponent.widgetContext = this.widgetContext; + this.dynamicWidgetComponent.widgetErrorData = this.widgetErrorData; + this.dynamicWidgetComponent.displayLegend = displayLegend; + this.dynamicWidgetComponent.legendConfig = legendConfig; + this.dynamicWidgetComponent.legendData = legendData; + + this.widgetContext.$scope = this.dynamicWidgetComponent; + + const containerElement = displayLegend ? $(this.elementRef.nativeElement.querySelector('#widget-container')) + : $(this.elementRef.nativeElement); + + this.widgetContext.$container = $('#container', containerElement); + this.widgetContext.$containerParent = $(containerElement); + + if (this.widgetSizeDetected) { + this.widgetContext.$container.css('height', this.widgetContext.height + 'px'); + this.widgetContext.$container.css('width', this.widgetContext.width + 'px'); + } + + // @ts-ignore + addResizeListener(this.widgetContext.$containerParent[0], this.onResizeListener); + + dynamicWidgetComponentSubject.next(); + dynamicWidgetComponentSubject.complete(); + }, + (e) => { + dynamicWidgetComponentSubject.error(e); + } + ); + return dynamicWidgetComponentSubject.asObservable(); + } + + private createSubscription(options: WidgetSubscriptionOptions, subscribe: boolean): Observable { + // TODO: + return of(null); + } + + private createSubscriptionFromInfo(type: widgetType, subscriptionsInfo: Array, + options: WidgetSubscriptionOptions, useDefaultComponents: boolean, + subscribe: boolean): Observable { + // TODO: + return of(null); + } + + private isNumeric(value: any): boolean { + return (value - parseFloat( value ) + 1) >= 0; + } + + private formatValue(value: any, dec?: number, units?: string, showZeroDecimals?: boolean): string | undefined { + if (isDefined(value) && + value != null && this.isNumeric(value)) { + let formatted: string | number = Number(value); + if (isDefined(dec)) { + formatted = formatted.toFixed(dec); + } + if (!showZeroDecimals) { + formatted = (Number(formatted) * 1); + } + formatted = formatted.toString(); + if (isDefined(units) && units.length > 0) { + formatted += ' ' + units; + } + return formatted; + } else { + return value; + } + } + + private getActionDescriptors(actionSourceId: string): Array { + let result = this.widgetContext.actionsApi.actionDescriptorsBySourceId[actionSourceId]; + if (!result) { + result = []; + } + return result; + } + + private handleWidgetAction($event: Event, descriptor: WidgetActionDescriptor, + entityId?: EntityId, entityName?: string, additionalParams?: any): void { + const type = descriptor.type; + const targetEntityParamName = descriptor.stateEntityParamName; + let targetEntityId: EntityId; + if (descriptor.setEntityId) { + targetEntityId = entityId; + } + switch (type) { + case WidgetActionType.openDashboardState: + case WidgetActionType.updateDashboardState: + let targetDashboardStateId = descriptor.targetDashboardStateId; + const params = {...this.widgetContext.stateController.getStateParams()}; + this.updateEntityParams(params, targetEntityParamName, targetEntityId, entityName); + if (type === WidgetActionType.openDashboardState) { + this.widgetContext.stateController.openState(targetDashboardStateId, params, descriptor.openRightLayout); + } else { + this.widgetContext.stateController.updateState(targetDashboardStateId, params, descriptor.openRightLayout); + } + break; + case WidgetActionType.openDashboard: + const targetDashboardId = descriptor.targetDashboardId; + targetDashboardStateId = descriptor.targetDashboardStateId; + const stateObject: StateObject = {}; + stateObject.params = {}; + this.updateEntityParams(stateObject.params, targetEntityParamName, targetEntityId, entityName); + if (targetDashboardStateId) { + stateObject.id = targetDashboardStateId; + } + const stateParams = { + dashboardId: targetDashboardId, + state: objToBase64([ stateObject ]) + }; + const state = objToBase64([ stateObject ]); + const currentUrl = this.route.snapshot.url; + let url; + if (currentUrl.length > 1) { + if (currentUrl[currentUrl.length - 2].path === 'dashboard') { + url = `/dashboard/${targetDashboardId}?state=${state}`; + } else { + url = `/dashboards/${targetDashboardId}?state=${state}`; + } + } + if (url) { + const urlTree = this.router.parseUrl(url); + this.router.navigateByUrl(url); + } + break; + case WidgetActionType.custom: + const customFunction = descriptor.customFunction; + if (isDefined(customFunction) && customFunction.length > 0) { + try { + if (!additionalParams) { + additionalParams = {}; + } + const customActionFunction = new Function('$event', 'widgetContext', 'entityId', + 'entityName', 'additionalParams', customFunction); + customActionFunction($event, this.widgetContext, entityId, entityName, additionalParams); + } catch (e) { + // + } + } + break; + case WidgetActionType.customPretty: + const customPrettyFunction = descriptor.customFunction; + const customHtml = descriptor.customHtml; + const customCss = descriptor.customCss; + const customResources = descriptor.customResources; + const actionNamespace = `custom-action-pretty-${descriptor.name.toLowerCase()}`; + let htmlTemplate = ''; + if (isDefined(customHtml) && customHtml.length > 0) { + htmlTemplate = customHtml; + } + this.loadCustomActionResources(actionNamespace, customCss, customResources).subscribe( + () => { + if (isDefined(customPrettyFunction) && customPrettyFunction.length > 0) { + try { + if (!additionalParams) { + additionalParams = {}; + } + const customActionPrettyFunction = new Function('$event', 'widgetContext', 'entityId', + 'entityName', 'htmlTemplate', 'additionalParams', customPrettyFunction); + customActionPrettyFunction($event, this.widgetContext, entityId, entityName, htmlTemplate, additionalParams); + } catch (e) { + // + } + } + }, + (errorMessages: string[]) => { + this.processResourcesLoadErrors(errorMessages); + } + ); + break; + } + } + + private elementClick($event: Event) { + $event.stopPropagation(); + const e = ($event.target || $event.srcElement) as Element; + if (e.id) { + const descriptors = this.getActionDescriptors('elementClick'); + if (descriptors.length) { + descriptors.forEach((descriptor) => { + if (descriptor.name === e.id) { + const entityInfo = this.getActiveEntityInfo(); + const entityId = entityInfo ? entityInfo.entityId : null; + const entityName = entityInfo ? entityInfo.entityName : null; + this.handleWidgetAction(event, descriptor, entityId, entityName); + } + }); + } + } + } + + private updateEntityParams(params: StateParams, targetEntityParamName?: string, targetEntityId?: EntityId, entityName?: string) { + if (targetEntityId) { + let targetEntityParams: StateParams; + if (targetEntityParamName && targetEntityParamName.length) { + targetEntityParams = params[targetEntityParamName]; + if (!targetEntityParams) { + targetEntityParams = {}; + params[targetEntityParamName] = targetEntityParams; + params.targetEntityParamName = targetEntityParamName; + } + } else { + targetEntityParams = params; + } + targetEntityParams.entityId = targetEntityId; + if (entityName) { + targetEntityParams.entityName = entityName; + } + } + } + + private loadCustomActionResources(actionNamespace: string, customCss: string, customResources: Array): Observable { + if (isDefined(customCss) && customCss.length > 0) { + this.cssParser.cssPreviewNamespace = actionNamespace; + this.cssParser.createStyleElement(actionNamespace, customCss, 'nonamespace'); + } + const resourceTasks: Observable[] = []; + if (customResources.length > 0) { + customResources.forEach((resource) => { + resourceTasks.push( + this.resources.loadResource(resource.url).pipe( + catchError(e => of(`Failed to load custom action resource: '${resource.url}'`)) + ) + ); + }); + return forkJoin(resourceTasks).pipe( + switchMap(msgs => { + let errors: string[]; + if (msgs && msgs.length) { + errors = msgs.filter(msg => msg && msg.length > 0); + } + if (errors && errors.length) { + return throwError(errors); + } else { + return of(null); + } + } + )); + } else { + return of(null); + } + } + + private processResourcesLoadErrors(errorMessages: string[]) { + let messageToShow = ''; + errorMessages.forEach(error => { + messageToShow += `
${error}
`; + }); + this.store.dispatch(new ActionNotificationShow({message: messageToShow, type: 'error'})); + } + + private getActiveEntityInfo(): EntityInfo { + let entityInfo = this.widgetContext.activeEntityInfo; + if (!entityInfo) { + for (const id of Object.keys(this.widgetContext.subscriptions)) { + const subscription = this.widgetContext.subscriptions[id]; + entityInfo = subscription.getFirstEntityInfo(); + if (entityInfo) { + break; + } + } + } + return entityInfo; + } + + private checkSize(): boolean { + const width = this.widgetContext.$containerParent.width(); + const height = this.widgetContext.$containerParent.height(); + let sizeChanged = false; + + if (!this.widgetContext.width || this.widgetContext.width !== width || + !this.widgetContext.height || this.widgetContext.height !== height) { + if (width > 0 && height > 0) { + this.widgetContext.$container.css('height', height + 'px'); + this.widgetContext.$container.css('width', width + 'px'); + this.widgetContext.width = width; + this.widgetContext.height = height; + sizeChanged = true; + this.widgetSizeDetected = true; + } + } + return sizeChanged; + } + +} diff --git a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts index d7e7b664bd..4520319c6c 100644 --- a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts @@ -22,92 +22,33 @@ import { Timewindow } from '@shared/models/time/time.models'; import { Observable } from 'rxjs'; import { isDefined, isUndefined } from '@app/core/utils'; import { EventEmitter } from '@angular/core'; - -export interface IAliasController { - [key: string]: any | null; - // TODO: -} +import { EntityId } from '@app/shared/models/id/entity-id'; +import { IAliasController, IStateController } from '@app/core/api/widget-api.models'; export interface WidgetsData { widgets: Array; widgetLayouts?: WidgetLayouts; } -export class DashboardConfig { - widgetsData?: Observable; - isEdit: boolean; - isEditActionEnabled: boolean; - isExportActionEnabled: boolean; - isRemoveActionEnabled: boolean; +export interface DashboardCallbacks { onEditWidget?: ($event: Event, widget: Widget) => void; onExportWidget?: ($event: Event, widget: Widget) => void; onRemoveWidget?: ($event: Event, widget: Widget) => void; onWidgetMouseDown?: ($event: Event, widget: Widget) => void; onWidgetClicked?: ($event: Event, widget: Widget) => void; - aliasController?: IAliasController; - autofillHeight?: boolean; - mobileAutofillHeight?: boolean; - dashboardStyle?: {[klass: string]: any} | null; - columns?: number; - margins?: [number, number]; - dashboardTimewindow?: Timewindow; - ignoreLoading?: boolean; - dashboardClass?: string; - mobileRowHeight?: number; - - private isMobileValue: boolean; - private isMobileDisabledValue: boolean; - - private layoutChange = new EventEmitter(); - layoutChange$ = this.layoutChange.asObservable(); - layoutChangeTimeout = null; - - set isMobile(isMobile: boolean) { - if (this.isMobileValue !== isMobile) { - const changed = isDefined(this.isMobileValue); - this.isMobileValue = isMobile; - if (changed) { - this.notifyLayoutChanged(); - } - } - } - get isMobile(): boolean { - return this.isMobileValue; - } - - set isMobileDisabled(isMobileDisabled: boolean) { - if (this.isMobileDisabledValue !== isMobileDisabled) { - const changed = isDefined(this.isMobileDisabledValue); - this.isMobileDisabledValue = isMobileDisabled; - if (changed) { - this.notifyLayoutChanged(); - } - } - } - get isMobileDisabled(): boolean { - return this.isMobileDisabledValue; - } - - private notifyLayoutChanged() { - if (this.layoutChangeTimeout) { - clearTimeout(this.layoutChangeTimeout); - } - this.layoutChangeTimeout = setTimeout(() => { - this.doNotifyLayoutChanged(); - }, 0); - } - - private doNotifyLayoutChanged() { - this.layoutChange.emit(); - this.layoutChangeTimeout = null; - } + prepareDashboardContextMenu?: ($event: Event) => void; + prepareWidgetContextMenu?: ($event: Event, widget: Widget) => void; } export interface IDashboardComponent { - options: DashboardConfig; gridsterOpts: GridsterConfig; gridster: GridsterComponent; + mobileAutofillHeight: boolean; isMobileSize: boolean; + autofillHeight: boolean; + dashboardTimewindow: Timewindow; + aliasController: IAliasController; + stateController: IStateController; } export class DashboardWidget implements GridsterItem { @@ -262,7 +203,7 @@ export class DashboardWidget implements GridsterItem { } get rows(): number { - if (this.dashboard.isMobileSize && !this.dashboard.options.mobileAutofillHeight) { + if (this.dashboard.isMobileSize && !this.dashboard.mobileAutofillHeight) { let mobileHeight; if (this.widgetLayout) { mobileHeight = this.widgetLayout.mobileHeight; @@ -285,7 +226,7 @@ export class DashboardWidget implements GridsterItem { } set rows(rows: number) { - if (!this.dashboard.isMobileSize && !this.dashboard.options.autofillHeight) { + if (!this.dashboard.isMobileSize && !this.dashboard.autofillHeight) { if (this.widgetLayout) { this.widgetLayout.sizeY = rows; } else { diff --git a/ui-ngx/src/app/modules/home/models/widget-component.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts index 4edc6101c3..63a959c3d0 100644 --- a/ui-ngx/src/app/modules/home/models/widget-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -14,23 +14,75 @@ /// limitations under the License. /// +import { ExceptionData } from '@shared/models/error.models'; +import { IDashboardComponent } from '@home/models/dashboard-component.models'; +import { WidgetActionDescriptor, WidgetConfig, WidgetConfigSettings, widgetType } from '@shared/models/widget.models'; +import { Timewindow } from '@shared/models/time/time.models'; +import { + EntityInfo, + IWidgetSubscription, + SubscriptionInfo, + WidgetSubscriptionOptions, + IStateController, + IAliasController, + TimewindowFunctions, + WidgetSubscriptionApi, + RpcApi, + WidgetActionsApi, + IWidgetUtils +} from '@core/api/widget-api.models'; +import { Observable } from 'rxjs'; +import { EntityId } from '@shared/models/id/entity-id'; + export interface IWidgetAction { + name: string; icon: string; onAction: ($event: Event) => void; } export interface WidgetHeaderAction extends IWidgetAction { displayName: string; + descriptor: WidgetActionDescriptor; } export interface WidgetAction extends IWidgetAction { - name: string; show: boolean; } +export interface IDynamicWidgetComponent { + widgetContext: WidgetContext; + widgetErrorData: ExceptionData; + loadingData: boolean; + [key: string]: any; +} + export interface WidgetContext { - widgetTitleTemplate?: string; + inited?: boolean; + $container?: any; + $containerParent?: any; + width?: number; + height?: number; + $scope?: IDynamicWidgetComponent; hideTitlePanel?: boolean; + isEdit?: boolean; + isMobile?: boolean; + dashboard?: IDashboardComponent; + widgetConfig?: WidgetConfig; + settings?: WidgetConfigSettings; + units?: string; + decimals?: number; + subscriptions?: {[id: string]: IWidgetSubscription}; + defaultSubscription?: IWidgetSubscription; + dashboardTimewindow?: Timewindow; + timewindowFunctions?: TimewindowFunctions; + subscriptionApi?: WidgetSubscriptionApi; + controlApi?: RpcApi; + utils?: IWidgetUtils; + actionsApi?: WidgetActionsApi; + stateController?: IStateController; + aliasController?: IAliasController; + activeEntityInfo?: EntityInfo; + widgetTitleTemplate?: string; widgetTitle?: string; customHeaderActions?: Array; widgetActions?: Array; diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts index 01c9784034..98d8fcb797 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts @@ -22,7 +22,7 @@ import {Authority} from '@shared/models/authority.enum'; import {RuleChainsTableConfigResolver} from '@modules/home/pages/rulechain/rulechains-table-config.resolver'; import {WidgetsBundlesTableConfigResolver} from '@modules/home/pages/widget/widgets-bundles-table-config.resolver'; import { WidgetLibraryComponent } from '@home/pages/widget/widget-library.component'; -import { BreadCrumbConfig } from '@shared/components/breadcrumb'; +import { BreadCrumbConfig, BreadCrumbLabelFunction } from '@shared/components/breadcrumb'; import { User } from '@shared/models/user.model'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -44,7 +44,9 @@ export class WidgetsBundleResolver implements Resolve { } } -const routes: Routes = [ +export const widgetTypesBreadcumbLabelFunction: BreadCrumbLabelFunction = ((route, translate) => route.data.widgetsBundle.title); + +export const routes: Routes = [ { path: 'widgets-bundles', data: { @@ -72,7 +74,7 @@ const routes: Routes = [ auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN], title: 'widget.widget-library', breadcrumb: { - labelFunction: ((route, translate) => route.data.widgetsBundle.title), + labelFunction: widgetTypesBreadcumbLabelFunction, icon: 'now_widgets' } as BreadCrumbConfig }, diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.html b/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.html index c37b47d155..ebb93167ed 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.html @@ -27,6 +27,12 @@ style="text-transform: uppercase; display: flex;" class="mat-headline tb-absolute-fill">widgets-bundle.empty - + diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.ts index 86f15e6b93..3707e0c277 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-library.component.ts @@ -24,14 +24,14 @@ import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; import { ActivatedRoute } from '@angular/router'; import { Authority } from '@shared/models/authority.enum'; import { NULL_UUID } from '@shared/models/id/has-uuid'; -import { Observable, of } from 'rxjs'; +import { Observable } from 'rxjs'; import { toWidgetInfo, Widget, widgetType } from '@app/shared/models/widget.models'; import { WidgetService } from '@core/http/widget.service'; import { map, share } from 'rxjs/operators'; import { DialogService } from '@core/services/dialog.service'; -import { speedDialFabAnimations } from '@shared/animations/speed-dial-fab.animations'; import { FooterFabButtons } from '@app/shared/components/footer-fab-buttons.component'; -import { DashboardConfig } from '@home/models/dashboard-component.models'; +import { DashboardCallbacks, WidgetsData } from '@home/models/dashboard-component.models'; +import { IAliasController } from '@app/core/api/widget-api.models'; @Component({ selector: 'tb-widget-library', @@ -69,19 +69,21 @@ export class WidgetLibraryComponent extends PageComponent implements OnInit { ] }; - dashboardOptions: DashboardConfig = new DashboardConfig(); + dashboardCallbacks: DashboardCallbacks = { + onEditWidget: this.openWidgetType.bind(this), + onExportWidget: this.exportWidgetType.bind(this), + onRemoveWidget: this.removeWidgetType.bind(this) + }; + + widgetsData: Observable; + + aliasController: IAliasController = {}; constructor(protected store: Store, private route: ActivatedRoute, private widgetService: WidgetService, private dialogService: DialogService) { super(store); - this.dashboardOptions.isEdit = false; - this.dashboardOptions.isEditActionEnabled = true; - this.dashboardOptions.isExportActionEnabled = true; - this.dashboardOptions.onEditWidget = ($event, widget) => { this.openWidgetType($event, widget); }; - this.dashboardOptions.onExportWidget = ($event, widget) => { this.exportWidgetType($event, widget); }; - this.dashboardOptions.onRemoveWidget = ($event, widget) => { this.removeWidgetType($event, widget); }; this.authUser = getCurrentAuthUser(store); this.widgetsBundle = this.route.snapshot.data.widgetsBundle; @@ -90,9 +92,8 @@ export class WidgetLibraryComponent extends PageComponent implements OnInit { } else { this.isReadOnly = this.authUser.authority !== Authority.SYS_ADMIN; } - this.dashboardOptions.isRemoveActionEnabled = !this.isReadOnly; this.loadWidgetTypes(); - this.dashboardOptions.widgetsData = this.widgetTypes$.pipe( + this.widgetsData = this.widgetTypes$.pipe( map(widgets => ({ widgets }))); } diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index b3538306d7..121848a2f7 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -113,3 +113,5 @@ export const valueTypesMap = new Map( ] ] ); + +export const customTranslationsPrefix = 'custom.'; diff --git a/ui-ngx/src/app/shared/models/error.models.ts b/ui-ngx/src/app/shared/models/error.models.ts new file mode 100644 index 0000000000..856617616f --- /dev/null +++ b/ui-ngx/src/app/shared/models/error.models.ts @@ -0,0 +1,23 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + + +export interface ExceptionData { + message?: string; + name?: string; + lineNumber?: number; + columnNumber?: number; +} diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index 85516edf00..f40e380364 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -87,19 +87,50 @@ export interface WidgetResource { url: string; } +export interface WidgetActionSource { + name: string; + value: string; + multiple: boolean; +} + +export const widgetActionSources: {[key: string]: WidgetActionSource} = { + headerButton: + { + name: 'widget-action.header-button', + value: 'headerButton', + multiple: true, + } +}; + export interface WidgetTypeDescriptor { type: widgetType; resources: Array; templateHtml: string; templateCss: string; controllerScript: string; - settingsSchema: string; - dataKeySettingsSchema: string; + settingsSchema?: string; + dataKeySettingsSchema?: string; defaultConfig: string; sizeX: number; sizeY: number; } +export interface WidgetTypeParameters { + useCustomDatasources?: boolean; + maxDatasources?: number; + maxDataKeys?: number; + dataKeysOptional?: boolean; + stateData?: boolean; +} + +export interface WidgetControllerDescriptor { + widgetTypeFunction?: any; + settingsSchema?: string; + dataKeySettingsSchema?: string; + typeParameters?: WidgetTypeParameters; + actionSources?: {[key: string]: WidgetActionSource}; +} + export interface WidgetType extends BaseData { tenantId: TenantId; bundleAlias: string; @@ -108,9 +139,67 @@ export interface WidgetType extends BaseData { descriptor: WidgetTypeDescriptor; } -export interface WidgetInfo extends WidgetTypeDescriptor { +export interface WidgetInfo extends WidgetTypeDescriptor, WidgetControllerDescriptor { widgetName: string; alias: string; + typeSettingsSchema?: string; + typeDataKeySettingsSchema?: string; +} + +export const MissingWidgetType: WidgetInfo = { + type: widgetType.latest, + widgetName: 'Widget type not found', + alias: 'undefined', + sizeX: 8, + sizeY: 6, + resources: [], + templateHtml: '
' + + '
widget.widget-type-not-found
' + + '
', + templateCss: '', + controllerScript: 'self.onInit = function() {}', + settingsSchema: '{}\n', + dataKeySettingsSchema: '{}\n', + defaultConfig: '{\n' + + '"title": "Widget type not found",\n' + + '"datasources": [],\n' + + '"settings": {}\n' + + '}\n' +}; + +export const ErrorWidgetType: WidgetInfo = { + type: widgetType.latest, + widgetName: 'Error loading widget', + alias: 'error', + sizeX: 8, + sizeY: 6, + resources: [], + templateHtml: '
' + + '
widget.widget-type-load-error
', + templateCss: '', + controllerScript: 'self.onInit = function() {}', + settingsSchema: '{}\n', + dataKeySettingsSchema: '{}\n', + defaultConfig: '{\n' + + '"title": "Widget failed to load",\n' + + '"datasources": [],\n' + + '"settings": {}\n' + + '}\n' +}; + +export interface WidgetTypeInstance { + getSettingsSchema?: () => string; + getDataKeySettingsSchema?: () => string; + typeParameters?: () => WidgetTypeParameters; + useCustomDatasources?: () => boolean; + actionSources?: () => {[key: string]: WidgetActionSource}; + + onInit?: () => void; + onDataUpdated?: () => void; + onResize?: () => void; + onEditModeChanged?: () => void; + onMobileModeChanged?: () => void; + onDestroy?: () => void; } export function toWidgetInfo(widgetTypeEntity: WidgetType): WidgetInfo { @@ -130,6 +219,107 @@ export function toWidgetInfo(widgetTypeEntity: WidgetType): WidgetInfo { }; } +export enum LegendDirection { + column = 'column', + row = 'row' +} + +export const legendDirectionTranslationMap = new Map( + [ + [ LegendDirection.column, 'direction.column' ], + [ LegendDirection.row, 'direction.row' ] + ] +); + +export enum LegendPosition { + top = 'top', + bottom = 'bottom', + left = 'left', + right = 'right' +} + +export const legendPositionTranslationMap = new Map( + [ + [ LegendPosition.top, 'position.top' ], + [ LegendPosition.bottom, 'position.bottom' ], + [ LegendPosition.left, 'position.left' ], + [ LegendPosition.right, 'position.right' ] + ] +); + +export interface LegendConfig { + position: LegendPosition; + direction?: LegendDirection; + showMin: boolean; + showMax: boolean; + showAvg: boolean; + showTotal: boolean; +} + +export interface DataKey { + label: string; + color: string; + hidden?: boolean; + [key: string]: any; + // TODO: +} + +export interface LegendKey { + dataKey: DataKey; + dataIndex: number; +} + +export interface LegendKeyData { + min: number; + max: number; + avg: number; + total: number; +} + +export interface LegendData { + keys: Array; + data: Array; +} + +export interface WidgetConfigSettings { + [key: string]: any; + // TODO: +} + +export enum WidgetActionType { + openDashboardState = 'openDashboardState', + updateDashboardState = 'updateDashboardState', + openDashboard = 'openDashboard', + custom = 'custom', + customPretty = 'customPretty' +} + +export const widgetActionTypeTranslationMap = new Map( + [ + [ WidgetActionType.openDashboardState, 'widget-action.open-dashboard-state' ], + [ WidgetActionType.updateDashboardState, 'widget-action.update-dashboard-state' ], + [ WidgetActionType.openDashboard, 'widget-action.open-dashboard' ], + [ WidgetActionType.custom, 'widget-action.custom' ], + [ WidgetActionType.customPretty, 'widget-action.custom-pretty' ] + ] +); + +export interface WidgetActionDescriptor { + name: string; + icon: string; + displayName?: string; + type: WidgetActionType; + targetDashboardId?: string; + targetDashboardStateId?: string; + openRightLayout?: boolean; + setEntityId?: boolean; + stateEntityParamName?: string; + customFunction?: string; + customResources?: Array; + customHtml?: string; + customCss?: string; +} + export interface WidgetConfig { title?: string; titleIcon?: string; @@ -141,6 +331,8 @@ export interface WidgetConfig { enableFullscreen?: boolean; useDashboardTimewindow?: boolean; displayTimewindow?: boolean; + showLegend?: boolean; + legendConfig?: LegendConfig; timewindow?: Timewindow; mobileHeight?: number; mobileOrder?: number; @@ -150,6 +342,10 @@ export interface WidgetConfig { margin?: string; widgetStyle?: {[klass: string]: any}; titleStyle?: {[klass: string]: any}; + units?: string; + decimals?: number; + actions?: {[actionSourceId: string]: Array}; + settings?: WidgetConfigSettings; [key: string]: any; // TODO: diff --git a/ui-ngx/src/app/shared/models/window-message.model.ts b/ui-ngx/src/app/shared/models/window-message.model.ts new file mode 100644 index 0000000000..adb53b2c42 --- /dev/null +++ b/ui-ngx/src/app/shared/models/window-message.model.ts @@ -0,0 +1,22 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +export type WindowMessageType = 'widgetException'; + +export interface WindowMessage { + type: WindowMessageType; + data: any; +}