UI: Ability to import form properties from JSON content. Update help resources according to new widget settings forms.

This commit is contained in:
Igor Kulikov 2024-12-23 16:48:20 +02:00
parent 39abbacb20
commit f6071177ae
13 changed files with 205 additions and 97 deletions

View File

@ -37,7 +37,12 @@ import {
} from '@angular/forms';
import { CdkDragDrop } from '@angular/cdk/drag-drop';
import { TranslateService } from '@ngx-translate/core';
import { FormProperty, FormPropertyType, propertyValid } from '@shared/models/dynamic-form.models';
import {
cleanupFormProperties,
FormProperty,
FormPropertyType,
propertyValid
} from '@shared/models/dynamic-form.models';
import {
DynamicFormPropertyRowComponent
} from '@home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-row.component';
@ -134,7 +139,7 @@ export class DynamicFormPropertiesComponent implements ControlValueAccessor, OnI
controls[i].patchValue(p, {emitEvent: false});
}
});
this.propagateChange(properties);
this.propagateChange(cleanupFormProperties(properties));
}
);
}

View File

@ -54,8 +54,24 @@ const widgetEditorCompletions = (settingsCompletions?: TbEditorCompletions): TbE
description: 'Called when widget element is destroyed. Should be used to cleanup all resources if necessary.',
meta: 'function'
},
getSettingsForm: {
description: 'Optional function returning widget settings form array as alternative to <b>Settings form</b> tab of settings section.',
meta: 'function',
return: {
description: 'An array of widget settings form properties',
type: 'Array&lt;FormProperty&gt;'
}
},
getDataKeySettingsForm: {
description: 'Optional function returning particular data key settings form array as alternative to <b>Data key settings form</b> tab of settings section.',
meta: 'function',
return: {
description: 'An array of data key settings form properties',
type: 'Array&lt;FormProperty&gt;'
}
},
getSettingsSchema: {
description: 'Optional function returning widget settings schema json as alternative to <b>Settings tab</b> of <a href="https://thingsboard.io/docs/user-guide/contribution/widgets-development/#settings-schema-section" target="_blank">Settings schema section</a>.',
description: '<b>Deprecated</b>. Use getSettingsForm() function.',
meta: 'function',
return: {
description: 'An widget settings schema json',
@ -63,7 +79,7 @@ const widgetEditorCompletions = (settingsCompletions?: TbEditorCompletions): TbE
}
},
getDataKeySettingsSchema: {
description: 'Optional function returning particular data key settings schema json as alternative to <b>Data key settings schema</b> of <a href="https://thingsboard.io/docs/user-guide/contribution/widgets-development/#settings-schema-section" target="_blank">Settings schema section</a>.',
description: '<b>Deprecated</b>. Use getDataKeySettingsForm() function.',
meta: 'function',
return: {
description: 'A particular data key settings schema json',

View File

@ -15,7 +15,7 @@
limitations under the License.
-->
<form [formGroup]="importFormGroup" (ngSubmit)="importFromJson()">
<form [formGroup]="importFormGroup" (ngSubmit)="importFromJson()" style="width: 500px;">
<mat-toolbar color="primary">
<h2 translate>{{ importTitle }}</h2>
<span class="flex-1"></span>
@ -30,15 +30,28 @@
<div mat-dialog-content>
<fieldset [disabled]="isLoading$ | async">
<div class="flex flex-1 flex-col">
<tb-file-input
<tb-toggle-select *ngIf="enableImportFromContent"
class="flex-1"
formControlName="importType">
<tb-toggle-option value="file">{{ importFileLabel | translate }}</tb-toggle-option>
<tb-toggle-option value="content">{{ importContentLabel | translate }}</tb-toggle-option>
</tb-toggle-select>
<tb-file-input *ngIf="importFormGroup.get('importType').value === 'file'"
[contentConvertFunction]="loadDataFromJsonContent"
formControlName="jsonContent"
[existingFileName]="currentFileName"
(fileNameChanged)="currentFileName = $event"
formControlName="fileContent"
required
label="{{importFileLabel | translate}}"
dropLabel="{{ 'import.drop-json-file-or' | translate }}"
accept=".json,application/json"
allowedExtensions="json">
</tb-file-input>
<tb-json-object-edit *ngIf="importFormGroup.get('importType').value === 'content'"
formControlName="jsonContent"
jsonRequired
label="{{ importContentLabel | translate }}">
</tb-json-object-edit>
</div>
</fieldset>
</div>

View File

@ -14,7 +14,7 @@
/// limitations under the License.
///
import { Component, Inject, OnInit, SkipSelf } from '@angular/core';
import { Component, DestroyRef, Inject, OnInit, SkipSelf } from '@angular/core';
import { ErrorStateMatcher } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
@ -23,10 +23,14 @@ import { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, FormGroupDire
import { Router } from '@angular/router';
import { DialogComponent } from '@app/shared/components/dialog.component';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import { isDefinedAndNotNull } from '@core/utils';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export interface ImportDialogData {
importTitle: string;
importFileLabel: string;
enableImportFromContent?: boolean;
importContentLabel?: string;
}
@Component({
@ -40,9 +44,13 @@ export class ImportDialogComponent extends DialogComponent<ImportDialogComponent
importTitle: string;
importFileLabel: string;
enableImportFromContent: boolean;
importContentLabel: string;
importFormGroup: UntypedFormGroup;
currentFileName: string;
submitted = false;
constructor(protected store: Store<AppState>,
@ -50,17 +58,26 @@ export class ImportDialogComponent extends DialogComponent<ImportDialogComponent
@Inject(MAT_DIALOG_DATA) public data: ImportDialogData,
@SkipSelf() private errorStateMatcher: ErrorStateMatcher,
public dialogRef: MatDialogRef<ImportDialogComponent>,
private destroyRef: DestroyRef,
private fb: UntypedFormBuilder) {
super(store, router, dialogRef);
this.importTitle = data.importTitle;
this.importFileLabel = data.importFileLabel;
this.importFormGroup = this.fb.group({
jsonContent: [null, [Validators.required]]
});
this.enableImportFromContent = isDefinedAndNotNull(data.enableImportFromContent) ? data.enableImportFromContent : false;
this.importContentLabel = data.importContentLabel;
}
ngOnInit(): void {
this.importFormGroup = this.fb.group({
importType: ['file'],
fileContent: [null, [Validators.required]],
jsonContent: [null, [Validators.required]]
});
this.importFormGroup.get('importType').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
this.importTypeChanged();
});
}
isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean {
@ -85,7 +102,19 @@ export class ImportDialogComponent extends DialogComponent<ImportDialogComponent
importFromJson(): void {
this.submitted = true;
const importData = this.importFormGroup.get('jsonContent').value;
const importType: 'file' | 'content' = this.importFormGroup.get('importType').value;
const importData = this.importFormGroup.get(importType === 'file' ? 'fileContent' : 'jsonContent').value;
this.dialogRef.close(importData);
}
private importTypeChanged() {
const importType: 'file' | 'content' = this.importFormGroup.get('importType').value;
if (importType === 'file') {
this.importFormGroup.get('fileContent').enable({emitEvent: false});
this.importFormGroup.get('jsonContent').disable({emitEvent: false});
} else {
this.importFormGroup.get('fileContent').disable({emitEvent: false});
this.importFormGroup.get('jsonContent').enable({emitEvent: false});
}
}
}

View File

@ -125,7 +125,8 @@ export class ImportExportService {
}
public importFormProperties(): Observable<FormProperty[]> {
return this.openImportDialog('dynamic-form.import-form', 'dynamic-form.form-json-file').pipe(
return this.openImportDialog('dynamic-form.import-form',
'dynamic-form.json-file', true, 'dynamic-form.json-content').pipe(
map((properties: FormProperty[]) => {
if (!this.validateImportedFormProperties(properties)) {
this.store.dispatch(new ActionNotificationShow(
@ -1134,14 +1135,17 @@ export class ImportExportService {
};
}
private openImportDialog(importTitle: string, importFileLabel: string): Observable<any> {
private openImportDialog(importTitle: string, importFileLabel: string,
enableImportFromContent = false, importContentLabel?: string): Observable<any> {
return this.dialog.open<ImportDialogComponent, ImportDialogData,
any>(ImportDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
importTitle,
importFileLabel
importFileLabel,
enableImportFromContent,
importContentLabel
}
}).afterClosed().pipe(
map((importedData) => {

View File

@ -325,7 +325,7 @@ export const widgetContextCompletionsWithSettings = (settingsCompletions?: TbEdi
type: 'object'
},
settings: {
description: 'Widget settings containing widget specific properties according to the defined <a href="https://thingsboard.io/docs/user-guide/contribution/widgets-development/#settings-schema-section" target="_blank">settings json schema</a>',
description: 'Widget settings containing widget specific properties according to the defined settings form.',
meta: 'property',
type: 'object',
children: settingsCompletions
@ -363,7 +363,7 @@ export const widgetContextCompletionsWithSettings = (settingsCompletions?: TbEdi
}
},
settings: {
description: 'Widget settings containing widget specific properties according to the defined <a href="https://thingsboard.io/docs/user-guide/contribution/widgets-development/#settings-schema-section" target="_blank">settings json schema</a>',
description: 'Widget settings containing widget specific properties according to the defined settings form.',
meta: 'property',
type: 'object',
children: settingsCompletions

View File

@ -16,8 +16,8 @@
import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe';
import { TbEditorCompletion, TbEditorCompletions } from '@shared/models/ace/completion.models';
import { deepClone, isDefinedAndNotNull, isString } from '@core/utils';
import { JsonSchema, JsonSettingsSchema, JsonFormData, KeyLabelItem } from '@shared/legacy/json-form.models';
import { deepClone, isDefinedAndNotNull, isEmptyStr, isString, isUndefinedOrNull } from '@core/utils';
import { JsonFormData, JsonSchema, JsonSettingsSchema, KeyLabelItem } from '@shared/legacy/json-form.models';
import JsonFormUtils from '@shared/legacy/json-form-utils';
import { constantColor, Font } from '@shared/models/widget-settings.models';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@ -165,6 +165,63 @@ export interface FormHtmlSection extends FormPropertyBase {
export type FormProperty = FormPropertyBase & FormTextareaProperty & FormNumberProperty & FormSelectProperty & FormRadiosProperty
& FormDateTimeProperty & FormJavascriptProperty & FormMarkdownProperty & FormFieldSetProperty & FormArrayProperty & FormHtmlSection;
export const cleanupFormProperties = (properties: FormProperty[]): FormProperty[] => {
for (const property of properties) {
cleanupFormProperty(property);
}
return properties;
}
export const cleanupFormProperty = (property: FormProperty): FormProperty => {
if (property.type !== FormPropertyType.number) {
delete property.min;
delete property.max;
delete property.step;
}
if (property.type !== FormPropertyType.textarea) {
delete property.rows;
}
if (property.type !== FormPropertyType.fieldset) {
delete property.properties;
} else if (property.properties?.length) {
property.properties = cleanupFormProperties(property.properties);
}
if (property.type !== FormPropertyType.array) {
delete property.arrayItemName;
delete property.arrayItemType;
}
if (property.type !== FormPropertyType.select) {
delete property.multiple;
delete property.allowEmptyOption;
delete property.minItems;
delete property.maxItems;
}
if (property.type !== FormPropertyType.radios) {
delete property.direction;
}
if (![FormPropertyType.select, FormPropertyType.radios].includes(property.type)) {
delete property.items;
}
if (property.type !== FormPropertyType.datetime) {
delete property.allowClear;
delete property.dateTimeType;
}
if (![FormPropertyType.javascript, FormPropertyType.markdown].includes(property.type)) {
delete property.helpId;
}
if (property.type !== FormPropertyType.htmlSection) {
delete property.htmlClassList;
delete property.htmlContent;
}
for (const key of Object.keys(property)) {
const val = property[key];
if (isUndefinedOrNull(val) || isEmptyStr(val)) {
delete property[key];
}
}
return property;
}
export enum FormPropertyContainerType {
field = 'field',
row = 'row',

View File

@ -40,32 +40,25 @@ The **Widget Editor** will be opened, pre-populated with the content of the defa
{:copy-code}
```
- Put the following JSON content inside the "Settings schema" tab of **Settings schema section**:
- Import the following JSON content inside the "Settings form" tab by clicking on 'Import form from JSON' button:
```json
{
"schema": {
"type": "object",
"title": "AlarmTableSettings",
"properties": {
"alarmSeverityColorFunction": {
"title": "Alarm severity color function: f(severity)",
"type": "string",
"default": "if(severity == 'CRITICAL') {return 'red';} else if (severity == 'MAJOR') {return 'orange';} else return 'green'; "
}
},
"required": []
},
"form": [
{
"key": "alarmSeverityColorFunction",
"type": "javascript"
}
]
}
[
{
"id": "alarmSeverityColorFunction",
"name": "Alarm severity color function: f(severity)",
"type": "javascript",
"default": "if (severity == 'CRITICAL') {\n return 'red';\n} else if (severity == 'MAJOR') {\n return 'orange';\n} else return 'green';",
"required": false
}
]
{:copy-code}
```
- Clear all 'form selector' fields in the "Widget settings" tab.
- Turn off 'Has basic mode' switch in the "Widget settings" tab.
- Put the following JavaScript code inside the "JavaScript" section:
```javascript

View File

@ -40,35 +40,28 @@ The **Widget Editor** will open, pre-populated with default **Control** template
{:copy-code}
```
- Put the following JSON content inside the "Settings schema" tab of **Settings schema section**:
- Import the following JSON content inside the "Settings form" tab by clicking on 'Import form from JSON' button:
```json
{
"schema": {
"type": "object",
"title": "Settings",
"properties": {
"oneWayElseTwoWay": {
"title": "Is One Way Command",
"type": "boolean",
"default": true
},
"requestTimeout": {
"title": "RPC request timeout",
"type": "number",
"default": 500
}
},
"required": []
},
"form": [
"oneWayElseTwoWay",
"requestTimeout"
]
}
[
{
"id": "oneWayElseTwoWay",
"name": "Is One Way Command",
"type": "switch",
"default": true
},
{
"id": "requestTimeout",
"name": "RPC request timeout",
"type": "number",
"default": 500
}
]
{:copy-code}
```
- Clear value of 'Settings form selector' in the "Widget settings" tab.
- Put the following JavaScript code inside the "JavaScript" section:
```javascript

View File

@ -17,28 +17,23 @@ The **Widget Editor** will be opened pre-populated with the content of default *
{:copy-code}
```
- Put the following JSON content inside the "Settings schema" tab of **Settings schema section**:
- Import the following JSON content inside the "Settings form" tab by clicking on 'Import form from JSON' button:
```json
{
"schema": {
"type": "object",
"title": "Settings",
"properties": {
"alertContent": {
"title": "Alert content",
"type": "string",
"default": "Content derived from alertContent property of widget settings."
}
}
},
"form": [
"alertContent"
]
}
[
{
"id": "alertContent",
"name": "Alert content",
"type": "text",
"default": "Content derived from alertContent property of widget settings.",
"fieldClass": "flex"
}
]
{:copy-code}
```
- Clear value of 'Settings form selector' in the "Widget settings" tab.
- Put the following JavaScript code inside the "JavaScript" section:
```javascript

View File

@ -10,18 +10,20 @@ Each widget function should be defined as a property of the **self** variable.
In order to implement a new widget, the following JavaScript functions should be defined *(Note: each function is optional and can be implemented according to widget specific behaviour):*
|{:auto} **Function** | **Description** |
|------------------------------------|----------------------------------------------------------------------------------------|
| ``` onInit() ``` | The first function which is called when widget is ready for initialization. Should be used to prepare widget DOM, process widget settings and initial subscription information. |
| ``` onDataUpdated() ``` | Called when the new data is available from the widget subscription. Latest data can be accessed from the <span trigger-style="fontSize: 16px;" trigger-text="<b>defaultSubscription</b>" tb-help-popup="widget/editor/widget_js_subscription_object"></span> object of widget context (**ctx**). |
| ``` onResize() ``` | Called when widget container is resized. Latest width and height can be obtained from widget context (**ctx**). |
| ``` onEditModeChanged() ``` | Called when dashboard editing mode is changed. Latest mode is handled by isEdit property of **ctx**. |
| ``` onMobileModeChanged() ``` | Called when dashboard view width crosses mobile breakpoint. Latest state is handled by isMobile property of **ctx**. |
| ``` onDestroy() ``` | Called when widget element is destroyed. Should be used to cleanup all resources if necessary. |
| ``` getSettingsSchema() ``` | Optional function returning widget settings schema json as alternative to **Settings schema** of settings section. |
| ``` getDataKeySettingsSchema() ``` | Optional function returning particular data key settings schema json as alternative to **Data key settings schema** tab of settings section. |
| ``` typeParameters() ``` | Returns [WidgetTypeParameters{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/shared/models/widget.models.ts#L151) object describing widget datasource parameters. See <span trigger-style="fontSize: 16px;" trigger-text="<b>Type parameters object</b>" tb-help-popup="widget/editor/widget_js_type_parameters_object"></span> | |
| ``` actionSources() ``` | Returns map describing available widget action sources ([WidgetActionSource{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/shared/models/widget.models.ts#L121)) used to define user actions. See <span trigger-style="fontSize: 16px;" trigger-text="<b>Action sources object</b>" tb-help-popup="widget/editor/widget_js_action_sources_object"></span> |
| {:auto} **Function** | **Description** |
|-----------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ``` onInit() ``` | The first function which is called when widget is ready for initialization. Should be used to prepare widget DOM, process widget settings and initial subscription information. |
| ``` onDataUpdated() ``` | Called when the new data is available from the widget subscription. Latest data can be accessed from the <span trigger-style="fontSize: 16px;" trigger-text="<b>defaultSubscription</b>" tb-help-popup="widget/editor/widget_js_subscription_object"></span> object of widget context (**ctx**). |
| ``` onResize() ``` | Called when widget container is resized. Latest width and height can be obtained from widget context (**ctx**). |
| ``` onEditModeChanged() ``` | Called when dashboard editing mode is changed. Latest mode is handled by isEdit property of **ctx**. |
| ``` onMobileModeChanged() ``` | Called when dashboard view width crosses mobile breakpoint. Latest state is handled by isMobile property of **ctx**. |
| ``` onDestroy() ``` | Called when widget element is destroyed. Should be used to cleanup all resources if necessary. |
| ``` getSettingsForm() ``` | Optional function returning widget settings form array as alternative to **Settings form** tab of settings section. |
| ``` getDataKeySettingsForm() ``` | Optional function returning particular data key settings form array as alternative to **Data key settings form** tab of settings section. |
| ``` typeParameters() ``` | Returns [WidgetTypeParameters{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/shared/models/widget.models.ts#L151) object describing widget datasource parameters. See <span trigger-style="fontSize: 16px;" trigger-text="<b>Type parameters object</b>" tb-help-popup="widget/editor/widget_js_type_parameters_object"></span> | |
| ``` actionSources() ``` | Returns map describing available widget action sources ([WidgetActionSource{:target="_blank"}](https://github.com/thingsboard/thingsboard/blob/2627fe51d491055d4140f16617ed543f7f5bd8f6/ui-ngx/src/app/shared/models/widget.models.ts#L121)) used to define user actions. See <span trigger-style="fontSize: 16px;" trigger-text="<b>Action sources object</b>" tb-help-popup="widget/editor/widget_js_action_sources_object"></span> |
| ~~getSettingsSchema()~~ | **Deprecated**. Use getSettingsForm() function. |
| ~~getDataKeySettingsSchema()~~ | **Deprecated**. Use getDataKeySettingsForm() function. |
<div class="divider"></div>

View File

@ -26,7 +26,7 @@ For [Latest values{:target="_blank"}](${siteBaseUrl}/docs${docPlatformPrefix}/us
label: 'Sin', // label of the dataKey. Used as display value (for ex. in the widget legend section)
color: '#ffffff', // color of the key. Can be used by widget to set color of the key data (for ex. lines in line chart or segments in the pie chart).
funcBody: "", // only applicable for datasource with type "function" and "function" key type. Defines body of the function to generate simulated data.
settings: {} // dataKey specific settings with structure according to the defined Data key settings json schema. See "Settings schema section".
settings: {} // dataKey specific settings with structure according to the defined Data key settings form.
},
//...
]
@ -72,7 +72,7 @@ For [Alarm widget{:target="_blank"}](${siteBaseUrl}/docs${docPlatformPrefix}/use
type: 'alarm', // type of the dataKey. Only "alarm" in this case.
label: 'Severity', // label of the dataKey. Used as display value (for ex. as a column title in the Alarms table)
color: '#ffffff', // color of the key. Can be used by widget to set color of the key data.
settings: {} // dataKey specific settings with structure according to the defined Data key settings json schema. See "Settings schema section".
settings: {} // dataKey specific settings with structure according to the defined Data key settings form.
},
//...
]

View File

@ -1699,7 +1699,8 @@
"clear-form-prompt": "Are you sure you want to remove all form properties?",
"import-form": "Import form from JSON",
"export-form": "Export form to JSON",
"form-json-file": "Form JSON file",
"json-file": "JSON file",
"json-content": "JSON content",
"invalid-form-json-file-error": "Unable to import form from JSON: Invalid form JSON data structure."
},
"asset-profile": {