UI: Import/Export support for dynamic form properties. Improve json schema form to dynamic form conversion.

This commit is contained in:
Igor Kulikov 2024-12-20 18:08:53 +02:00
parent 9fe5f7199e
commit 5840bf1f67
13 changed files with 214 additions and 28 deletions

View File

@ -21,7 +21,36 @@
<div class="tb-form-table-header-cell tb-id-header" translate>dynamic-form.property.id</div> <div class="tb-form-table-header-cell tb-id-header" translate>dynamic-form.property.id</div>
<div class="tb-form-table-header-cell tb-name-header" translate>dynamic-form.property.name</div> <div class="tb-form-table-header-cell tb-name-header" translate>dynamic-form.property.name</div>
<div class="tb-form-table-header-cell tb-type-header" translate>dynamic-form.property.type</div> <div class="tb-form-table-header-cell tb-type-header" translate>dynamic-form.property.type</div>
<div class="tb-form-table-header-cell tb-actions-header" [class.disabled]="disabled"></div> <div class="tb-form-table-header-cell tb-actions-header tb-form-table-row-cell-buttons justify-end" [class.disabled]="disabled">
<button mat-icon-button
*ngIf="!disabled && importExport"
type="button"
matTooltip="{{ 'dynamic-form.import-form' | translate }}"
matTooltipPosition="above"
(click)="import($event)">
<tb-icon>mdi:file-import</tb-icon>
</button>
<button mat-icon-button
*ngIf="!disabled && importExport"
[class.tb-hidden]="!propertiesFormArray().controls.length"
type="button"
[disabled]="!propertiesFormArray().controls.length"
matTooltip="{{ 'dynamic-form.export-form' | translate }}"
matTooltipPosition="above"
(click)="export($event)">
<tb-icon>mdi:file-export</tb-icon>
</button>
<button mat-icon-button
*ngIf="!disabled"
[class.tb-hidden]="!propertiesFormArray().controls.length"
type="button"
[disabled]="!propertiesFormArray().controls.length"
matTooltip="{{ 'dynamic-form.clear-form' | translate }}"
matTooltipPosition="above"
(click)="clear($event)">
<tb-icon>mdi:broom</tb-icon>
</button>
</div>
</div> </div>
<div *ngIf="propertiesFormArray().controls.length; else noProperties" class="tb-form-table-body tb-drop-list" <div *ngIf="propertiesFormArray().controls.length; else noProperties" class="tb-form-table-body tb-drop-list"
cdkDropList cdkDropListOrientation="vertical" cdkDropList cdkDropListOrientation="vertical"

View File

@ -43,6 +43,8 @@ import {
} from '@home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-row.component'; } from '@home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-row.component';
import { coerceBoolean } from '@shared/decorators/coercion'; import { coerceBoolean } from '@shared/decorators/coercion';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ImportExportService } from '@shared/import-export/import-export.service';
import { DialogService } from '@core/services/dialog.service';
@Component({ @Component({
selector: 'tb-dynamic-form-properties', selector: 'tb-dynamic-form-properties',
@ -89,6 +91,13 @@ export class DynamicFormPropertiesComponent implements ControlValueAccessor, OnI
@coerceBoolean() @coerceBoolean()
fillHeight = false; fillHeight = false;
@Input()
@coerceBoolean()
importExport = false;
@Input()
exportFileName = 'form';
booleanPropertyIds: string[] = []; booleanPropertyIds: string[] = [];
propertiesFormGroup: UntypedFormGroup; propertiesFormGroup: UntypedFormGroup;
@ -103,7 +112,9 @@ export class DynamicFormPropertiesComponent implements ControlValueAccessor, OnI
constructor(private fb: UntypedFormBuilder, constructor(private fb: UntypedFormBuilder,
private destroyRef: DestroyRef, private destroyRef: DestroyRef,
private translate: TranslateService) { private translate: TranslateService,
private importExportService: ImportExportService,
private dialogService: DialogService) {
} }
ngOnInit() { ngOnInit() {
@ -114,10 +125,7 @@ export class DynamicFormPropertiesComponent implements ControlValueAccessor, OnI
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
).subscribe( ).subscribe(
() => { () => {
let properties: FormProperty[] = this.propertiesFormGroup.get('properties').value; const properties = this.getProperties();
if (properties) {
properties = properties.filter(p => propertyValid(p));
}
this.booleanPropertyIds = properties.filter(p => p.type === FormPropertyType.switch).map(p => p.id); this.booleanPropertyIds = properties.filter(p => p.type === FormPropertyType.switch).map(p => p.id);
properties.forEach((p, i) => { properties.forEach((p, i) => {
if (p.disableOnProperty && !this.booleanPropertyIds.includes(p.disableOnProperty)) { if (p.disableOnProperty && !this.booleanPropertyIds.includes(p.disableOnProperty)) {
@ -222,6 +230,38 @@ export class DynamicFormPropertiesComponent implements ControlValueAccessor, OnI
}); });
} }
export($event: Event) {
if ($event) {
$event.stopPropagation();
}
const properties = this.getProperties();
this.importExportService.exportFormProperties(properties, this.exportFileName);
}
import($event: Event) {
if ($event) {
$event.stopPropagation();
}
this.importExportService.importFormProperties().subscribe((properties) => {
if (properties) {
this.propertiesFormGroup.setControl('properties', this.preparePropertiesFormArray(properties), {emitEvent: true});
}
});
}
clear($event: Event) {
if ($event) {
$event.stopPropagation();
}
this.dialogService.confirm(this.translate.instant('dynamic-form.clear-form'),
this.translate.instant('dynamic-form.clear-form-prompt'), null, this.translate.instant('action.clear'))
.subscribe((clear) => {
if (clear) {
(this.propertiesFormGroup.get('properties') as UntypedFormArray).clear({emitEvent: true});
}
});
}
private preparePropertiesFormArray(properties: FormProperty[] | undefined): UntypedFormArray { private preparePropertiesFormArray(properties: FormProperty[] | undefined): UntypedFormArray {
const propertiesControls: Array<AbstractControl> = []; const propertiesControls: Array<AbstractControl> = [];
if (properties) { if (properties) {
@ -231,4 +271,12 @@ export class DynamicFormPropertiesComponent implements ControlValueAccessor, OnI
} }
return this.fb.array(propertiesControls); return this.fb.array(propertiesControls);
} }
private getProperties(): FormProperty[] {
let properties: FormProperty[] = this.propertiesFormGroup.get('properties').value;
if (properties) {
properties = properties.filter(p => propertyValid(p));
}
return properties;
}
} }

View File

@ -267,6 +267,22 @@
{{ 'dynamic-form.property.allow-empty-select-option' | translate }} {{ 'dynamic-form.property.allow-empty-select-option' | translate }}
</mat-slide-toggle> </mat-slide-toggle>
</div> </div>
<div *ngIf="propertyItemType === FormPropertyType.select && propertyFormGroup.get('multiple').value" class="tb-form-row space-between">
<div translate>dynamic-form.property.selected-options-limit</div>
<div class="flex flex-1 flex-row items-center justify-end gap-2">
<div class="tb-small-label" translate>dynamic-form.property.min</div>
<mat-form-field appearance="outline" class="number flex" subscriptSizing="dynamic">
<input matInput formControlName="minItems"
type="number" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<mat-divider vertical></mat-divider>
<div class="tb-small-label" translate>dynamic-form.property.max</div>
<mat-form-field appearance="outline" class="number flex" subscriptSizing="dynamic">
<input matInput formControlName="maxItems"
type="number" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div>
<tb-dynamic-form-select-items <tb-dynamic-form-select-items
formControlName="items"> formControlName="items">
</tb-dynamic-form-select-items> </tb-dynamic-form-select-items>

View File

@ -124,6 +124,8 @@ export class DynamicFormPropertyPanelComponent implements OnInit {
properties: [this.property.properties, []], properties: [this.property.properties, []],
multiple: [this.property.multiple, []], multiple: [this.property.multiple, []],
allowEmptyOption: [this.property.allowEmptyOption, []], allowEmptyOption: [this.property.allowEmptyOption, []],
minItems: [this.property.minItems, []],
maxItems: [this.property.maxItems, []],
items: [this.property.items, []], items: [this.property.items, []],
helpId: [this.property.helpId, []], helpId: [this.property.helpId, []],
direction: [this.property.direction || 'column', []], direction: [this.property.direction || 'column', []],
@ -208,13 +210,19 @@ export class DynamicFormPropertyPanelComponent implements OnInit {
const multiple: boolean = this.propertyFormGroup.get('multiple').value; const multiple: boolean = this.propertyFormGroup.get('multiple').value;
if (multiple) { if (multiple) {
this.propertyFormGroup.get('allowEmptyOption').disable({emitEvent: false}); this.propertyFormGroup.get('allowEmptyOption').disable({emitEvent: false});
this.propertyFormGroup.get('minItems').enable({emitEvent: false});
this.propertyFormGroup.get('maxItems').enable({emitEvent: false});
} else { } else {
this.propertyFormGroup.get('allowEmptyOption').enable({emitEvent: false}); this.propertyFormGroup.get('allowEmptyOption').enable({emitEvent: false});
this.propertyFormGroup.get('minItems').disable({emitEvent: false});
this.propertyFormGroup.get('maxItems').disable({emitEvent: false});
} }
} }
} else { } else {
this.propertyFormGroup.get('multiple').disable({emitEvent: false}); this.propertyFormGroup.get('multiple').disable({emitEvent: false});
this.propertyFormGroup.get('allowEmptyOption').disable({emitEvent: false}); this.propertyFormGroup.get('allowEmptyOption').disable({emitEvent: false});
this.propertyFormGroup.get('minItems').disable({emitEvent: false});
this.propertyFormGroup.get('maxItems').disable({emitEvent: false});
this.propertyFormGroup.get('items').disable({emitEvent: false}); this.propertyFormGroup.get('items').disable({emitEvent: false});
} }
if (type === FormPropertyType.datetime) { if (type === FormPropertyType.datetime) {
@ -290,7 +298,9 @@ export class DynamicFormPropertyPanelComponent implements OnInit {
this.propertyFormGroup.get('default').patchValue(newVal, {emitEvent: false}); this.propertyFormGroup.get('default').patchValue(newVal, {emitEvent: false});
} }
this.propertyFormGroup.get('allowEmptyOption').patchValue(false, {emitEvent: false}); this.propertyFormGroup.get('allowEmptyOption').patchValue(false, {emitEvent: false});
this.propertyFormGroup.get('allowEmptyOption').disable({emitEvent: false}); this.propertyFormGroup.get('allowEmptyOption').disable({emitEvent: false})
this.propertyFormGroup.get('minItems').enable({emitEvent: false});
this.propertyFormGroup.get('maxItems').enable({emitEvent: false});
} else { } else {
if (defaultValue && Array.isArray(defaultValue)) { if (defaultValue && Array.isArray(defaultValue)) {
const newVal = defaultValue.length ? defaultValue[0] : null; const newVal = defaultValue.length ? defaultValue[0] : null;
@ -299,6 +309,8 @@ export class DynamicFormPropertyPanelComponent implements OnInit {
}); });
} }
this.propertyFormGroup.get('allowEmptyOption').enable({emitEvent: false}); this.propertyFormGroup.get('allowEmptyOption').enable({emitEvent: false});
this.propertyFormGroup.get('minItems').disable({emitEvent: false});
this.propertyFormGroup.get('maxItems').disable({emitEvent: false});
} }
} }
} }

View File

@ -88,7 +88,7 @@
</ng-template> </ng-template>
<ng-template #propertyRowTpl let-propertyRow="propertyRow"> <ng-template #propertyRowTpl let-propertyRow="propertyRow">
<div [formGroup]="propertiesFormGroup" class="tb-form-row space-between flex-wrap overflow-auto" [class]="propertyRow.rowClass"> <div [formGroup]="propertiesFormGroup" class="tb-form-row space-between overflow-auto" [class]="propertyRow.rowClass">
<mat-slide-toggle *ngIf="propertyRow.switch && propertyRow.switch.visible" <mat-slide-toggle *ngIf="propertyRow.switch && propertyRow.switch.visible"
class="mat-slide fixed-title-width margin" formControlName="{{ propertyRow.switch.id }}"> class="mat-slide fixed-title-width margin" formControlName="{{ propertyRow.switch.id }}">
{{ propertyRow.label | customTranslate }} {{ propertyRow.label | customTranslate }}

View File

@ -215,6 +215,16 @@ export class DynamicFormComponent implements OnInit, OnChanges, ControlValueAcce
validators.push(Validators.max(property.max)); validators.push(Validators.max(property.max));
} }
} }
if (property.type === FormPropertyType.select) {
if (property.multiple) {
if (isDefinedAndNotNull(property.minItems)) {
validators.push(Validators.minLength(property.minItems));
}
if (isDefinedAndNotNull(property.maxItems)) {
validators.push(Validators.maxLength(property.maxItems));
}
}
}
this.propertiesFormGroup.addControl(property.id, this.fb.control(null, validators), {emitEvent: false}); this.propertiesFormGroup.addControl(property.id, this.fb.control(null, validators), {emitEvent: false});
} }
} }

View File

@ -107,6 +107,8 @@
</div> </div>
<div [class.!hidden]="selectedOption !== 'properties'" class="mat-content overflow-hidden"> <div [class.!hidden]="selectedOption !== 'properties'" class="mat-content overflow-hidden">
<tb-dynamic-form-properties <tb-dynamic-form-properties
importExport
[exportFileName]="(modelValue?.title || 'scada-symbol') + '-properties'"
formControlName="properties"> formControlName="properties">
</tb-dynamic-form-properties> </tb-dynamic-form-properties>
</div> </div>

View File

@ -90,7 +90,7 @@ export class ScadaSymbolMetadataComponent extends PageComponent implements OnIni
@Input() @Input()
tags: string[]; tags: string[];
private modelValue: ScadaSymbolMetadata; modelValue: ScadaSymbolMetadata;
private propagateChange = null; private propagateChange = null;

View File

@ -188,9 +188,11 @@
</div> </div>
<div #topRightPanel class="tb-split tb-content"> <div #topRightPanel class="tb-split tb-content">
<mat-tab-group mat-stretch-tabs="false" dynamicHeight="true" style="width: 100%; height: 100%;"> <mat-tab-group mat-stretch-tabs="false" dynamicHeight="true" style="width: 100%; height: 100%;">
<mat-tab label="{{ 'widget.settings-form-properties' | translate }}"> <mat-tab label="{{ 'widget.settings-form' | translate }}">
<div class="tb-resize-container" style="background-color: #fff;"> <div class="tb-resize-container" style="background-color: #fff;">
<tb-dynamic-form-properties <tb-dynamic-form-properties
importExport
[exportFileName]="(widget.widgetName || 'widget') + '-settings-form'"
[(ngModel)]="widget.settingsForm" [(ngModel)]="widget.settingsForm"
(ngModelChange)="settingsFormUpdated()" (ngModelChange)="settingsFormUpdated()"
noBorder fillHeight> noBorder fillHeight>

View File

@ -87,6 +87,7 @@ import {
ExportResourceDialogData, ExportResourceDialogData,
ExportResourceDialogDialogResult ExportResourceDialogDialogResult
} from '@shared/import-export/export-resource-dialog.component'; } from '@shared/import-export/export-resource-dialog.component';
import { FormProperty, propertyValid } from '@shared/models/dynamic-form.models';
export type editMissingAliasesFunction = (widgets: Array<Widget>, isSingleWidget: boolean, export type editMissingAliasesFunction = (widgets: Array<Widget>, isSingleWidget: boolean,
customTitle: string, missingEntityAliases: EntityAliases) => Observable<EntityAliases>; customTitle: string, missingEntityAliases: EntityAliases) => Observable<EntityAliases>;
@ -119,6 +120,26 @@ export class ImportExportService {
} }
public exportFormProperties(properties: FormProperty[], fileName: string) {
this.exportToPc(properties, fileName);
}
public importFormProperties(): Observable<FormProperty[]> {
return this.openImportDialog('dynamic-form.import-form', 'dynamic-form.form-json-file').pipe(
map((properties: FormProperty[]) => {
if (!this.validateImportedFormProperties(properties)) {
this.store.dispatch(new ActionNotificationShow(
{message: this.translate.instant('dynamic-form.invalid-form-json-file-error'),
type: 'error'}));
throw new Error('Invalid form JSON file');
} else {
return properties;
}
}),
catchError(() => of(null))
);
}
public exportImage(type: ImageResourceType, key: string) { public exportImage(type: ImageResourceType, key: string) {
this.imageService.exportImage(type, key).subscribe( this.imageService.exportImage(type, key).subscribe(
{ {
@ -927,6 +948,14 @@ export class ImportExportService {
type: 'error'})); type: 'error'}));
} }
private validateImportedFormProperties(properties: FormProperty[]): boolean {
if (!properties.length) {
return false;
} else {
return !properties.some(p => !propertyValid(p));
}
}
private validateImportedImage(image: ImageExportData): boolean { private validateImportedImage(image: ImageExportData): boolean {
return !(!isNotEmptyStr(image.data) return !(!isNotEmptyStr(image.data)
|| !isNotEmptyStr(image.title) || !isNotEmptyStr(image.title)

View File

@ -132,6 +132,8 @@ export interface FormSelectProperty extends FormPropertyBase {
multiple?: boolean; multiple?: boolean;
allowEmptyOption?: boolean; allowEmptyOption?: boolean;
items?: FormSelectItem[]; items?: FormSelectItem[];
minItems?: number;
maxItems?: number;
} }
export type FormPropertyDirection = 'row' | 'column'; export type FormPropertyDirection = 'row' | 'column';
@ -311,6 +313,11 @@ const toPropertyContainers = (properties: FormProperty[],
visible: true visible: true
}; };
result.push(propertyRow); result.push(propertyRow);
const rowClasses = (propertyRow.rowClass || '').split(' ').filter(cls => cls.trim().length > 0);
if (!rowClasses.includes('flex-wrap')) {
rowClasses.push('flex-wrap');
}
propertyRow.rowClass = rowClasses.join(' ');
} }
if (property.type === FormPropertyType.switch) { if (property.type === FormPropertyType.switch) {
propertyRow.switch = property; propertyRow.switch = property;
@ -330,10 +337,18 @@ const toPropertyContainers = (properties: FormProperty[],
delete container.properties; delete container.properties;
delete container.rowClass; delete container.rowClass;
} else { } else {
if (!container.rowClass) { container.propertiesRowClass = 'gt-xs:align-center xs:flex-col gt-xs:flex-row gt-xs:justify-end';
container.rowClass = 'column-xs'; const rowClasses = (container.rowClass || '').split(' ').filter(cls => cls.trim().length > 0);
container.propertiesRowClass = 'gt-xs:align-center xs:flex-col gt-xs:flex-row gt-xs:justify-end'; if (!rowClasses.includes('column-xs')) {
rowClasses.push('column-xs');
} }
if (property.fieldClass && property.fieldClass.split(' ').includes('flex')) {
container.propertiesRowClass += ' overflow-hidden';
if (rowClasses.includes('flex-wrap')) {
rowClasses.splice(rowClasses.indexOf('flex-wrap'), 1);
}
}
container.rowClass = rowClasses.join(' ');
} }
} }
} }
@ -600,7 +615,7 @@ const jsonFormDataToProperty = (form: JsonFormData, level: number, groupTitle?:
name: form.title || id, name: form.title || id,
group: groupTitle, group: groupTitle,
type: null, type: null,
default: form.schema?.default || null, default: (isDefinedAndNotNull(form.default) ? form.default : form.schema?.default) || null,
required: isDefinedAndNotNull(form.required) ? form.required : false required: isDefinedAndNotNull(form.required) ? form.required : false
}; };
if (form.condition?.length) { if (form.condition?.length) {
@ -635,6 +650,14 @@ const jsonFormDataToProperty = (form: JsonFormData, level: number, groupTitle?:
property.multiple = form.multiple; property.multiple = form.multiple;
property.fieldClass = 'flex'; property.fieldClass = 'flex';
property.allowEmptyOption = isDefinedAndNotNull(form.allowClear) ? form.allowClear : false; property.allowEmptyOption = isDefinedAndNotNull(form.allowClear) ? form.allowClear : false;
if (property.multiple) {
if (typeof (form.schema as any)?.minItems === 'number') {
property.minItems = (form.schema as any).minItems;
}
if (typeof (form.schema as any)?.maxItems === 'number') {
property.maxItems = (form.schema as any).maxItems;
}
}
break; break;
case 'select': case 'select':
property.type = FormPropertyType.select; property.type = FormPropertyType.select;
@ -699,17 +722,25 @@ const jsonFormDataToProperty = (form: JsonFormData, level: number, groupTitle?:
break; break;
case 'array': case 'array':
if (form.items?.length) { if (form.items?.length) {
const item: JsonFormData = form.items[0] as JsonFormData; const arrayItemSchema = form.schema.items;
if (item.type !== 'array') { if (arrayItemSchema && arrayItemSchema.type && arrayItemSchema.type !== 'array') {
const arrayProperty = jsonFormDataToProperty(item, 0); if (arrayItemSchema.type === 'object') {
arrayProperty.arrayItemType = arrayProperty.type; property.arrayItemType = FormPropertyType.fieldset;
arrayProperty.arrayItemName = arrayProperty.name; property.arrayItemName = '';
arrayProperty.id = property.id; property.properties = form.items ? (form.items as JsonFormData[]).map(item =>
arrayProperty.name = property.name; jsonFormDataToProperty(item, level+2)).filter(p => p !== null) : [];
arrayProperty.group = property.group; } else {
arrayProperty.condition = property.condition; const item: JsonFormData = form.items[0] as JsonFormData;
arrayProperty.required = property.required; const arrayProperty = jsonFormDataToProperty(item, 0);
property = arrayProperty; arrayProperty.arrayItemType = arrayProperty.type;
arrayProperty.arrayItemName = arrayProperty.name;
arrayProperty.id = property.id;
arrayProperty.name = property.name;
arrayProperty.group = property.group;
arrayProperty.condition = property.condition;
arrayProperty.required = property.required;
property = arrayProperty;
}
property.type = FormPropertyType.array; property.type = FormPropertyType.array;
} }
} }

View File

@ -1657,6 +1657,7 @@
"min": "Min", "min": "Min",
"max": "Max", "max": "Max",
"step": "Step", "step": "Step",
"selected-options-limit": "Selected options limit",
"advanced-ui-settings": "Advanced UI settings", "advanced-ui-settings": "Advanced UI settings",
"disable-on-property": "Disable on property", "disable-on-property": "Disable on property",
"display-condition-function": "Display condition function", "display-condition-function": "Display condition function",
@ -1693,7 +1694,13 @@
"item-type": "Item type", "item-type": "Item type",
"item-name": "Item name", "item-name": "Item name",
"no-items": "No items" "no-items": "No items"
} },
"clear-form": "Clear form",
"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",
"invalid-form-json-file-error": "Unable to import form from JSON: Invalid form JSON data structure."
}, },
"asset-profile": { "asset-profile": {
"asset-profile": "Asset profile", "asset-profile": "Asset profile",
@ -5473,7 +5480,7 @@
"html": "HTML", "html": "HTML",
"tidy": "Tidy", "tidy": "Tidy",
"css": "CSS", "css": "CSS",
"settings-form-properties": "Settings form properties", "settings-form": "Settings form",
"settings-schema": "Settings schema", "settings-schema": "Settings schema",
"datakey-settings-schema": "Data key settings schema", "datakey-settings-schema": "Data key settings schema",
"latest-datakey-settings-schema": "Latest data key settings schema", "latest-datakey-settings-schema": "Latest data key settings schema",

View File

@ -223,7 +223,7 @@
} }
.mat-divider-vertical { .mat-divider-vertical {
height: 56px; height: 56px;
margin-top: -7px; margin-top: -9px;
margin-bottom: -7px; margin-bottom: -7px;
} }
.mat-mdc-form-field, tb-unit-input { .mat-mdc-form-field, tb-unit-input {