Browse Source

Widget component initial implementation

pull/2147/head
Igor Kulikov 7 years ago
parent
commit
9ec843cbfa
  1. 6
      ui-ngx/angular.json
  2. 13
      ui-ngx/package-lock.json
  3. 3
      ui-ngx/package.json
  4. 123
      ui-ngx/src/app/core/api/widget-api.models.ts
  5. 688
      ui-ngx/src/app/core/css/css.js
  6. 295
      ui-ngx/src/app/core/http/widget.service.ts
  7. 83
      ui-ngx/src/app/core/services/resources.service.ts
  8. 116
      ui-ngx/src/app/core/services/utils.service.ts
  9. 156
      ui-ngx/src/app/core/utils.ts
  10. 25
      ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html
  11. 204
      ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts
  12. 27
      ui-ngx/src/app/modules/home/components/home-components.module.ts
  13. 100
      ui-ngx/src/app/modules/home/components/widget/dynamic-widget-component-factory.service.ts
  14. 63
      ui-ngx/src/app/modules/home/components/widget/dynamic-widget.component.ts
  15. 42
      ui-ngx/src/app/modules/home/components/widget/legend.component.html
  16. 68
      ui-ngx/src/app/modules/home/components/widget/legend.component.scss
  17. 55
      ui-ngx/src/app/modules/home/components/widget/legend.component.ts
  18. 38
      ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
  19. 18
      ui-ngx/src/app/modules/home/components/widget/widget.component.html
  20. 47
      ui-ngx/src/app/modules/home/components/widget/widget.component.scss
  21. 758
      ui-ngx/src/app/modules/home/components/widget/widget.component.ts
  22. 83
      ui-ngx/src/app/modules/home/models/dashboard-component.models.ts
  23. 56
      ui-ngx/src/app/modules/home/models/widget-component.models.ts
  24. 8
      ui-ngx/src/app/modules/home/pages/widget/widget-library-routing.module.ts
  25. 8
      ui-ngx/src/app/modules/home/pages/widget/widget-library.component.html
  26. 25
      ui-ngx/src/app/modules/home/pages/widget/widget-library.component.ts
  27. 2
      ui-ngx/src/app/shared/models/constants.ts
  28. 23
      ui-ngx/src/app/shared/models/error.models.ts
  29. 202
      ui-ngx/src/app/shared/models/widget.models.ts
  30. 22
      ui-ngx/src/app/shared/models/window-message.model.ts

6
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",

13
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",

3
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",

123
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<IWidgetSubscription>;
createSubscriptionFromInfo: (type: widgetType, subscriptionsInfo: Array<SubscriptionInfo>,
options: WidgetSubscriptionOptions, useDefaultComponents: boolean, subscribe: boolean)
=> Observable<IWidgetSubscription>;
removeSubscription: (id: string) => void;
}
export interface RpcApi {
sendOneWayCommand: (method: string, params?: any, timeout?: number) => Observable<any>;
sendTwoWayCommand: (method: string, params?: any, timeout?: number) => Observable<any>;
}
export interface IWidgetUtils {
formatValue: (value: any, dec?: number, units?: string, showZeroDecimals?: boolean) => string | undefined;
}
export interface WidgetActionsApi {
actionDescriptorsBySourceId: {[sourceId: string]: Array<WidgetActionDescriptor>};
getActionDescriptors: (actionSourceId: string) => Array<WidgetActionDescriptor>;
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<number>;
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<any>;
sendTwoWayCommand: (method: string, params?: any, timeout?: number) => Observable<any>;
clearRpcError: () => void;
getFirstEntityInfo: () => EntityInfo;
destroy(): void;
[key: string]: any;
// TODO:
}

688
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 */

295
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<string, WidgetInfo>();
private widgetsInfoFetchQueue = new Map<string, Array<Subject<WidgetInfo>>>();
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<PageData<WidgetsBundle>> {
@ -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<WidgetType> {
return this.http.get<WidgetType>(`/api/widgetType?isSystem=${isSystem}&bundleAlias=${bundleAlias}&alias=${widgetTypeAlias}`,
defaultHttpOptions(ignoreLoading, ignoreErrors));
}
public getWidgetInfo(bundleAlias: string, widgetTypeAlias: string, isSystem: boolean): Observable<WidgetInfo> {
const widgetInfoSubject = new ReplaySubject<WidgetInfo>();
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<Subject<WidgetInfo>>();
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<WidgetInfo>) {
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<any> {
const widgetNamespace = `widget-type-${(isSystem ? 'sys-' : '')}${bundleAlias}-${widgetInfo.alias}`;
this.cssParser.cssPreviewNamespace = widgetNamespace;
this.cssParser.createStyleElement(widgetNamespace, widgetInfo.templateCss);
const resourceTasks: Observable<string>[] = [];
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<WidgetInfo>) {
const widgetInfo = {...ErrorWidgetType};
errorMessages.forEach(error => {
widgetInfo.templateHtml += `<div class="tb-widget-error-msg">${error}</div>`;
});
widgetInfo.templateHtml += '</div>';
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);
}
}

83
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<any> } = {};
private anchor = this.document.getElementsByTagName('head')[0] || this.document.getElementsByTagName('body')[0];
constructor(@Inject(DOCUMENT) private readonly document: any) {}
public loadResource(url: string): Observable<any> {
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<any> {
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();
}
}

116
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 = /(.*<anonymous>):(\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;
}
}

156
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<Event> {
const scrollSubject = new Subject<Event>();
@ -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;
}

25
ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html

@ -16,15 +16,15 @@
-->
<div fxFlex fxLayout="column" class="tb-progress-cover" fxLayoutAlign="center center"
[ngStyle]="options.dashboardStyle"
[fxShow]="(loading() | async) && !options.isEdit">
[ngStyle]="dashboardStyle"
[fxShow]="(((isLoading$ | async) && !this.ignoreLoading) || this.dashboardLoading) && !isEdit">
<mat-spinner color="warn" mode="indeterminate" diameter="100">
</mat-spinner>
</div>
<div id="gridster-parent"
fxFlex class="tb-dashboard-content layout-wrap" [ngStyle]="{overflowY: isAutofillHeight() ? 'hidden' : 'auto'}"
(contextmenu)="openDashboardContextMenu($event)">
<div [ngClass]="options.dashboardClass" id="gridster-background" style="height: auto; min-height: 100%; display: inline;">
<div [ngClass]="dashboardClass" id="gridster-background" style="height: auto; min-height: 100%; display: inline;">
<gridster #gridster id="gridster-child" [options]="gridsterOpts">
<gridster-item [item]="widget" class="tb-noselect" *ngFor="let widget of widgets$ | async">
<div tb-fullscreen [fullscreen]="widget.isFullscreen" (fullscreenChanged)="onWidgetFullscreenChanged($event, widget)"
@ -58,42 +58,42 @@
fxLayoutAlign="start center"
(mousedown)="$event.stopPropagation()">
<button mat-button mat-icon-button *ngFor="let action of widget.customHeaderActions"
[fxShow]="!options.isEdit"
[fxShow]="!isEdit"
(click)="action.onAction($event)"
matTooltip="{{ action.displayName }}"
matTooltipPosition="above">
<mat-icon>{{ action.icon }}</mat-icon>
</button>
<button mat-button mat-icon-button *ngFor="let action of widget.widgetActions"
[fxShow]="!options.isEdit && action.show"
[fxShow]="!isEdit && action.show"
(click)="action.onAction($event)"
matTooltip="{{ action.name | translate }}"
matTooltipPosition="above">
<mat-icon>{{ action.icon }}</mat-icon>
</button>
<button mat-button mat-icon-button
[fxShow]="!options.isEdit && widget.enableFullscreen"
[fxShow]="!isEdit && widget.enableFullscreen"
(click)="widget.isFullscreen = !widget.isFullscreen"
matTooltip="{{(widget.isFullscreen ? 'fullscreen.exit' : 'fullscreen.expand') | translate}}"
matTooltipPosition="above">
<mat-icon>{{ widget.isFullscreen ? 'fullscreen_exit' : 'fullscreen' }}</mat-icon>
</button>
<button mat-button mat-icon-button
[fxShow]="options.isEditActionEnabled && !widget.isFullscreen"
[fxShow]="isEditActionEnabled && !widget.isFullscreen"
(click)="editWidget($event, widget)"
matTooltip="{{ 'widget.edit' | translate }}"
matTooltipPosition="above">
<mat-icon>edit</mat-icon>
</button>
<button mat-button mat-icon-button
[fxShow]="options.isExportActionEnabled && !widget.isFullscreen"
[fxShow]="isExportActionEnabled && !widget.isFullscreen"
(click)="exportWidget($event, widget)"
matTooltip="{{ 'widget.export' | translate }}"
matTooltipPosition="above">
<mat-icon>file_download</mat-icon>
</button>
<button mat-button mat-icon-button
[fxShow]="options.isRemoveActionEnabled && !widget.isFullscreen"
[fxShow]="isRemoveActionEnabled && !widget.isFullscreen"
(click)="removeWidget($event, widget)"
matTooltip="{{ 'widget.remove' | translate }}"
matTooltipPosition="above">
@ -102,7 +102,12 @@
</div>
</div>
<div fxFlex fxLayout="column" class="tb-widget-content">
<tb-widget fxFlex
[dashboardWidget]="widget"
[isEdit]="isEdit"
[isMobile]="isMobileSize"
[dashboard]="this">
</tb-widget>
</div>
</div>
</gridster-item>

204
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<WidgetsData>;
@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<DashboardWidget>();
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<boolean> {
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']);
}

27
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 { }

100
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<DynamicWidgetComponentModule>;
moduleType: Type<DynamicWidgetComponentModule>;
}
@Injectable()
export class DynamicWidgetComponentFactoryService {
private dynamicComponentModulesMap = new Map<ComponentFactory<DynamicWidgetComponent>, DynamicWidgetComponentModuleData>();
constructor(private compiler: Compiler,
private injector: Injector) {
}
public createDynamicWidgetComponentFactory(template: string): Observable<ComponentFactory<DynamicWidgetComponent>> {
const dymamicWidgetComponentFactorySubject = new ReplaySubject<ComponentFactory<DynamicWidgetComponent>>();
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<DynamicWidgetComponent>) {
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<DynamicWidgetComponent> {
// noinspection AngularMissingOrInvalidDeclarationInModule
@Component({
template
})
class DynamicWidgetInstanceComponent extends DynamicWidgetComponent { }
return DynamicWidgetInstanceComponent;
}
}

63
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<AppState>) {
super(store);
}
ngOnInit() {
}
ngOnDestroy(): void {
console.log('Component destroyed!');
}
clearRpcError() {
if (this.widgetContext.defaultSubscription) {
this.widgetContext.defaultSubscription.clearRpcError();
}
}
}

42
ui-ngx/src/app/modules/home/components/widget/legend.component.html

@ -0,0 +1,42 @@
<!--
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.
-->
<table class="tb-legend">
<thead>
<tr class="tb-legend-header" *ngIf="!isRowDirection">
<th colspan="2"></th>
<th *ngIf="legendConfig.showMin === true">{{ 'legend.min' | translate }}</th>
<th *ngIf="legendConfig.showMax === true">{{ 'legend.max' | translate }}</th>
<th *ngIf="legendConfig.showAvg === true">{{ 'legend.avg' | translate }}</th>
<th *ngIf="legendConfig.showTotal === true">{{ 'legend.total' | translate }}</th>
</tr>
</thead>
<tbody>
<tr class="tb-legend-keys" *ngFor="let legendKey of legendData.keys" [ngClass]="{ 'tb-row-direction': isRowDirection }">
<td><span class="tb-legend-line" [ngStyle]="{backgroundColor: legendKey.dataKey.color}"></span></td>
<td class="tb-legend-label"
(click)="toggleHideData(legendKey.dataIndex)"
[ngClass]="{ 'tb-hidden-label': legendData.keys[legendKey.dataIndex].dataKey.hidden, 'tb-horizontal': isHorizontal }">
{{ legendKey.dataKey.label }}
</td>
<td class="tb-legend-value" *ngIf="legendConfig.showMin === true">{{ legendData.data[legendKey.dataIndex].min }}</td>
<td class="tb-legend-value" *ngIf="legendConfig.showMax === true">{{ legendData.data[legendKey.dataIndex].max }}</td>
<td class="tb-legend-value" *ngIf="legendConfig.showAvg === true">{{ legendData.data[legendKey.dataIndex].avg }}</td>
<td class="tb-legend-value" *ngIf="legendConfig.showTotal === true">{{ legendData.data[legendKey.dataIndex].total }}</td>
</tr>
</tbody>
</table>

68
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;
}
}
}
}

55
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;
}
}

38
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 { }

18
ui-ngx/src/app/modules/home/components/widget/widget.component.html

@ -0,0 +1,18 @@
<!--
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.
-->
<ng-container #widgetContent></ng-container>

47
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;
}
}

758
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<DynamicWidgetComponent>;
dynamicWidgetComponentRef: ComponentRef<DynamicWidgetComponent>;
dynamicWidgetComponent: DynamicWidgetComponent;
subscriptionContext: WidgetSubscriptionContext;
subscriptionInited = false;
widgetSizeDetected = false;
onResizeListener = this.onResize.bind(this);
private cssParser = new cssjs();
constructor(protected store: Store<AppState>,
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<WidgetActionDescriptor>} = {};
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<WidgetActionDescriptor> = [];
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<any> {
const dynamicWidgetComponentSubject = new ReplaySubject();
let html = '<div class="tb-absolute-fill tb-widget-error" *ngIf="widgetErrorData">' +
'<span>Widget Error: {{ widgetErrorData.name + ": " + widgetErrorData.message}}</span>' +
'</div>' +
'<div class="tb-absolute-fill tb-widget-loading" [fxShow]="loadingData" fxLayout="column" fxLayoutAlign="center center">' +
'<mat-spinner color="accent" md-mode="indeterminate" diameter="40"></mat-spinner>' +
'</div>';
let containerHtml = `<div id="container">${this.widgetInfo.templateHtml}</div>`;
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 = `<tb-legend style="${legendStyle}" [legendConfig]="legendConfig" [legendData]="legendData"></tb-legend>`;
containerHtml = `<div fxFlex id="widget-container">${containerHtml}</div>`;
html += `<div class="tb-absolute-fill" fxLayout="${layoutType}">`;
if (legendConfig.position === LegendPosition.top ||
legendConfig.position === LegendPosition.left) {
html += legendHtml;
html += containerHtml;
} else {
html += containerHtml;
html += legendHtml;
}
html += '</div>';
} 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<IWidgetSubscription> {
// TODO:
return of(null);
}
private createSubscriptionFromInfo(type: widgetType, subscriptionsInfo: Array<SubscriptionInfo>,
options: WidgetSubscriptionOptions, useDefaultComponents: boolean,
subscribe: boolean): Observable<IWidgetSubscription> {
// 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<WidgetActionDescriptor> {
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<WidgetResource>): Observable<any> {
if (isDefined(customCss) && customCss.length > 0) {
this.cssParser.cssPreviewNamespace = actionNamespace;
this.cssParser.createStyleElement(actionNamespace, customCss, 'nonamespace');
}
const resourceTasks: Observable<string>[] = [];
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 += `<div>${error}</div>`;
});
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;
}
}

83
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<Widget>;
widgetLayouts?: WidgetLayouts;
}
export class DashboardConfig {
widgetsData?: Observable<WidgetsData>;
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 {

56
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<WidgetHeaderAction>;
widgetActions?: Array<WidgetAction>;

8
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<WidgetsBundle> {
}
}
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
},

8
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</span>
</section>
<tb-dashboard [options]="dashboardOptions"></tb-dashboard>
<tb-dashboard [aliasController]="aliasController"
[widgetsData]="widgetsData"
[isEdit]="false"
[isEditActionEnabled]="true"
[isExportActionEnabled]="true"
[isRemoveActionEnabled]="!isReadOnly"
[callbacks]="dashboardCallbacks"></tb-dashboard>
<tb-footer-fab-buttons [fxShow]="!isReadOnly" [footerFabButtons]="footerFabButtons">
</tb-footer-fab-buttons>

25
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<WidgetsData>;
aliasController: IAliasController = {};
constructor(protected store: Store<AppState>,
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 })));
}

2
ui-ngx/src/app/shared/models/constants.ts

@ -113,3 +113,5 @@ export const valueTypesMap = new Map<ValueType, ValueTypeData>(
]
]
);
export const customTranslationsPrefix = 'custom.';

23
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;
}

202
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<WidgetResource>;
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<WidgetTypeId> {
tenantId: TenantId;
bundleAlias: string;
@ -108,9 +139,67 @@ export interface WidgetType extends BaseData<WidgetTypeId> {
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: '<div class="tb-widget-error-container">' +
'<div translate class="tb-widget-error-msg">widget.widget-type-not-found</div>' +
'</div>',
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: '<div class="tb-widget-error-container">' +
'<div translate class="tb-widget-error-msg">widget.widget-type-load-error</div>',
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, string>(
[
[ 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, string>(
[
[ 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<LegendKey>;
data: Array<LegendKeyData>;
}
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, string>(
[
[ 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<WidgetResource>;
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<WidgetActionDescriptor>};
settings?: WidgetConfigSettings;
[key: string]: any;
// TODO:

22
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;
}
Loading…
Cancel
Save