UI: Import/Export support for dynamic form properties. Improve json schema form to dynamic form conversion.
This commit is contained in:
parent
9fe5f7199e
commit
5840bf1f67
@ -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"
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -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});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -90,7 +90,7 @@ export class ScadaSymbolMetadataComponent extends PageComponent implements OnIni
|
||||
@Input()
|
||||
tags: string[];
|
||||
|
||||
private modelValue: ScadaSymbolMetadata;
|
||||
modelValue: ScadaSymbolMetadata;
|
||||
|
||||
private propagateChange = null;
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user