thingsboard/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.ts
2020-12-28 16:06:36 +02:00

733 lines
24 KiB
TypeScript

///
/// Copyright © 2016-2020 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 { Component, ElementRef, Inject, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import { WidgetsBundle } from '@shared/models/widgets-bundle.model';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { WidgetService } from '@core/http/widget.service';
import { toWidgetInfo, WidgetInfo } from '@home/models/widget-component.models';
import { Widget, WidgetConfig, WidgetType, widgetType, widgetTypesData } from '@shared/models/widget.models';
import { ActivatedRoute, Router } from '@angular/router';
import { deepClone } from '@core/utils';
import { HasDirtyFlag } from '@core/guards/confirm-on-exit.guard';
import { AuthUser } from '@shared/models/user.model';
import { getCurrentAuthUser } from '@core/auth/auth.selectors';
import { Authority } from '@shared/models/authority.enum';
import { NULL_UUID } from '@shared/models/id/has-uuid';
import { Hotkey } from 'angular2-hotkeys';
import { TranslateService } from '@ngx-translate/core';
import { getCurrentIsLoading } from '@app/core/interceptors/load.selectors';
import { Ace } from 'ace-builds';
import { getAce, Range } from '@shared/models/ace/ace.models';
import { css_beautify, html_beautify } from 'js-beautify';
import { CancelAnimationFrame, RafService } from '@core/services/raf.service';
import { WINDOW } from '@core/services/window.service';
import { WindowMessage } from '@shared/models/window-message.model';
import { ExceptionData } from '@shared/models/error.models';
import { ActionNotificationHide, ActionNotificationShow } from '@core/notification/notification.actions';
import { MatDialog } from '@angular/material/dialog';
import {
SaveWidgetTypeAsDialogComponent,
SaveWidgetTypeAsDialogResult
} from '@home/pages/widget/save-widget-type-as-dialog.component';
import { forkJoin, from, Subscription } from 'rxjs';
import { ResizeObserver } from '@juggle/resize-observer';
import Timeout = NodeJS.Timeout;
import { widgetEditorCompleter } from '@home/pages/widget/widget-editor.models';
import { Observable } from 'rxjs/internal/Observable';
import { map, tap } from 'rxjs/operators';
// @dynamic
@Component({
selector: 'tb-widget-editor',
templateUrl: './widget-editor.component.html',
styleUrls: ['./widget-editor.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class WidgetEditorComponent extends PageComponent implements OnInit, OnDestroy, HasDirtyFlag {
@ViewChild('topPanel', {static: true})
topPanelElmRef: ElementRef;
@ViewChild('topLeftPanel', {static: true})
topLeftPanelElmRef: ElementRef;
@ViewChild('topRightPanel', {static: true})
topRightPanelElmRef: ElementRef;
@ViewChild('bottomPanel', {static: true})
bottomPanelElmRef: ElementRef;
@ViewChild('javascriptPanel', {static: true})
javascriptPanelElmRef: ElementRef;
@ViewChild('framePanel', {static: true})
framePanelElmRef: ElementRef;
@ViewChild('htmlInput', {static: true})
htmlInputElmRef: ElementRef;
@ViewChild('cssInput', {static: true})
cssInputElmRef: ElementRef;
@ViewChild('settingsJsonInput', {static: true})
settingsJsonInputElmRef: ElementRef;
@ViewChild('dataKeySettingsJsonInput', {static: true})
dataKeySettingsJsonInputElmRef: ElementRef;
@ViewChild('javascriptInput', {static: true})
javascriptInputElmRef: ElementRef;
@ViewChild('widgetIFrame', {static: true})
widgetIFrameElmRef: ElementRef<HTMLIFrameElement>;
iframe: JQuery<HTMLIFrameElement>;
widgetTypes = widgetType;
allWidgetTypes = Object.keys(widgetType);
widgetTypesDataMap = widgetTypesData;
authUser: AuthUser;
isReadOnly: boolean;
widgetsBundle: WidgetsBundle;
widgetType: WidgetType;
widget: WidgetInfo;
origWidget: WidgetInfo;
isDirty = false;
fullscreen = false;
htmlFullscreen = false;
cssFullscreen = false;
jsonSettingsFullscreen = false;
jsonDataKeySettingsFullscreen = false;
javascriptFullscreen = false;
iFrameFullscreen = false;
aceEditors: Ace.Editor[] = [];
editorsResizeCafs: {[editorId: string]: CancelAnimationFrame} = {};
htmlEditor: Ace.Editor;
cssEditor: Ace.Editor;
jsonSettingsEditor: Ace.Editor;
dataKeyJsonSettingsEditor: Ace.Editor;
jsEditor: Ace.Editor;
aceResize$: ResizeObserver;
onWindowMessageListener = this.onWindowMessage.bind(this);
iframeWidgetEditModeInited = false;
saveWidgetPending = false;
saveWidgetAsPending = false;
gotError = false;
errorMarkers: number[] = [];
errorAnnotationId = -1;
saveWidgetTimeout: Timeout;
hotKeys: Hotkey[] = [];
private rxSubscriptions = new Array<Subscription>();
constructor(protected store: Store<AppState>,
@Inject(WINDOW) private window: Window,
private route: ActivatedRoute,
private router: Router,
private widgetService: WidgetService,
private translate: TranslateService,
private raf: RafService,
private dialog: MatDialog) {
super(store);
this.authUser = getCurrentAuthUser(store);
this.rxSubscriptions.push(this.route.data.subscribe(
(data) => {
this.init(data);
}
));
this.initHotKeys();
}
private init(data: any) {
this.widgetsBundle = data.widgetsBundle;
if (this.authUser.authority === Authority.TENANT_ADMIN) {
this.isReadOnly = !this.widgetsBundle || this.widgetsBundle.tenantId.id === NULL_UUID;
} else {
this.isReadOnly = this.authUser.authority !== Authority.SYS_ADMIN;
}
this.widgetType = data.widgetEditorData.widgetType;
this.widget = data.widgetEditorData.widget;
if (this.widgetType) {
const config = JSON.parse(this.widget.defaultConfig);
this.widget.defaultConfig = JSON.stringify(config);
}
this.origWidget = deepClone(this.widget);
if (!this.widgetType) {
this.isDirty = true;
}
}
ngOnInit(): void {
this.initSplitLayout();
this.initAceEditors();
this.iframe = $(this.widgetIFrameElmRef.nativeElement);
this.window.addEventListener('message', this.onWindowMessageListener);
this.iframe.attr('data-widget', JSON.stringify(this.widget));
this.iframe.attr('src', '/widget-editor');
}
ngOnDestroy(): void {
this.window.removeEventListener('message', this.onWindowMessageListener);
this.aceResize$.disconnect();
this.rxSubscriptions.forEach((subscription) => {
subscription.unsubscribe();
});
this.rxSubscriptions.length = 0;
}
private initHotKeys(): void {
this.hotKeys.push(
new Hotkey('ctrl+q', (event: KeyboardEvent) => {
if (!getCurrentIsLoading(this.store) && !this.undoDisabled()) {
event.preventDefault();
this.undoWidget();
}
return false;
}, ['INPUT', 'SELECT', 'TEXTAREA'],
this.translate.instant('widget.undo'))
);
this.hotKeys.push(
new Hotkey('ctrl+s', (event: KeyboardEvent) => {
if (!getCurrentIsLoading(this.store) && !this.saveDisabled()) {
event.preventDefault();
this.saveWidget();
}
return false;
}, ['INPUT', 'SELECT', 'TEXTAREA'],
this.translate.instant('widget.save'))
);
this.hotKeys.push(
new Hotkey('shift+ctrl+s', (event: KeyboardEvent) => {
if (!getCurrentIsLoading(this.store) && !this.saveAsDisabled()) {
event.preventDefault();
this.saveWidgetAs();
}
return false;
}, ['INPUT', 'SELECT', 'TEXTAREA'],
this.translate.instant('widget.saveAs'))
);
this.hotKeys.push(
new Hotkey('shift+ctrl+f', (event: KeyboardEvent) => {
event.preventDefault();
this.fullscreen = !this.fullscreen;
return false;
}, ['INPUT', 'SELECT', 'TEXTAREA'],
this.translate.instant('widget.toggle-fullscreen'))
);
this.hotKeys.push(
new Hotkey('ctrl+enter', (event: KeyboardEvent) => {
event.preventDefault();
this.applyWidgetScript();
return false;
}, ['INPUT', 'SELECT', 'TEXTAREA'],
this.translate.instant('widget.run'))
);
}
private initSplitLayout() {
Split([this.topPanelElmRef.nativeElement, this.bottomPanelElmRef.nativeElement], {
sizes: [35, 65],
gutterSize: 8,
cursor: 'row-resize',
direction: 'vertical'
});
Split([this.topLeftPanelElmRef.nativeElement, this.topRightPanelElmRef.nativeElement], {
sizes: [50, 50],
gutterSize: 8,
cursor: 'col-resize'
});
Split([this.javascriptPanelElmRef.nativeElement, this.framePanelElmRef.nativeElement], {
sizes: [50, 50],
gutterSize: 8,
cursor: 'col-resize'
});
}
private initAceEditors() {
this.aceResize$ = new ResizeObserver((entries) => {
entries.forEach((entry) => {
const editor = this.aceEditors.find(aceEditor => aceEditor.container === entry.target);
this.onAceEditorResize(editor);
});
});
const editorsObservables: Observable<any>[] = [];
editorsObservables.push(this.createAceEditor(this.htmlInputElmRef, 'html').pipe(
tap((editor) => {
this.htmlEditor = editor;
this.htmlEditor.on('input', () => {
const editorValue = this.htmlEditor.getValue();
if (this.widget.templateHtml !== editorValue) {
this.widget.templateHtml = editorValue;
this.isDirty = true;
}
});
})
));
editorsObservables.push(this.createAceEditor(this.cssInputElmRef, 'css').pipe(
tap((editor) => {
this.cssEditor = editor;
this.cssEditor.on('input', () => {
const editorValue = this.cssEditor.getValue();
if (this.widget.templateCss !== editorValue) {
this.widget.templateCss = editorValue;
this.isDirty = true;
}
});
})
));
editorsObservables.push(this.createAceEditor(this.settingsJsonInputElmRef, 'json').pipe(
tap((editor) => {
this.jsonSettingsEditor = editor;
this.jsonSettingsEditor.on('input', () => {
const editorValue = this.jsonSettingsEditor.getValue();
if (this.widget.settingsSchema !== editorValue) {
this.widget.settingsSchema = editorValue;
this.isDirty = true;
}
});
})
));
editorsObservables.push(this.createAceEditor(this.dataKeySettingsJsonInputElmRef, 'json').pipe(
tap((editor) => {
this.dataKeyJsonSettingsEditor = editor;
this.dataKeyJsonSettingsEditor.on('input', () => {
const editorValue = this.dataKeyJsonSettingsEditor.getValue();
if (this.widget.dataKeySettingsSchema !== editorValue) {
this.widget.dataKeySettingsSchema = editorValue;
this.isDirty = true;
}
});
})
));
editorsObservables.push(this.createAceEditor(this.javascriptInputElmRef, 'javascript').pipe(
tap((editor) => {
this.jsEditor = editor;
this.jsEditor.on('input', () => {
const editorValue = this.jsEditor.getValue();
if (this.widget.controllerScript !== editorValue) {
this.widget.controllerScript = editorValue;
this.isDirty = true;
}
});
this.jsEditor.on('change', () => {
this.cleanupJsErrors();
});
this.jsEditor.completers = [widgetEditorCompleter, ...(this.jsEditor.completers || [])];
})
));
forkJoin(editorsObservables).subscribe(
() => {
this.setAceEditorValues();
}
);
}
private setAceEditorValues() {
this.htmlEditor.setValue(this.widget.templateHtml ? this.widget.templateHtml : '', -1);
this.cssEditor.setValue(this.widget.templateCss ? this.widget.templateCss : '', -1);
this.jsonSettingsEditor.setValue(this.widget.settingsSchema ? this.widget.settingsSchema : '', -1);
this.dataKeyJsonSettingsEditor.setValue(this.widget.dataKeySettingsSchema ? this.widget.dataKeySettingsSchema : '', -1);
this.jsEditor.setValue(this.widget.controllerScript ? this.widget.controllerScript : '', -1);
}
private createAceEditor(editorElementRef: ElementRef, mode: string): Observable<Ace.Editor> {
const editorElement = editorElementRef.nativeElement;
let editorOptions: Partial<Ace.EditorOptions> = {
mode: `ace/mode/${mode}`,
showGutter: true,
showPrintMargin: true
};
const advancedOptions = {
enableSnippets: true,
enableBasicAutocompletion: true,
enableLiveAutocompletion: true
};
editorOptions = {...editorOptions, ...advancedOptions};
return getAce().pipe(
map((ace) => {
const aceEditor = ace.edit(editorElement, editorOptions);
aceEditor.session.setUseWrapMode(true);
this.aceEditors.push(aceEditor);
this.aceResize$.observe(editorElement);
return aceEditor;
})
);
}
private onAceEditorResize(aceEditor: Ace.Editor) {
if (this.editorsResizeCafs[aceEditor.id]) {
this.editorsResizeCafs[aceEditor.id]();
delete this.editorsResizeCafs[aceEditor.id];
}
this.editorsResizeCafs[aceEditor.id] = this.raf.raf(() => {
aceEditor.resize();
aceEditor.renderer.updateFull();
});
}
private onWindowMessage(event: MessageEvent) {
let message: WindowMessage;
if (event.data) {
try {
message = JSON.parse(event.data);
} catch (e) {}
}
if (message) {
switch (message.type) {
case 'widgetException':
this.onWidgetException(message.data);
break;
case 'widgetEditModeInited':
this.onWidgetEditModeInited();
break;
case 'widgetEditUpdated':
this.onWidgetEditUpdated(message.data);
break;
}
}
}
private onWidgetEditModeInited() {
this.iframeWidgetEditModeInited = true;
if (this.saveWidgetPending || this.saveWidgetAsPending) {
if (!this.saveWidgetTimeout) {
this.saveWidgetTimeout = setTimeout(() => {
if (!this.gotError) {
if (this.saveWidgetPending) {
this.commitSaveWidget();
} else if (this.saveWidgetAsPending) {
this.commitSaveWidgetAs();
}
} else {
this.store.dispatch(new ActionNotificationShow(
{message: this.translate.instant('widget.unable-to-save-widget-error'), type: 'error'}));
this.saveWidgetPending = false;
this.saveWidgetAsPending = false;
}
this.saveWidgetTimeout = undefined;
}, 1500);
}
}
}
private onWidgetEditUpdated(widget: Widget) {
this.widget.sizeX = widget.sizeX / 2;
this.widget.sizeY = widget.sizeY / 2;
this.widget.defaultConfig = JSON.stringify(widget.config);
this.iframe.attr('data-widget', JSON.stringify(this.widget));
this.isDirty = true;
}
private onWidgetException(details: ExceptionData) {
if (!this.gotError) {
this.gotError = true;
let errorInfo = 'Error:';
if (details.name) {
errorInfo += ' ' + details.name + ':';
}
if (details.message) {
errorInfo += ' ' + details.message;
}
if (details.lineNumber) {
errorInfo += '<br>Line ' + details.lineNumber;
if (details.columnNumber) {
errorInfo += ' column ' + details.columnNumber;
}
errorInfo += ' of script.';
}
if (!this.saveWidgetPending && !this.saveWidgetAsPending) {
this.store.dispatch(new ActionNotificationShow(
{message: errorInfo, type: 'error', target: 'javascriptPanel'}));
}
if (details.lineNumber) {
const line = details.lineNumber - 1;
let column = 0;
if (details.columnNumber) {
column = details.columnNumber;
}
const errorMarkerId = this.jsEditor.session.addMarker(new Range(line, 0, line, Infinity),
'ace_active-line', 'screenLine');
this.errorMarkers.push(errorMarkerId);
const annotations = this.jsEditor.session.getAnnotations();
const errorAnnotation: Ace.Annotation = {
row: line,
column,
text: details.message,
type: 'error'
};
this.errorAnnotationId = annotations.push(errorAnnotation) - 1;
this.jsEditor.session.setAnnotations(annotations);
}
}
}
private cleanupJsErrors() {
this.store.dispatch(new ActionNotificationHide({}));
this.errorMarkers.forEach((errorMarker) => {
this.jsEditor.session.removeMarker(errorMarker);
});
this.errorMarkers.length = 0;
if (this.errorAnnotationId > -1) {
const annotations = this.jsEditor.session.getAnnotations();
annotations.splice(this.errorAnnotationId, 1);
this.jsEditor.session.setAnnotations(annotations);
this.errorAnnotationId = -1;
}
}
private commitSaveWidget() {
const id = (this.widgetType && this.widgetType.id) ? this.widgetType.id : undefined;
this.widgetService.saveWidgetType(this.widget, id, this.widgetsBundle.alias).subscribe(
(widgetTypeInstance) => {
this.setWidgetType(widgetTypeInstance);
this.saveWidgetPending = false;
this.store.dispatch(new ActionNotificationShow(
{message: this.translate.instant('widget.widget-saved'), type: 'success', duration: 500}));
},
() => {
this.saveWidgetPending = false;
}
);
}
private commitSaveWidgetAs() {
this.dialog.open<SaveWidgetTypeAsDialogComponent, any,
SaveWidgetTypeAsDialogResult>(SaveWidgetTypeAsDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog']
}).afterClosed().subscribe(
(saveWidgetAsData) => {
if (saveWidgetAsData) {
this.widget.widgetName = saveWidgetAsData.widgetName;
this.widget.alias = undefined;
const config = JSON.parse(this.widget.defaultConfig);
config.title = this.widget.widgetName;
this.widget.defaultConfig = JSON.stringify(config);
this.isDirty = false;
this.widgetService.saveWidgetType(this.widget, undefined, saveWidgetAsData.bundleAlias).subscribe(
(widgetTypeInstance) => {
this.router.navigateByUrl(`/widgets-bundles/${saveWidgetAsData.bundleId}/widgetTypes/${widgetTypeInstance.id.id}`);
}
);
}
this.saveWidgetAsPending = false;
}
);
}
private setWidgetType(widgetTypeInstance: WidgetType) {
this.widgetType = widgetTypeInstance;
this.widget = toWidgetInfo(this.widgetType);
const config = JSON.parse(this.widget.defaultConfig);
this.widget.defaultConfig = JSON.stringify(config);
this.origWidget = deepClone(this.widget);
this.isDirty = false;
}
applyWidgetScript(): void {
this.cleanupJsErrors();
this.gotError = false;
this.iframeWidgetEditModeInited = false;
const config: WidgetConfig = JSON.parse(this.widget.defaultConfig);
config.title = this.widget.widgetName;
this.widget.defaultConfig = JSON.stringify(config);
this.iframe.attr('data-widget', JSON.stringify(this.widget));
this.iframe[0].contentWindow.location.reload(true);
}
undoWidget(): void {
this.widget = deepClone(this.origWidget);
this.setAceEditorValues();
this.isDirty = false;
this.applyWidgetScript();
}
saveWidget(): void {
if (!this.widget.widgetName) {
this.store.dispatch(new ActionNotificationShow(
{message: this.translate.instant('widget.missing-widget-title-error'), type: 'error'}));
} else {
this.saveWidgetPending = true;
this.applyWidgetScript();
}
}
saveWidgetAs(): void {
this.saveWidgetAsPending = true;
this.applyWidgetScript();
}
undoDisabled(): boolean {
return !this.isDirty
|| !this.iframeWidgetEditModeInited
|| this.saveWidgetPending
|| this.saveWidgetAsPending;
}
saveDisabled(): boolean {
return this.isReadOnly
|| !this.isDirty
|| !this.iframeWidgetEditModeInited
|| this.saveWidgetPending
|| this.saveWidgetAsPending;
}
saveAsDisabled(): boolean {
return !this.iframeWidgetEditModeInited
|| this.saveWidgetPending
|| this.saveWidgetAsPending;
}
beautifyCss(): void {
const res = css_beautify(this.widget.templateCss, {indent_size: 4});
if (this.widget.templateCss !== res) {
this.isDirty = true;
this.widget.templateCss = res;
this.cssEditor.setValue(this.widget.templateCss ? this.widget.templateCss : '', -1);
}
}
beautifyHtml(): void {
const res = html_beautify(this.widget.templateHtml, {indent_size: 4, wrap_line_length: 60});
if (this.widget.templateHtml !== res) {
this.isDirty = true;
this.widget.templateHtml = res;
this.htmlEditor.setValue(this.widget.templateHtml ? this.widget.templateHtml : '', -1);
}
}
beautifyJson(): void {
const res = js_beautify(this.widget.settingsSchema, {indent_size: 4});
if (this.widget.settingsSchema !== res) {
this.isDirty = true;
this.widget.settingsSchema = res;
this.jsonSettingsEditor.setValue(this.widget.settingsSchema ? this.widget.settingsSchema : '', -1);
}
}
beautifyDataKeyJson(): void {
const res = js_beautify(this.widget.dataKeySettingsSchema, {indent_size: 4});
if (this.widget.dataKeySettingsSchema !== res) {
this.isDirty = true;
this.widget.dataKeySettingsSchema = res;
this.dataKeyJsonSettingsEditor.setValue(this.widget.dataKeySettingsSchema ? this.widget.dataKeySettingsSchema : '', -1);
}
}
beautifyJs(): void {
const res = js_beautify(this.widget.controllerScript, {indent_size: 4, wrap_line_length: 60});
if (this.widget.controllerScript !== res) {
this.isDirty = true;
this.widget.controllerScript = res;
this.jsEditor.setValue(this.widget.controllerScript ? this.widget.controllerScript : '', -1);
}
}
removeResource(index: number) {
if (index > -1) {
if (this.widget.resources.splice(index, 1).length > 0) {
this.isDirty = true;
}
}
}
addResource() {
this.widget.resources.push({url: ''});
this.isDirty = true;
}
widetTypeChanged() {
const config: WidgetConfig = JSON.parse(this.widget.defaultConfig);
if (this.widget.type !== widgetType.rpc &&
this.widget.type !== widgetType.alarm) {
if (config.targetDeviceAliases) {
delete config.targetDeviceAliases;
}
if (config.alarmSource) {
delete config.alarmSource;
}
if (!config.datasources) {
config.datasources = [];
}
if (!config.timewindow) {
config.timewindow = {
realtime: {
timewindowMs: 60000
}
};
}
} else if (this.widget.type === widgetType.rpc) {
if (config.datasources) {
delete config.datasources;
}
if (config.alarmSource) {
delete config.alarmSource;
}
if (config.timewindow) {
delete config.timewindow;
}
if (!config.targetDeviceAliases) {
config.targetDeviceAliases = [];
}
} else { // alarm
if (config.datasources) {
delete config.datasources;
}
if (config.targetDeviceAliases) {
delete config.targetDeviceAliases;
}
if (!config.alarmSource) {
config.alarmSource = {};
}
if (!config.timewindow) {
config.timewindow = {
realtime: {
timewindowMs: 24 * 60 * 60 * 1000
}
};
}
}
this.widget.defaultConfig = JSON.stringify(config);
this.isDirty = true;
}
}