Device profiles: implementation of the protobuf editor for MQTT device transport configuration

This commit is contained in:
Artem Babak 2021-10-26 07:31:24 +03:00
parent 004df83266
commit e90e35c678
6 changed files with 337 additions and 20 deletions

View File

@ -74,35 +74,54 @@
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
<div *ngIf="protoPayloadType" fxLayout="column"> <div *ngIf="protoPayloadType" fxLayout="column">
<mat-form-field fxFlex> <div>
<mat-label translate>device-profile.telemetry-proto-schema</mat-label> <tb-protobuf-content
<textarea matInput required formControlName="deviceTelemetryProtoSchema" rows="5"></textarea> [disabled]="disabled"
fxFlex
formControlName="deviceTelemetryProtoSchema"
label="{{ 'device-profile.telemetry-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceTelemetryProtoSchema').hasError('required')"> <mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceTelemetryProtoSchema').hasError('required')">
{{ 'device-profile.telemetry-proto-schema-required' | translate}} {{ 'device-profile.telemetry-proto-schema-required' | translate}}
</mat-error> </mat-error>
</mat-form-field> </div>
<mat-form-field fxFlex> <div>
<mat-label translate>device-profile.attributes-proto-schema</mat-label> <tb-protobuf-content
<textarea matInput required formControlName="deviceAttributesProtoSchema" rows="5"></textarea> [disabled]="disabled"
fxFlex
formControlName="deviceAttributesProtoSchema"
label="{{ 'device-profile.attributes-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceAttributesProtoSchema').hasError('required')"> <mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceAttributesProtoSchema').hasError('required')">
{{ 'device-profile.attributes-proto-schema-required' | translate}} {{ 'device-profile.attributes-proto-schema-required' | translate}}
</mat-error> </mat-error>
</mat-form-field> </div>
<mat-form-field style="padding-bottom: 20px" fxFlex> <div>
<mat-label translate>device-profile.rpc-request-proto-schema</mat-label> <tb-protobuf-content
<textarea matInput required formControlName="deviceRpcRequestProtoSchema" rows="5"></textarea> [disabled]="disabled"
fxFlex
formControlName="deviceRpcRequestProtoSchema"
label="{{ 'device-profile.rpc-request-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceRpcRequestProtoSchema').hasError('required')"> <mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceRpcRequestProtoSchema').hasError('required')">
{{ 'device-profile.rpc-request-proto-schema-required' | translate}} {{ 'device-profile.rpc-request-proto-required' | translate}}
</mat-error> </mat-error>
<mat-hint class="tb-hint" translate>device-profile.rpc-request-proto-schema-hint</mat-hint> </div>
</mat-form-field> <div>
<mat-form-field fxFlex> <tb-protobuf-content
<mat-label translate>device-profile.rpc-response-proto-schema</mat-label> [disabled]="disabled"
<textarea matInput required formControlName="deviceRpcResponseProtoSchema" rows="5"></textarea> fxFlex
formControlName="deviceRpcResponseProtoSchema"
label="{{ 'device-profile.rpc-response-proto-schema' | translate }}"
[fillHeight]="true">
</tb-protobuf-content>
<mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceRpcResponseProtoSchema').hasError('required')"> <mat-error *ngIf="mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceRpcResponseProtoSchema').hasError('required')">
{{ 'device-profile.rpc-response-proto-schema-required' | translate}} {{ 'device-profile.rpc-response-proto-schema-required' | translate}}
</mat-error> </mat-error>
</mat-form-field> </div>
</div> </div>
</div> </div>
</fieldset> </fieldset>

View File

@ -0,0 +1,30 @@
<div style="background: #fff;" [ngClass]="{'fill-height': fillHeight}"
tb-fullscreen
[fullscreen]="fullscreen" (fullscreenChanged)="onFullscreen()" fxLayout="column">
<div fxLayout="row" fxLayoutAlign="start center" style="height: 40px;" class="tb-protobuf-content-toolbar">
<label class="tb-title no-padding" [ngClass]="{'tb-error': !contentValid}">{{ label }}</label>
<span fxFlex></span>
<button type="button"
mat-button *ngIf="!readonly && !disabled" class="tidy" (click)="beautifyJSON()">
{{'js-func.tidy' | translate }}
</button>
<button type="button"
mat-button *ngIf="!readonly && !disabled" class="tidy" (click)="minifyJSON()">
{{'js-func.mini' | translate }}
</button>
<fieldset style="width: initial">
<div matTooltip="{{(fullscreen ? 'fullscreen.exit' : 'fullscreen.expand') | translate}}"
matTooltipPosition="above"
style="border-radius: 50%"
(click)="fullscreen = !fullscreen">
<button type='button' mat-button mat-icon-button class="tb-mat-32">
<mat-icon class="material-icons">{{ fullscreen ? 'fullscreen_exit' : 'fullscreen' }}</mat-icon>
</button>
</div>
</fieldset>
</div>
<div id="tb-protobuf-panel" tb-toast toastTarget="{{toastTargetId}}"
class="tb-protobuf-content-panel" fxLayout="column">
<div #protobufEditor id="tb-protobuf-input" [ngStyle]="editorStyle" [ngClass]="{'fill-height': fillHeight}"></div>
</div>
</div>

View File

@ -0,0 +1,43 @@
:host {
position: relative;
.fill-height {
height: 100%;
}
}
.tb-protobuf-content-toolbar {
button.mat-button, button.mat-icon-button, button.mat-icon-button.tb-mat-32 {
align-items: center;
vertical-align: middle;
min-width: 32px;
min-height: 15px;
padding: 4px;
margin: 0;
font-size: .8rem;
line-height: 15px;
color: #7b7b7b;
background: rgba(220, 220, 220, .35);
&:not(:last-child) {
margin-right: 4px;
}
}
}
.tb-protobuf-content-panel {
height: 100%;
margin-left: 15px;
border: 1px solid #c0c0c0;
overflow: auto;
resize: vertical;
#tb-protobuf-input {
width: 100%;
min-width: 200px;
min-height: 100px;
&:not(.fill-height) {
min-height: 200px;
}
}
}

View File

@ -0,0 +1,221 @@
import {
Component,
ElementRef,
forwardRef,
Input,
OnChanges,
OnDestroy,
OnInit,
SimpleChanges,
ViewChild
} from '@angular/core';
import { ControlValueAccessor, FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validator } from '@angular/forms';
import { Ace } from 'ace-builds';
import { CancelAnimationFrame, RafService } from '@core/services/raf.service';
import { ResizeObserver } from '@juggle/resize-observer';
import { guid } from '@core/utils';
import { ContentType } from '@shared/models/constants';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { getAce } from '@shared/models/ace/ace.models';
import { beautifyJs } from '@shared/models/beautify.models';
import { ActionNotificationHide, ActionNotificationShow } from '@core/notification/notification.actions';
@Component({
selector: 'tb-protobuf-content',
templateUrl: './protobuf-content.component.html',
styleUrls: ['./protobuf-content.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ProtobufContentComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => ProtobufContentComponent),
multi: true,
}
]
})
export class ProtobufContentComponent implements OnInit, ControlValueAccessor, Validator, OnChanges, OnDestroy {
@ViewChild('protobufEditor', {static: true})
protobufEditorElmRef: ElementRef;
private protobufEditor: Ace.Editor;
private editorsResizeCaf: CancelAnimationFrame;
private editorResize$: ResizeObserver;
private ignoreChange = false;
toastTargetId = `protobufContentEditor-${guid()}`;
@Input() label: string;
contentType: ContentType = ContentType.TEXT;
@Input() disabled: boolean;
@Input() fillHeight: boolean;
@Input() editorStyle: {[klass: string]: any};
@Input() tbPlaceholder: string;
private readonlyValue: boolean;
get readonly(): boolean {
return this.readonlyValue;
}
@Input()
set readonly(value: boolean) {
this.readonlyValue = coerceBooleanProperty(value);
}
fullscreen = false;
contentBody: string;
contentValid: boolean;
errorShowed = false;
private propagateChange = null;
constructor(public elementRef: ElementRef,
protected store: Store<AppState>,
private raf: RafService) {
}
ngOnInit(): void {
const editorElement = this.protobufEditorElmRef.nativeElement;
let mode = 'protobuf';
let editorOptions: Partial<Ace.EditorOptions> = {
mode: `ace/mode/${mode}`,
showGutter: true,
showPrintMargin: false,
readOnly: this.disabled || this.readonly,
};
const advancedOptions = {
enableSnippets: true,
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
autoScrollEditorIntoView: true
};
editorOptions = {...editorOptions, ...advancedOptions};
getAce().subscribe(
(ace) => {
this.protobufEditor = ace.edit(editorElement, editorOptions);
this.protobufEditor.session.setUseWrapMode(true);
this.protobufEditor.setValue(this.contentBody ? this.contentBody : '', -1);
this.protobufEditor.setReadOnly(this.disabled || this.readonly);
this.protobufEditor.on('change', () => {
if (!this.ignoreChange) {
this.updateView();
}
});
this.editorResize$ = new ResizeObserver(() => {
this.onAceEditorResize();
});
this.editorResize$.observe(editorElement);
}
);
}
ngOnDestroy(): void {
if (this.editorResize$) {
this.editorResize$.disconnect();
}
}
private onAceEditorResize() {
if (this.editorsResizeCaf) {
this.editorsResizeCaf();
this.editorsResizeCaf = null;
}
this.editorsResizeCaf = this.raf.raf(() => {
this.protobufEditor.resize();
this.protobufEditor.renderer.updateFull();
});
}
ngOnChanges(changes: SimpleChanges): void {
for (const propName of Object.keys(changes)) {
const change = changes[propName];
if (!change.firstChange && change.currentValue !== change.previousValue) {
if (propName === 'contentType') {
if (this.protobufEditor) {
let mode = 'protobuf';
this.protobufEditor.session.setMode(`ace/mode/${mode}`);
}
}
}
}
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (this.protobufEditor) {
this.protobufEditor.setReadOnly(this.disabled || this.readonly);
}
}
public validate(c: FormControl) {
return (this.contentValid) ? null : {
contentBody: {
valid: false,
},
};
}
writeValue(value: string): void {
this.contentBody = value;
this.contentValid = true;
if (this.protobufEditor) {
this.ignoreChange = true;
this.protobufEditor.setValue(this.contentBody ? this.contentBody : '', -1);
this.ignoreChange = false;
}
}
updateView() {
const editorValue = this.protobufEditor.getValue();
if (this.contentBody !== editorValue) {
this.contentBody = editorValue;
this.propagateChange(this.contentBody);
}
}
beautifyJSON() {
beautifyJs(this.contentBody, {indent_size: 4, wrap_line_length: 60}).subscribe(
(res) => {
this.protobufEditor.setValue(res ? res : '', -1);
this.updateView();
}
);
}
minifyJSON() {
const res = JSON.stringify(this.contentBody);
this.protobufEditor.setValue(res ? res : '', -1);
this.updateView();
}
onFullscreen() {
if (this.protobufEditor) {
setTimeout(() => {
this.protobufEditor.resize();
}, 0);
}
}
}

View File

@ -36,6 +36,7 @@ export function loadAceDependencies(): Observable<any> {
aceObservables.push(from(import('ace-builds/src-noconflict/mode-text'))); aceObservables.push(from(import('ace-builds/src-noconflict/mode-text')));
aceObservables.push(from(import('ace-builds/src-noconflict/mode-markdown'))); aceObservables.push(from(import('ace-builds/src-noconflict/mode-markdown')));
aceObservables.push(from(import('ace-builds/src-noconflict/mode-html'))); aceObservables.push(from(import('ace-builds/src-noconflict/mode-html')));
aceObservables.push(from(import('ace-builds/src-noconflict/mode-protobuf')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/java'))); aceObservables.push(from(import('ace-builds/src-noconflict/snippets/java')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/css'))); aceObservables.push(from(import('ace-builds/src-noconflict/snippets/css')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/json'))); aceObservables.push(from(import('ace-builds/src-noconflict/snippets/json')));

View File

@ -155,6 +155,7 @@ import { MarkedOptionsService } from '@shared/components/marked-options.service'
import { TbPopoverService } from '@shared/components/popover.service'; import { TbPopoverService } from '@shared/components/popover.service';
import { HELP_MARKDOWN_COMPONENT_TOKEN, SHARED_MODULE_TOKEN } from '@shared/components/tokens'; import { HELP_MARKDOWN_COMPONENT_TOKEN, SHARED_MODULE_TOKEN } from '@shared/components/tokens';
import { TbMarkdownComponent } from '@shared/components/markdown.component'; import { TbMarkdownComponent } from '@shared/components/markdown.component';
import { ProtobufContentComponent } from './components/protobuf-content.component';
export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) {
return markedOptionsService; return markedOptionsService;
@ -268,7 +269,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
OtaPackageAutocompleteComponent, OtaPackageAutocompleteComponent,
WidgetsBundleSearchComponent, WidgetsBundleSearchComponent,
CopyButtonComponent, CopyButtonComponent,
TogglePasswordComponent TogglePasswordComponent,
ProtobufContentComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -458,7 +460,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
OtaPackageAutocompleteComponent, OtaPackageAutocompleteComponent,
WidgetsBundleSearchComponent, WidgetsBundleSearchComponent,
CopyButtonComponent, CopyButtonComponent,
TogglePasswordComponent TogglePasswordComponent,
ProtobufContentComponent
] ]
}) })
export class SharedModule { } export class SharedModule { }