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-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-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 *ngIf="propertiesFormArray().controls.length; else noProperties" class="tb-form-table-body tb-drop-list"
cdkDropList cdkDropListOrientation="vertical"

View File

@ -43,6 +43,8 @@ import {
} from '@home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-row.component';
import { coerceBoolean } from '@shared/decorators/coercion';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ImportExportService } from '@shared/import-export/import-export.service';
import { DialogService } from '@core/services/dialog.service';
@Component({
selector: 'tb-dynamic-form-properties',
@ -89,6 +91,13 @@ export class DynamicFormPropertiesComponent implements ControlValueAccessor, OnI
@coerceBoolean()
fillHeight = false;
@Input()
@coerceBoolean()
importExport = false;
@Input()
exportFileName = 'form';
booleanPropertyIds: string[] = [];
propertiesFormGroup: UntypedFormGroup;
@ -103,7 +112,9 @@ export class DynamicFormPropertiesComponent implements ControlValueAccessor, OnI
constructor(private fb: UntypedFormBuilder,
private destroyRef: DestroyRef,
private translate: TranslateService) {
private translate: TranslateService,
private importExportService: ImportExportService,
private dialogService: DialogService) {
}
ngOnInit() {
@ -114,10 +125,7 @@ export class DynamicFormPropertiesComponent implements ControlValueAccessor, OnI
takeUntilDestroyed(this.destroyRef)
).subscribe(
() => {
let properties: FormProperty[] = this.propertiesFormGroup.get('properties').value;
if (properties) {
properties = properties.filter(p => propertyValid(p));
}
const properties = this.getProperties();
this.booleanPropertyIds = properties.filter(p => p.type === FormPropertyType.switch).map(p => p.id);
properties.forEach((p, i) => {
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 {
const propertiesControls: Array<AbstractControl> = [];
if (properties) {
@ -231,4 +271,12 @@ export class DynamicFormPropertiesComponent implements ControlValueAccessor, OnI
}
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 }}
</mat-slide-toggle>
</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
formControlName="items">
</tb-dynamic-form-select-items>

View File

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

View File

@ -88,7 +88,7 @@
</ng-template>
<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"
class="mat-slide fixed-title-width margin" formControlName="{{ propertyRow.switch.id }}">
{{ propertyRow.label | customTranslate }}

View File

@ -215,6 +215,16 @@ export class DynamicFormComponent implements OnInit, OnChanges, ControlValueAcce
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});
}
}

View File

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

View File

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

View File

@ -188,9 +188,11 @@
</div>
<div #topRightPanel class="tb-split tb-content">
<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;">
<tb-dynamic-form-properties
importExport
[exportFileName]="(widget.widgetName || 'widget') + '-settings-form'"
[(ngModel)]="widget.settingsForm"
(ngModelChange)="settingsFormUpdated()"
noBorder fillHeight>

View File

@ -87,6 +87,7 @@ import {
ExportResourceDialogData,
ExportResourceDialogDialogResult
} 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,
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) {
this.imageService.exportImage(type, key).subscribe(
{
@ -927,6 +948,14 @@ export class ImportExportService {
type: 'error'}));
}
private validateImportedFormProperties(properties: FormProperty[]): boolean {
if (!properties.length) {
return false;
} else {
return !properties.some(p => !propertyValid(p));
}
}
private validateImportedImage(image: ImageExportData): boolean {
return !(!isNotEmptyStr(image.data)
|| !isNotEmptyStr(image.title)

View File

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

View File

@ -1657,6 +1657,7 @@
"min": "Min",
"max": "Max",
"step": "Step",
"selected-options-limit": "Selected options limit",
"advanced-ui-settings": "Advanced UI settings",
"disable-on-property": "Disable on property",
"display-condition-function": "Display condition function",
@ -1693,7 +1694,13 @@
"item-type": "Item type",
"item-name": "Item name",
"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",
@ -5473,7 +5480,7 @@
"html": "HTML",
"tidy": "Tidy",
"css": "CSS",
"settings-form-properties": "Settings form properties",
"settings-form": "Settings form",
"settings-schema": "Settings schema",
"datakey-settings-schema": "Data key settings schema",
"latest-datakey-settings-schema": "Latest data key settings schema",

View File

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