Browse Source

Implement JS Module resources support.

pull/12171/head
Igor Kulikov 2 years ago
parent
commit
03ba0c32dc
  1. 39
      ui-ngx/src/app/core/services/utils.service.ts
  2. 13
      ui-ngx/src/app/modules/home/components/widget/lib/action/action-widget.models.ts
  3. 3
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action.models.ts
  4. 1
      ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-config.resolver.ts
  5. 4
      ui-ngx/src/app/modules/home/pages/widget/widget-editor.models.ts
  6. 15
      ui-ngx/src/app/shared/components/help-markdown.component.ts
  7. 13
      ui-ngx/src/app/shared/components/help-popup.component.html
  8. 15
      ui-ngx/src/app/shared/components/help-popup.component.scss
  9. 56
      ui-ngx/src/app/shared/components/help-popup.component.ts
  10. 15
      ui-ngx/src/app/shared/components/js-func-module-row.component.html
  11. 4
      ui-ngx/src/app/shared/components/js-func-module-row.component.scss
  12. 36
      ui-ngx/src/app/shared/components/js-func-module-row.component.ts
  13. 8
      ui-ngx/src/app/shared/components/js-func-modules.component.scss
  14. 71
      ui-ngx/src/app/shared/components/js-func.component.ts
  15. 2
      ui-ngx/src/app/shared/components/json-form/json-form.component.ts
  16. 5
      ui-ngx/src/app/shared/components/popover.service.ts
  17. 10
      ui-ngx/src/app/shared/components/resource/resource-autocomplete.component.ts
  18. 2
      ui-ngx/src/app/shared/models/ace/completion.models.ts
  19. 47
      ui-ngx/src/app/shared/models/error.models.ts
  20. 175
      ui-ngx/src/app/shared/models/js-function.models.ts
  21. 4
      ui-ngx/src/assets/locale/locale.constant-en_US.json

39
ui-ngx/src/app/core/services/utils.service.ts

@ -19,7 +19,7 @@
import { Inject, Injectable, NgZone, Renderer2 } from '@angular/core';
import { WINDOW } from '@core/services/window.service';
import { ExceptionData } from '@app/shared/models/error.models';
import { ExceptionData, parseException } from '@app/shared/models/error.models';
import {
base64toObj,
base64toString,
@ -214,42 +214,7 @@ export class UtilsService {
}
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;
return parseException(exception, lineOffset);
}
public customTranslation(translationValue: string, defaultValue: string): string {

13
ui-ngx/src/app/modules/home/components/widget/lib/action/action-widget.models.ts

@ -24,7 +24,6 @@ import {
import { WidgetContext } from '@home/models/widget-component.models';
import { BehaviorSubject, forkJoin, Observable, Observer, of, Subscription, throwError } from 'rxjs';
import { catchError, delay, map, share, take } from 'rxjs/operators';
import { UtilsService } from '@core/services/utils.service';
import { AfterViewInit, ChangeDetectorRef, Directive, Input, OnDestroy, OnInit, TemplateRef } from '@angular/core';
import {
DataToValueSettings,
@ -45,6 +44,7 @@ import { ValueType } from '@shared/models/constants';
import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models';
import { EntityId } from '@shared/models/id/entity-id';
import { isDefinedAndNotNull } from '@core/utils';
import { parseError } from '@shared/models/error.models';
@Directive()
// eslint-disable-next-line @angular-eslint/directive-class-suffix
@ -110,7 +110,7 @@ export abstract class BasicActionWidgetComponent implements OnInit, OnDestroy, A
}
},
error: (err: any) => {
const message = parseError(this.ctx, err);
const message = parseError(err);
this.onError(message);
if (valueObserver?.error) {
valueObserver.error(err);
@ -152,7 +152,7 @@ export abstract class BasicActionWidgetComponent implements OnInit, OnDestroy, A
if (setValueObserver?.error) {
setValueObserver.error(err);
}
const message = parseError(this.ctx, err);
const message = parseError(err);
this.onError(message);
}
});
@ -214,7 +214,7 @@ export abstract class ValueAction {
protected settings: ValueActionSettings) {}
protected handleError(err: any): Error {
const reason = parseError(this.ctx, err);
const reason = parseError(err);
let errorMessage = this.ctx.translate.instant('widgets.value-action.error.failed-to-perform-action',
{actionLabel: this.settings.actionLabel});
if (reason) {
@ -709,15 +709,12 @@ export class TimeSeriesValueSetter<V> extends TelemetryValueSetter<V> {
}
const parseError = (ctx: WidgetContext, err: any): string =>
ctx.$injector.get(UtilsService).parseException(err).message || 'Unknown Error';
const handleRpcError = (ctx: WidgetContext, err: any): Error => {
let reason: string;
if (ctx.defaultSubscription.rpcErrorText) {
reason = ctx.defaultSubscription.rpcErrorText;
} else {
reason = parseError(ctx, err);
reason = parseError(err);
}
return new Error(reason);
};

3
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action.models.ts

@ -63,8 +63,7 @@ const customActionCompletions: TbEditorCompletions = {
type: 'string',
description: 'Label of the entity for which the action was triggered.'
}
},
...serviceCompletions
}
};
const customPrettyActionCompletions: TbEditorCompletions = {

1
ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-config.resolver.ts

@ -104,6 +104,7 @@ export class JsLibraryTableConfigResolver {
}
};
this.config.saveEntity = resource => {
resource.resourceType = ResourceType.JS_MODULE;
let saveObservable = this.resourceService.saveResource(resource);
if (resource.resourceSubType === ResourceSubType.MODULE) {
saveObservable = saveObservable.pipe(

4
ui-ngx/src/app/modules/home/pages/widget/widget-editor.models.ts

@ -85,9 +85,7 @@ const widgetEditorCompletions: TbEditorCompletions = {
},
...widgetContextCompletions
}
}},
...widgetContextCompletions,
...serviceCompletions
}}
};
export const widgetEditorCompleter = new TbEditorCompleter(widgetEditorCompletions);

15
ui-ngx/src/app/shared/components/help-markdown.component.ts

@ -21,10 +21,11 @@ import {
OnDestroy, OnInit,
Output, SimpleChanges
} from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { BehaviorSubject, Observable } from 'rxjs';
import { share } from 'rxjs/operators';
import { HelpService } from '@core/services/help.service';
import { coerceBoolean } from '@shared/decorators/coercion';
import { base64toString } from '@core/utils';
@Component({
selector: 'tb-help-markdown',
@ -37,6 +38,10 @@ export class HelpMarkdownComponent implements OnDestroy, OnInit, OnChanges {
@Input() helpContent: string;
@Input() helpContentBase64: string;
@Input() asyncHelpContent: Observable<string>;
@Input()
@coerceBoolean()
visible: boolean;
@ -73,7 +78,7 @@ export class HelpMarkdownComponent implements OnDestroy, OnInit, OnChanges {
this.loadHelp();
}
}
if (propName === 'helpId' || propName === 'helpContent') {
if (['helpId', 'helpContent', 'helpContentBase64', 'asyncHelpContent'].includes(propName)) {
this.markdownText.next(null);
this.loadHelpWhenVisible();
}
@ -96,6 +101,12 @@ export class HelpMarkdownComponent implements OnDestroy, OnInit, OnChanges {
});
} else if (this.helpContent) {
this.markdownText.next(this.helpContent);
} else if (this.helpContentBase64) {
this.markdownText.next(base64toString(this.helpContentBase64));
} else if (this.asyncHelpContent) {
this.asyncHelpContent.subscribe((content) => {
this.markdownText.next(content);
});
}
}

13
ui-ngx/src/app/shared/components/help-popup.component.html

@ -17,16 +17,20 @@
-->
<fieldset class="tb-help-popup-button-container" *ngIf="!textMode">
<div #toggleHelpButton
matTooltip="{{'help.show-help' | translate}}"
matTooltip="{{ helpIconTooltip }}"
matTooltipPosition="above"
style="border-radius: 50%"
(click)="toggleHelp()">
<button mat-icon-button
[disabled]="disabled()"
color="primary"
class="tb-help-popup-button tb-mat-32"
class="tb-help-popup-button"
[class]="helpIconButtonClass"
type="button">
<mat-icon class="material-icons">{{popoverVisible ? 'help' : 'help_outline'}}</mat-icon>
<mat-spinner *ngIf="popoverVisible && !popoverReady" class="tb-help-popup-button-loading" mode="indeterminate" diameter="20" strokeWidth="2"></mat-spinner>
<tb-icon matButtonIcon>{{popoverVisible ? helpOpenedIcon : helpIcon}}</tb-icon>
<div *ngIf="popoverVisible && !popoverReady" class="tb-help-popup-button-loading absolute inset-0 flex items-center justify-center">
<mat-spinner mode="indeterminate" diameter="20" strokeWidth="2"></mat-spinner>
</div>
</button>
</div>
</fieldset>
@ -34,6 +38,7 @@
<div #toggleHelpTextButton
(click)="toggleHelp()">
<button mat-button
[disabled]="disabled()"
type="button"
color="primary"
class="tb-help-popup-text-button"

15
ui-ngx/src/app/shared/components/help-popup.component.scss

@ -22,21 +22,18 @@
}
}
.tb-help-popup-button-loading {
background: #fff;
border-radius: 50%;
z-index: 1;
}
.tb-help-popup-button {
position: relative;
.mat-mdc-progress-spinner {
position: absolute;
top: 0;
left: 0;
background: #fff;
border-radius: 50%;
width: 32px !important;
height: 32px !important;
.mdc-circular-progress__indeterminate-container {
width: 20px;
height: 20px;
top: 6px;
left: 6px;
}
svg {
width: 20px;

56
ui-ngx/src/app/shared/components/help-popup.component.ts

@ -29,9 +29,11 @@ import { PopoverPlacement } from '@shared/components/popover.models';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { isDefinedAndNotNull } from '@core/utils';
import { coerceBoolean } from '@shared/decorators/coercion';
import { Observable } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
@Component({
selector: '[tb-help-popup], [tb-help-popup-content]',
selector: '[tb-help-popup], [tb-help-popup-content], [tb-help-popup-content-base64], [tb-help-popup-async-content]',
templateUrl: './help-popup.component.html',
styleUrls: ['./help-popup.component.scss'],
encapsulation: ViewEncapsulation.None
@ -45,6 +47,22 @@ export class HelpPopupComponent implements OnChanges, OnDestroy {
@Input('tb-help-popup-content') helpContent: string;
@Input('tb-help-popup-content-base64') helpContentBase64: string;
@Input('tb-help-popup-async-content') asyncHelpContent: () => Observable<string> | null;
// eslint-disable-next-line @angular-eslint/no-input-rename
@Input('help-icon') helpIcon = 'help_outline';
// eslint-disable-next-line @angular-eslint/no-input-rename
@Input('help-opened-icon') helpOpenedIcon = 'help';
// eslint-disable-next-line @angular-eslint/no-input-rename
@Input('help-icon-tooltip') helpIconTooltip = this.translate.instant('help.show-help');
// eslint-disable-next-line @angular-eslint/no-input-rename
@Input('help-icon-button-class') helpIconButtonClass = 'tb-mat-32';
// eslint-disable-next-line @angular-eslint/no-input-rename
@Input('trigger-text') triggerText: string;
@ -69,10 +87,10 @@ export class HelpPopupComponent implements OnChanges, OnDestroy {
textMode = false;
constructor(private viewContainerRef: ViewContainerRef,
private element: ElementRef<HTMLElement>,
private sanitizer: DomSanitizer,
private renderer: Renderer2,
private popoverService: TbPopoverService) {
private popoverService: TbPopoverService,
private translate: TranslateService) {
}
ngOnChanges(changes: SimpleChanges): void {
@ -84,19 +102,27 @@ export class HelpPopupComponent implements OnChanges, OnDestroy {
this.textMode = this.triggerSafeHtml != null;
}
disabled(): boolean {
return !this.helpId && !this.helpContent && !this.helpContentBase64 && !this.asyncHelpContent;
}
toggleHelp() {
const trigger = this.textMode ? this.toggleHelpTextButton.nativeElement : this.toggleHelpButton.nativeElement;
this.popoverService.toggleHelpPopover(trigger, this.renderer, this.viewContainerRef,
this.helpId,
this.helpContent,
(visible) => {
this.popoverVisible = visible;
}, (ready => {
this.popoverReady = ready;
}),
this.helpPopupPlacement,
{},
this.helpPopupStyle);
if (!this.disabled()) {
const trigger = this.textMode ? this.toggleHelpTextButton.nativeElement : this.toggleHelpButton.nativeElement;
this.popoverService.toggleHelpPopover(trigger, this.renderer, this.viewContainerRef,
this.helpId,
this.helpContent,
this.helpContentBase64,
this.asyncHelpContent ? this.asyncHelpContent() : null,
(visible) => {
this.popoverVisible = visible;
}, (ready => {
this.popoverReady = ready;
}),
this.helpPopupPlacement,
{},
this.helpPopupStyle);
}
}
ngOnDestroy(): void {

15
ui-ngx/src/app/shared/components/js-func-module-row.component.html

@ -20,6 +20,7 @@
<input required matInput formControlName="alias" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-resource-autocomplete class="tb-module-link-field"
#resourceAutocomplete
formControlName="moduleLink"
inlineField
hideRequiredMarker required
@ -28,6 +29,20 @@
placeholder="{{ 'widget-config.set' | translate }}">
</tb-resource-autocomplete>
<div class="tb-form-table-row-cell-buttons">
<div [tb-help-popup-async-content]="this.moduleRowFormGroup.get('moduleLink').value ? moduleDescription : null"
tb-help-popup-placement="top"
[tb-help-popup-style]="{marginTop: '8px'}"
help-icon-button-class=""
help-icon="info_outline"
help-opened-icon="info"
help-icon-tooltip="{{ 'js-func.show-module-info' | translate }}"></div>
<div [tb-help-popup-async-content]="this.moduleRowFormGroup.get('moduleLink').value ? moduleSourceCode : null"
tb-help-popup-placement="top"
[tb-help-popup-style]="{marginTop: '8px'}"
help-icon-button-class=""
help-icon="mdi:application-brackets-outline"
help-opened-icon="mdi:application-brackets"
help-icon-tooltip="{{ 'js-func.show-module-source-code' | translate }}"></div>
<button type="button"
mat-icon-button
(click)="moduleRemoved.emit()"

4
ui-ngx/src/app/shared/components/js-func-module-row.component.scss

@ -15,9 +15,9 @@
*/
.tb-js-func-module-row {
.tb-alias-field {
flex: 1 1 40%;
flex: 1 1 25%;
}
.tb-module-link-field {
flex: 1 1 60%;
flex: 1 1 75%;
}
}

36
ui-ngx/src/app/shared/components/js-func-module-row.component.ts

@ -21,7 +21,7 @@ import {
forwardRef,
Input,
OnInit,
Output,
Output, ViewChild,
ViewEncapsulation
} from '@angular/core';
import {
@ -37,6 +37,10 @@ import {
} from '@angular/forms';
import { JsFuncModulesComponent } from '@shared/components/js-func-modules.component';
import { ResourceSubType } from '@shared/models/resource.models';
import { Observable, of } from 'rxjs';
import { ResourceAutocompleteComponent } from '@shared/components/resource/resource-autocomplete.component';
import { HttpClient } from '@angular/common/http';
import { loadModuleMarkdownDescription, loadModuleMarkdownSourceCode } from '@shared/models/js-function.models';
export interface JsFuncModuleRow {
alias: string;
@ -67,6 +71,9 @@ export class JsFuncModuleRowComponent implements ControlValueAccessor, OnInit, V
ResourceSubType = ResourceSubType;
@ViewChild('resourceAutocomplete')
resourceAutocomplete: ResourceAutocompleteComponent;
@Input()
index: number;
@ -77,11 +84,16 @@ export class JsFuncModuleRowComponent implements ControlValueAccessor, OnInit, V
modelValue: JsFuncModuleRow;
moduleDescription = this.loadModuleDescription.bind(this);
moduleSourceCode = this.loadModuleSourceCode.bind(this);
private propagateChange = (_val: any) => {};
constructor(private fb: UntypedFormBuilder,
private cd: ChangeDetectorRef,
private modulesComponent: JsFuncModulesComponent) {}
private modulesComponent: JsFuncModulesComponent,
private http: HttpClient) {}
ngOnInit() {
this.moduleRowFormGroup = this.fb.group({
@ -131,6 +143,26 @@ export class JsFuncModuleRowComponent implements ControlValueAccessor, OnInit, V
return null;
}
private loadModuleDescription(): Observable<string> | null {
const moduleLink = this.moduleRowFormGroup.get('moduleLink').value;
if (moduleLink) {
const resource = this.resourceAutocomplete.resource;
return loadModuleMarkdownDescription(this.http, resource);
} else {
return null;
}
}
private loadModuleSourceCode(): Observable<string> | null {
const moduleLink = this.moduleRowFormGroup.get('moduleLink').value;
if (moduleLink) {
const resource = this.resourceAutocomplete.resource;
return loadModuleMarkdownSourceCode(this.http, resource);
} else {
return null;
}
}
private moduleAliasValidator(): ValidatorFn {
return control => {
if (!control.value) {

8
ui-ngx/src/app/shared/components/js-func-modules.component.scss

@ -51,14 +51,14 @@
margin: 12px;
.tb-form-table-header-cell {
&.tb-alias-header {
flex: 1 1 40%;
flex: 1 1 25%;
}
&.tb-module-link-header {
flex: 1 1 60%;
flex: 1 1 75%;
}
&.tb-actions-header {
width: 40px;
min-width: 40px;
width: 120px;
min-width: 120px;
}
}
.tb-form-table {

71
ui-ngx/src/app/shared/components/js-func.component.ts

@ -21,8 +21,10 @@ import {
forwardRef,
Input,
OnDestroy,
OnInit, Renderer2,
ViewChild, ViewContainerRef,
OnInit,
Renderer2,
ViewChild,
ViewContainerRef,
ViewEncapsulation
} from '@angular/core';
import { ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, UntypedFormControl, Validator } from '@angular/forms';
@ -40,13 +42,11 @@ import { TbEditorCompleter } from '@shared/models/ace/completion.models';
import { beautifyJs } from '@shared/models/beautify.models';
import { ScriptLanguage } from '@shared/models/rule-node.models';
import { coerceBoolean } from '@shared/decorators/coercion';
import { TbFunction } from '@shared/models/js-function.models';
import { MatButton } from '@angular/material/button';
import { loadModulesCompleter, TbFunction } from '@shared/models/js-function.models';
import { TbPopoverService } from '@shared/components/popover.service';
import {
ScadaSymbolPropertyPanelComponent
} from '@home/pages/scada-symbol/metadata-components/scada-symbol-property-panel.component';
import { JsFuncModulesComponent } from '@shared/components/js-func-modules.component';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
@Component({
selector: 'tb-js-func',
@ -72,6 +72,7 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
javascriptEditorElmRef: ElementRef;
private jsEditor: Ace.Editor;
private initialCompleters: Ace.Completer[];
private editorsResizeCaf: CancelAnimationFrame;
private editorResize$: ResizeObserver;
private ignoreChange = false;
@ -167,7 +168,8 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
private cd: ChangeDetectorRef,
private popoverService: TbPopoverService,
private renderer: Renderer2,
private viewContainerRef: ViewContainerRef) {
private viewContainerRef: ViewContainerRef,
private http: HttpClient) {
}
ngOnInit(): void {
@ -237,7 +239,7 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
const hasErrors = annotations.filter(annotation => annotation.type === 'error').length > 0;
if (this.hasErrors !== hasErrors) {
this.hasErrors = hasErrors;
this.propagateChange(this.modelValue);
this.propagateValue(this.modelValue);
this.cd.markForCheck();
}
});
@ -258,9 +260,8 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
this.jsEditor.session.$onChangeMode(newMode);
}
this.updateJsWorkerGlobals();
if (this.editorCompleter) {
this.jsEditor.completers = [this.editorCompleter, ...(this.jsEditor.completers || [])];
}
this.initialCompleters = this.jsEditor.completers || [];
this.updateCompleters();
this.editorResize$ = new ResizeObserver(() => {
this.onAceEditorResize();
});
@ -326,7 +327,7 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
this.cleanupJsErrors();
this.functionValid = this.validateJsFunc();
if (!this.functionValid) {
this.propagateChange(this.modelValue);
this.propagateValue(this.modelValue);
this.cd.markForCheck();
this.store.dispatch(new ActionNotificationShow(
{
@ -455,6 +456,7 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
if (this.jsEditor) {
if (this.withModules) {
this.updateJsWorkerGlobals();
this.updateCompleters();
}
this.ignoreChange = true;
this.jsEditor.setValue(this.modelValue ? this.modelValue : '', -1);
@ -467,15 +469,7 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
if (this.modelValue !== editorValue || force) {
this.modelValue = editorValue;
this.functionValid = true;
if (this.withModules && this.modules && Object.keys(this.modules).length) {
const tbFunction: TbFunction = {
body: this.modelValue,
modules: this.modules
};
this.propagateChange(tbFunction);
} else {
this.propagateChange(this.modelValue);
}
this.propagateValue(this.modelValue);
this.cd.markForCheck();
}
}
@ -501,11 +495,24 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
modulesPanelPopover.hide();
this.modules = modules;
this.updateJsWorkerGlobals();
this.updateCompleters();
this.updateView(true);
});
}
}
private propagateValue(value: string) {
if (this.withModules && this.modules && Object.keys(this.modules).length) {
const tbFunction: TbFunction = {
body: value,
modules: this.modules
};
this.propagateChange(tbFunction);
} else {
this.propagateChange(value);
}
}
private updateJsWorkerGlobals() {
// @ts-ignore
if (!!this.jsEditor.session.$worker) {
@ -535,4 +542,24 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
this.jsEditor.session.$worker.send('changeOptions', [jsWorkerOptions]);
}
}
updateCompleters() {
let modulesCompleterObservable: Observable<TbEditorCompleter>;
if (this.withModules) {
modulesCompleterObservable = loadModulesCompleter(this.http, this.modules);
} else {
modulesCompleterObservable = of(null);
}
modulesCompleterObservable.subscribe((modulesCompleter) => {
const completers: Ace.Completer[] = [];
if (this.editorCompleter) {
completers.push(this.editorCompleter);
}
if (modulesCompleter) {
completers.push(modulesCompleter);
}
completers.push(...this.initialCompleters);
this.jsEditor.completers = completers;
});
}
}

2
ui-ngx/src/app/shared/components/json-form/json-form.component.ts

@ -243,7 +243,7 @@ export class JsonFormComponent implements ControlValueAccessor, Validator, OnCha
private onHelpClick(event: MouseEvent, helpId: string, helpVisibleFn: (visible: boolean) => void, helpReadyFn: (ready: boolean) => void) {
const trigger = event.currentTarget as Element;
this.popoverService.toggleHelpPopover(trigger, this.renderer, this.viewContainerRef, helpId, '', helpVisibleFn, helpReadyFn);
this.popoverService.toggleHelpPopover(trigger, this.renderer, this.viewContainerRef, helpId, '', '', null, helpVisibleFn, helpReadyFn);
}
private updateAndRender() {

5
ui-ngx/src/app/shared/components/popover.service.ts

@ -29,6 +29,7 @@ import { TbPopoverComponent } from '@shared/components/popover.component';
import { ComponentType } from '@angular/cdk/portal';
import { HELP_MARKDOWN_COMPONENT_TOKEN } from '@shared/components/tokens';
import { CdkOverlayOrigin } from '@angular/cdk/overlay';
import { Observable } from 'rxjs';
@Injectable()
export class TbPopoverService {
@ -113,6 +114,8 @@ export class TbPopoverService {
toggleHelpPopover(trigger: Element, renderer: Renderer2, hostView: ViewContainerRef, helpId = '',
helpContent = '',
helpContentBase64 = '',
asyncHelpContent: Observable<string> = null,
visibleFn: (visible: boolean) => void = () => {},
readyFn: (ready: boolean) => void = () => {},
preferredPlacement: PopoverPreferredPlacement = 'bottom',
@ -144,6 +147,8 @@ export class TbPopoverService {
component.tbComponentContext = {
helpId,
helpContent,
helpContentBase64,
asyncHelpContent,
style: helpStyle,
visible: true
};

10
ui-ngx/src/app/shared/components/resource/resource-autocomplete.component.ts

@ -88,6 +88,7 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn
@ViewChild('resourceInput', {static: true}) resourceInput: ElementRef;
resource: ResourceInfo;
private modelValue: string;
private dirty = false;
@ -108,10 +109,13 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn
tap(value => {
let modelValue: string;
if (isObject(value)) {
modelValue = prependTbResourcePrefix((value as ResourceInfo).link);
this.resource = value as ResourceInfo;
modelValue = prependTbResourcePrefix(this.resource.link);
} else if (isEmptyStr(value) || this.subType !== ResourceSubType.EXTENSION) {
this.resource = null;
modelValue = null;
} else {
this.resource = null;
modelValue = value as string;
}
this.updateView(modelValue);
@ -147,10 +151,12 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn
if (isObject(value) && typeof value !== 'string' && (value as TbResourceId).id) {
this.resourceService.getResourceInfoById(value.id, {ignoreLoading: true, ignoreErrors: true}).subscribe({
next: resource => {
this.resource = resource;
this.modelValue = prependTbResourcePrefix(resource.link);
this.resourceFormGroup.get('resource').patchValue(resource, {emitEvent: false});
},
error: () => {
this.resource = null;
this.modelValue = '';
this.resourceFormGroup.get('resource').patchValue('');
}
@ -160,10 +166,12 @@ export class ResourceAutocompleteComponent implements ControlValueAccessor, OnIn
const params = extractParamsFromJSResourceUrl(url);
this.resourceService.getResourceInfo(params.type, params.scope, params.key, {ignoreLoading: true, ignoreErrors: true}).subscribe({
next: resource => {
this.resource = resource;
this.modelValue = value;
this.resourceFormGroup.get('resource').patchValue(resource, {emitEvent: false});
},
error: () => {
this.resource = null;
this.modelValue = '';
this.resourceFormGroup.get('resource').patchValue('');
}

2
ui-ngx/src/app/shared/models/ace/completion.models.ts

@ -17,7 +17,7 @@
import { Ace } from 'ace-builds';
import { deepClone } from '@core/utils';
export type tbMetaType = 'object' | 'function' | 'service' | 'property' | 'argument';
export type tbMetaType = 'object' | 'function' | 'service' | 'property' | 'argument' | 'constant' | 'module';
export type TbEditorCompletions = {[name: string]: TbEditorCompletion};

47
ui-ngx/src/app/shared/models/error.models.ts

@ -15,9 +15,56 @@
///
import { isUndefined } from '@core/utils';
import { WidgetContext } from '@home/models/widget-component.models';
import { UtilsService } from '@core/services/utils.service';
export interface ExceptionData {
message?: string;
name?: string;
lineNumber?: number;
columnNumber?: number;
}
export const 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;
}
export const parseError = (err: any): string =>
parseException(err).message || 'Unknown Error';

175
ui-ngx/src/app/shared/models/js-function.models.ts

@ -14,10 +14,14 @@
/// limitations under the License.
///
import { forkJoin, from, map, Observable, of, ReplaySubject, switchMap } from 'rxjs';
import { removeTbResourcePrefix } from '@shared/models/resource.models';
import { forkJoin, from, map, mergeMap, Observable, of, ReplaySubject, switchMap } from 'rxjs';
import { removeTbResourcePrefix, ResourceInfo } from '@shared/models/resource.models';
import { HttpClient } from '@angular/common/http';
import { defaultHttpOptionsFromConfig } from '@core/http/http-utils';
import { TbEditorCompleter, TbEditorCompletion } from '@shared/models/ace/completion.models';
import { blobToText } from '@core/utils';
import { catchError, finalize } from 'rxjs/operators';
import { parseError } from '@shared/models/error.models';
export interface TbFunctionWithModules {
body: string;
@ -59,6 +63,130 @@ export const compileTbFunction = (http: HttpClient, tbFunction: TbFunction, ...a
);
}
export const loadModulesCompleter = (http: HttpClient, modules: {[alias: string]: string }): Observable<TbEditorCompleter | null> => {
if (!modules || !Object.keys(modules).length) {
return of(null);
} else {
const modulesDescription: {[alias: string]: Observable<TbEditorCompletion>} = {};
for (const alias of Object.keys(modules)) {
modulesDescription[alias] = loadModuleCompletion(http, modules[alias]);
}
return forkJoin(modulesDescription).pipe(
map((completions) => {
return new TbEditorCompleter(completions);
})
);
}
};
export const loadModuleMarkdownDescription = (http: HttpClient, resource: ResourceInfo): Observable<string> => {
let description = `<div class="flex flex-col !pl-4 !pr-4"><h6>${resource.title}</h6><small>Module members</small></div>\n\n`;
description += '<div class="divider !pt-2"></div>\n' +
'<br/>\n\n';
return loadFunctionModuleWithSource(http, resource.link).pipe(
map((moduleWithSource) => {
const module = moduleWithSource.module;
const propertiesData: { type: 'function' | 'const', propName: string, description: string }[] = [];
for (const propName of Object.keys(module)) {
let propDescription = '';
const prop = module[propName];
const type = typeof prop;
if (type === 'function') {
const funcArgs = getFunctionArguments(prop);
propDescription += `<p class="!pl-4 !pr-4"><em>function</em> <strong>${propName}</strong> <em>(${funcArgs.join(', ')})</em>: <code>any</code></p>`;
} else {
propDescription += `<p class="!pl-4 !pr-4"><em>const</em> <strong>${propName}</strong>: <code>${type}</code>`;
if (type !== 'object') {
propDescription += ` = ${prop}`;
}
propDescription += '</p>';
}
propDescription += '\n\n';
const propertyData: { type: 'function' | 'const', propName: string, description: string } = {
type: type === 'function' ? 'function' : 'const',
propName,
description: propDescription
}
propertiesData.push(propertyData);
}
propertiesData.sort((a, b) => {
if (a.type === b.type) {
return a.propName.localeCompare(b.propName);
} else if (a.type === 'const') return -1;
else return 1;
});
if (!propertiesData.length) {
description += `<div class="!pl-4 !pr-4">Module has no exported members</div>\n\n`;
} else {
propertiesData.forEach((pData) => {
description += pData.description;
});
}
return description;
}),
catchError(err => {
const errorText = parseError(err);
description += `<div class="!pl-4 !pr-4">Module load error:<br/><span style="color: red;">${errorText}</span></div>\n\n`;
return of(description);
})
);
}
export const loadModuleMarkdownSourceCode = (http: HttpClient, resource: ResourceInfo): Observable<string> => {
let sourceCode = `<div class="flex flex-col !pl-4"><h6>${resource.title}</h6><small>Source code</small></div>\n\n`;
return loadFunctionModuleSource(http, resource.link).pipe(
map((source) => {
sourceCode += '```javascript\n{:code-style="margin-left: -16px; margin-right: -16px;"}\n' + source + '\n```';
return sourceCode;
}),
catchError(err => {
const errorText = parseError(err);
sourceCode += `<div class="!pl-4 !pr-4">Source code load error:<br/><span style="color: red;">${errorText}</span></div>\n\n`;
return of(sourceCode);
})
);
}
const loadModuleCompletion = (http: HttpClient, moduleLink: string): Observable<TbEditorCompletion> => {
return loadFunctionModule(http, moduleLink).pipe(
map((module) => {
const completion: TbEditorCompletion = {
meta: 'module',
type: 'module',
children: {}
};
for (const propName of Object.keys(module)) {
const prop = module[propName];
const type = typeof prop;
const propertyCompletion: TbEditorCompletion = {
meta: type === 'function' ? 'function' : 'constant',
type
};
if (type === 'function') {
propertyCompletion.args = getFunctionArguments(prop).map(functionArg => {
return {name: functionArg}
});
propertyCompletion.return = { type: 'any'};
} else if (type !== 'object') {
propertyCompletion.description = `<div class="tb-api-title">Constant value:</div><code class="title">${prop}</code>`;
}
completion.children[propName] = propertyCompletion;
}
return completion;
}),
catchError(err => {
const completion: TbEditorCompletion = {
meta: 'module',
type: 'module',
children: {}
};
const errorText = parseError(err);
completion.description = `<div>Module load error:<br/><span style="color: red;">${errorText}</span></div>`;
return of(completion);
})
);
}
export class CompiledTbFunction {
constructor(private compiledFunction: Function,
@ -101,11 +229,14 @@ const loadFunctionModule = (http: HttpClient, moduleLink: string): Observable<Sy
modulesLoading[url] = request;
const options = defaultHttpOptionsFromConfig({ignoreLoading: true, ignoreErrors: true});
http.get(url, {...options, ...{ observe: 'response', responseType: 'blob' } }).pipe(
switchMap((response) => {
mergeMap((response) => {
const objectURL = URL.createObjectURL(response.body);
const asyncModule = from(import(/* @vite-ignore */objectURL));
URL.revokeObjectURL(objectURL);
return asyncModule;
}),
finalize(() => {
delete modulesLoading[url];
})
).subscribe(
{
@ -115,12 +246,44 @@ const loadFunctionModule = (http: HttpClient, moduleLink: string): Observable<Sy
},
error: err => {
request.error(err);
},
complete: () => {
delete modulesLoading[url];
}
}
);
}
return request;
}
interface TbModuleWithSource {
module: System.Module;
source: string;
}
const loadFunctionModuleWithSource = (http: HttpClient, moduleLink: string): Observable<TbModuleWithSource> => {
const url = removeTbResourcePrefix(moduleLink);
const options = defaultHttpOptionsFromConfig({ignoreLoading: true, ignoreErrors: true});
return http.get(url, {...options, ...{ observe: 'response', responseType: 'blob' } }).pipe(
switchMap((response) => {
const objectURL = URL.createObjectURL(response.body);
const asyncModule = from(import(/* @vite-ignore */objectURL));
URL.revokeObjectURL(objectURL);
const asyncSource = blobToText(response.body);
return forkJoin({
module: asyncModule,
source: asyncSource
});
}));
}
const loadFunctionModuleSource = (http: HttpClient, moduleLink: string): Observable<string> => {
const url = removeTbResourcePrefix(moduleLink);
const options = defaultHttpOptionsFromConfig({ignoreLoading: true, ignoreErrors: true});
return http.get(url, {...options, ...{ responseType: 'text' } });
}
const getFunctionArguments = (func: Function): string[] => {
const fnStr = func.toString().replace(/((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg, '');
let result = new Array<string>(...fnStr.slice(fnStr.indexOf('(')+1, fnStr.indexOf(')')).match(/([^\s,]+)/g));
if (result === null)
result = [];
return result;
}

4
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -3303,7 +3303,9 @@
"add-module": "Add module",
"module-alias": "Alias",
"module-resource": "JS module resource",
"not-unique-module-aliases-error": "Modules aliases must be unique!"
"not-unique-module-aliases-error": "Modules aliases must be unique!",
"show-module-info": "Show module info",
"show-module-source-code": "Show module source code"
},
"key-val": {
"key": "Key",

Loading…
Cancel
Save