44 changed files with 2772 additions and 1297 deletions
@ -0,0 +1,212 @@ |
|||
///
|
|||
/// Copyright © 2016-2024 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { Component, DestroyRef, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; |
|||
import { TbPopoverComponent } from '@shared/components/popover.component'; |
|||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; |
|||
import { ValueType } from '@shared/models/constants'; |
|||
import { |
|||
FormProperty, |
|||
formPropertyFieldClasses, |
|||
formPropertyRowClasses, |
|||
FormPropertyType, |
|||
formPropertyTypes, |
|||
formPropertyTypeTranslations, |
|||
FormSelectItem |
|||
} from '@shared/models/dynamic-form.models'; |
|||
import { |
|||
defaultPropertyValue |
|||
} from '@home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-row.component'; |
|||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-dynamic-form-property-panel', |
|||
templateUrl: './dynamic-form-property-panel.component.html', |
|||
styleUrls: ['./dynamic-form-property-panel.component.scss'], |
|||
encapsulation: ViewEncapsulation.None |
|||
}) |
|||
export class DynamicFormPropertyPanelComponent implements OnInit { |
|||
|
|||
ValueType = ValueType; |
|||
|
|||
FormPropertyType = FormPropertyType; |
|||
|
|||
formPropertyTypes = formPropertyTypes; |
|||
formPropertyTypeTranslations = formPropertyTypeTranslations; |
|||
|
|||
formPropertyRowClasses = formPropertyRowClasses; |
|||
|
|||
formPropertyFieldClasses = formPropertyFieldClasses; |
|||
|
|||
@Input() |
|||
isAdd = false; |
|||
|
|||
@Input() |
|||
property: FormProperty; |
|||
|
|||
@Input() |
|||
booleanPropertyIds: string[]; |
|||
|
|||
@Input() |
|||
disabled: boolean; |
|||
|
|||
@Input() |
|||
popover: TbPopoverComponent<DynamicFormPropertyPanelComponent>; |
|||
|
|||
@Output() |
|||
propertySettingsApplied = new EventEmitter<FormProperty>(); |
|||
|
|||
panelTitle: string; |
|||
|
|||
propertyFormGroup: UntypedFormGroup; |
|||
|
|||
private propertyType: FormPropertyType; |
|||
|
|||
constructor(private fb: UntypedFormBuilder, |
|||
private destroyRef: DestroyRef) { |
|||
} |
|||
|
|||
ngOnInit(): void { |
|||
this.panelTitle = this.isAdd ? 'dynamic-form.property.add-property' : 'dynamic-form.property.property-settings'; |
|||
this.propertyType = this.property.type; |
|||
this.propertyFormGroup = this.fb.group( |
|||
{ |
|||
id: [this.property.id, [Validators.required]], |
|||
name: [this.property.name, [Validators.required]], |
|||
group: [this.property.group, []], |
|||
type: [this.property.type, [Validators.required]], |
|||
default: [this.property.default, []], |
|||
required: [this.property.required, []], |
|||
subLabel: [this.property.subLabel, []], |
|||
divider: [this.property.divider, []], |
|||
fieldSuffix: [this.property.fieldSuffix, []], |
|||
disableOnProperty: [this.property.disableOnProperty, []], |
|||
condition: [this.property.condition, []], |
|||
rowClass: [(this.property.rowClass || '').split(' '), []], |
|||
fieldClass: [(this.property.fieldClass || '').split(' '), []], |
|||
min: [this.property.min, []], |
|||
max: [this.property.max, []], |
|||
step: [this.property.step, [Validators.min(0)]], |
|||
properties: [this.property.properties, []], |
|||
multiple: [this.property.multiple, []], |
|||
items: [this.property.items, []] |
|||
} |
|||
); |
|||
if (this.disabled) { |
|||
this.propertyFormGroup.disable({emitEvent: false}); |
|||
} else { |
|||
this.propertyFormGroup.get('type').valueChanges.pipe( |
|||
takeUntilDestroyed(this.destroyRef) |
|||
).subscribe(() => { |
|||
this.updateValidators(); |
|||
}); |
|||
|
|||
this.propertyFormGroup.get('items').valueChanges.pipe( |
|||
takeUntilDestroyed(this.destroyRef) |
|||
).subscribe(() => { |
|||
this.onSelectItemsChange(); |
|||
}); |
|||
this.propertyFormGroup.get('multiple').valueChanges.pipe( |
|||
takeUntilDestroyed(this.destroyRef) |
|||
).subscribe(() => { |
|||
this.onMultipleSelectChange(); |
|||
}); |
|||
this.updateValidators(); |
|||
} |
|||
} |
|||
|
|||
cancel() { |
|||
this.popover?.hide(); |
|||
} |
|||
|
|||
applyPropertySettings() { |
|||
const property = this.propertyFormGroup.getRawValue(); |
|||
property.rowClass = (property.rowClass || []).join(' '); |
|||
property.fieldClass = (property.fieldClass || []).join(' '); |
|||
this.propertySettingsApplied.emit(property); |
|||
} |
|||
|
|||
private updateValidators() { |
|||
const type: FormPropertyType = this.propertyFormGroup.get('type').value; |
|||
if (type === FormPropertyType.number) { |
|||
this.propertyFormGroup.get('min').enable({emitEvent: false}); |
|||
this.propertyFormGroup.get('max').enable({emitEvent: false}); |
|||
this.propertyFormGroup.get('step').enable({emitEvent: false}); |
|||
} else { |
|||
this.propertyFormGroup.get('min').disable({emitEvent: false}); |
|||
this.propertyFormGroup.get('max').disable({emitEvent: false}); |
|||
this.propertyFormGroup.get('step').disable({emitEvent: false}); |
|||
} |
|||
if (type === FormPropertyType.fieldset) { |
|||
this.propertyFormGroup.get('default').disable({emitEvent: false}); |
|||
this.propertyFormGroup.get('properties').enable({emitEvent: false}); |
|||
} else { |
|||
this.propertyFormGroup.get('default').enable({emitEvent: false}); |
|||
this.propertyFormGroup.get('properties').disable({emitEvent: false}); |
|||
} |
|||
if (type === FormPropertyType.select) { |
|||
this.propertyFormGroup.get('multiple').enable({emitEvent: false}); |
|||
this.propertyFormGroup.get('items').enable({emitEvent: false}); |
|||
} else { |
|||
this.propertyFormGroup.get('multiple').disable({emitEvent: false}); |
|||
this.propertyFormGroup.get('items').disable({emitEvent: false}); |
|||
} |
|||
if (this.propertyType !== type) { |
|||
const defaultValue = defaultPropertyValue(type); |
|||
this.propertyFormGroup.get('default').patchValue(defaultValue, {emitEvent: false}); |
|||
this.propertyType = type; |
|||
} |
|||
} |
|||
|
|||
private onSelectItemsChange() { |
|||
const type: FormPropertyType = this.propertyFormGroup.get('type').value; |
|||
const multiple: boolean = this.propertyFormGroup.get('multiple').value; |
|||
const defaultValue: any = this.propertyFormGroup.get('default').value; |
|||
const items: FormSelectItem[] = this.propertyFormGroup.get('items').value; |
|||
if (defaultValue && type === FormPropertyType.select) { |
|||
if (multiple) { |
|||
let targetValue: any[] = defaultValue; |
|||
targetValue = targetValue.filter(valItem => !!items.find(item => item.value === valItem)); |
|||
this.propertyFormGroup.get('default').patchValue(targetValue, {emitEvent: false}); |
|||
} else { |
|||
if (!items.find(item => item.value === defaultValue)) { |
|||
this.propertyFormGroup.get('default').patchValue(null, {emitEvent: false}); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private onMultipleSelectChange() { |
|||
const type: FormPropertyType = this.propertyFormGroup.get('type').value; |
|||
const multiple: boolean = this.propertyFormGroup.get('multiple').value; |
|||
const defaultValue: any = this.propertyFormGroup.get('default').value; |
|||
if (type === FormPropertyType.select) { |
|||
if (multiple) { |
|||
if (defaultValue && !Array.isArray(defaultValue)) { |
|||
const newVal = [defaultValue]; |
|||
this.propertyFormGroup.get('default').patchValue(newVal, {emitEvent: false}); |
|||
} |
|||
} else { |
|||
if (defaultValue && Array.isArray(defaultValue)) { |
|||
const newVal = defaultValue.length ? defaultValue[0] : null; |
|||
setTimeout(() => { |
|||
this.propertyFormGroup.get('default').patchValue(newVal, {emitEvent: false}); |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,37 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2024 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
<div [formGroup]="selectItemRowFormGroup" class="tb-form-table-row tb-dynamic-form-select-item-row"> |
|||
<tb-value-input class="tb-value-field" |
|||
shortBooleanField |
|||
[allowedValueTypes]="[ValueType.STRING, ValueType.INTEGER, ValueType.DOUBLE, ValueType.BOOLEAN]" |
|||
formControlName="value"> |
|||
</tb-value-input> |
|||
<mat-form-field class="tb-inline-field tb-label-field" appearance="outline" subscriptSizing="dynamic"> |
|||
<input required matInput formControlName="label" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
</mat-form-field> |
|||
<div class="tb-form-table-row-cell-buttons"> |
|||
<button *ngIf="!disabled" |
|||
type="button" |
|||
mat-icon-button |
|||
(click)="selectItemRemoved.emit()" |
|||
matTooltip="{{ 'dynamic-form.property.remove-option' | translate }}" |
|||
matTooltipPosition="above"> |
|||
<mat-icon>delete</mat-icon> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,23 @@ |
|||
/** |
|||
* Copyright © 2016-2024 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0 |
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
.tb-dynamic-form-select-item-row { |
|||
.tb-value-field { |
|||
flex: 1 1 70%; |
|||
} |
|||
.tb-label-field { |
|||
flex: 1 1 30%; |
|||
} |
|||
} |
|||
@ -0,0 +1,172 @@ |
|||
///
|
|||
/// Copyright © 2016-2024 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { |
|||
ChangeDetectorRef, |
|||
Component, DestroyRef, |
|||
EventEmitter, |
|||
forwardRef, |
|||
Input, |
|||
OnInit, |
|||
Output, |
|||
ViewEncapsulation |
|||
} from '@angular/core'; |
|||
import { |
|||
ControlValueAccessor, |
|||
NG_VALIDATORS, |
|||
NG_VALUE_ACCESSOR, |
|||
UntypedFormBuilder, |
|||
UntypedFormControl, |
|||
UntypedFormGroup, |
|||
Validator, |
|||
ValidatorFn, |
|||
Validators |
|||
} from '@angular/forms'; |
|||
import { isDefinedAndNotNull } from '@core/utils'; |
|||
import { FormSelectItem } from '@shared/models/dynamic-form.models'; |
|||
import { |
|||
DynamicFormSelectItemsComponent |
|||
} from '@home/components/widget/lib/settings/common/dynamic-form/dynamic-form-select-items.component'; |
|||
import { TimeSeriesChartStateSourceType } from '@home/components/widget/lib/chart/time-series-chart.models'; |
|||
import { ValueType } from '@shared/models/constants'; |
|||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
|||
|
|||
export const selectItemValid = (item: FormSelectItem): boolean => isDefinedAndNotNull(item.value) && !!item.label; |
|||
|
|||
@Component({ |
|||
selector: 'tb-dynamic-form-select-item-row', |
|||
templateUrl: './dynamic-form-select-item-row.component.html', |
|||
styleUrls: ['./dynamic-form-select-item-row.component.scss'], |
|||
providers: [ |
|||
{ |
|||
provide: NG_VALUE_ACCESSOR, |
|||
useExisting: forwardRef(() => DynamicFormSelectItemRowComponent), |
|||
multi: true |
|||
}, |
|||
{ |
|||
provide: NG_VALIDATORS, |
|||
useExisting: forwardRef(() => DynamicFormSelectItemRowComponent), |
|||
multi: true |
|||
} |
|||
], |
|||
encapsulation: ViewEncapsulation.None |
|||
}) |
|||
export class DynamicFormSelectItemRowComponent implements ControlValueAccessor, OnInit, Validator { |
|||
|
|||
ValueType = ValueType; |
|||
|
|||
@Input() |
|||
disabled: boolean; |
|||
|
|||
@Input() |
|||
index: number; |
|||
|
|||
@Output() |
|||
selectItemRemoved = new EventEmitter(); |
|||
|
|||
selectItemRowFormGroup: UntypedFormGroup; |
|||
|
|||
modelValue: FormSelectItem; |
|||
|
|||
private propagateChange = (_val: any) => {}; |
|||
|
|||
constructor(private fb: UntypedFormBuilder, |
|||
private destroyRef: DestroyRef, |
|||
private cd: ChangeDetectorRef, |
|||
private selectItemsComponent: DynamicFormSelectItemsComponent) { |
|||
} |
|||
|
|||
ngOnInit() { |
|||
this.selectItemRowFormGroup = this.fb.group({ |
|||
value: [null, [this.selectItemValueValidator()]], |
|||
label: [null, [Validators.required]] |
|||
}); |
|||
this.selectItemRowFormGroup.valueChanges.pipe( |
|||
takeUntilDestroyed(this.destroyRef) |
|||
).subscribe( |
|||
() => this.updateModel() |
|||
); |
|||
} |
|||
|
|||
registerOnChange(fn: any): void { |
|||
this.propagateChange = fn; |
|||
} |
|||
|
|||
registerOnTouched(fn: any): void { |
|||
} |
|||
|
|||
setDisabledState(isDisabled: boolean): void { |
|||
this.disabled = isDisabled; |
|||
if (isDisabled) { |
|||
this.selectItemRowFormGroup.disable({emitEvent: false}); |
|||
} else { |
|||
this.selectItemRowFormGroup.enable({emitEvent: false}); |
|||
} |
|||
} |
|||
|
|||
writeValue(value: FormSelectItem): void { |
|||
this.modelValue = value; |
|||
this.selectItemRowFormGroup.patchValue( |
|||
{ |
|||
value: value?.value, |
|||
label: value?.label |
|||
}, {emitEvent: false} |
|||
); |
|||
this.cd.markForCheck(); |
|||
} |
|||
|
|||
public validate(c: UntypedFormControl) { |
|||
const valueControl = this.selectItemRowFormGroup.get('value'); |
|||
if (valueControl.hasError('itemValueNotUnique')) { |
|||
valueControl.updateValueAndValidity({onlySelf: false, emitEvent: false}); |
|||
} |
|||
if (valueControl.hasError('itemValueNotUnique')) { |
|||
this.selectItemRowFormGroup.get('value').markAsTouched(); |
|||
return { |
|||
itemValueNotUnique: true |
|||
}; |
|||
} |
|||
const item: FormSelectItem = {...this.modelValue, ...this.selectItemRowFormGroup.value}; |
|||
if (!selectItemValid(item)) { |
|||
return { |
|||
selectItem: true |
|||
}; |
|||
} |
|||
return null; |
|||
} |
|||
|
|||
private selectItemValueValidator(): ValidatorFn { |
|||
return control => { |
|||
if (!control.value) { |
|||
return { |
|||
required: true |
|||
}; |
|||
} |
|||
if (!this.selectItemsComponent.selectItemValueUnique(control.value, this.index)) { |
|||
return { |
|||
itemValueNotUnique: true |
|||
}; |
|||
} |
|||
return null; |
|||
}; |
|||
} |
|||
|
|||
private updateModel() { |
|||
const value: FormSelectItem = this.selectItemRowFormGroup.value; |
|||
this.modelValue = {...this.modelValue, ...value}; |
|||
this.propagateChange(this.modelValue); |
|||
} |
|||
} |
|||
@ -0,0 +1,61 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2024 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
<div class="tb-form-panel tb-dynamic-form-select-items no-border no-padding"> |
|||
<div class="tb-form-table"> |
|||
<div class="tb-form-table-header" [class.no-padding-right]="!disabled"> |
|||
<div class="tb-form-table-header-cell tb-value-header" translate>dynamic-form.property.value</div> |
|||
<div class="tb-form-table-header-cell tb-label-header" translate>dynamic-form.property.label</div> |
|||
<div class="tb-form-table-header-cell tb-actions-header" [class.disabled]="disabled"></div> |
|||
</div> |
|||
<div *ngIf="selectItemsFormArray().controls.length; else noSelectItems" class="tb-form-table-body tb-drop-list" |
|||
cdkDropList cdkDropListOrientation="vertical" |
|||
[cdkDropListDisabled]="!dragEnabled" |
|||
(cdkDropListDropped)="selectItemDrop($event)"> |
|||
<div cdkDrag [cdkDragDisabled]="!dragEnabled" |
|||
[class.tb-draggable-form-table-row]="!disabled" |
|||
*ngFor="let selectItemControl of selectItemsFormArray().controls; trackBy: trackBySelectItem; let $index = index;"> |
|||
<tb-dynamic-form-select-item-row class="flex-1" |
|||
[index]="$index" |
|||
[formControl]="selectItemControl" |
|||
(selectItemRemoved)="removeSelectItem($index)"> |
|||
</tb-dynamic-form-select-item-row> |
|||
<div class="tb-form-table-row-cell-buttons"> |
|||
<button mat-icon-button |
|||
*ngIf="!disabled" |
|||
type="button" |
|||
cdkDragHandle |
|||
[class.tb-hidden]="!dragEnabled" |
|||
matTooltip="{{ 'action.drag' | translate }}" |
|||
matTooltipPosition="above"> |
|||
<mat-icon>drag_indicator</mat-icon> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<tb-error *ngIf="errorText" noMargin [error]="errorText" style="padding-left: 12px;"></tb-error> |
|||
</div> |
|||
<div *ngIf="!disabled"> |
|||
<button type="button" mat-stroked-button color="primary" (click)="addSelectItem()"> |
|||
{{ 'dynamic-form.property.add-option' | translate }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<ng-template #noSelectItems> |
|||
<span class="tb-prompt flex items-center justify-center">{{ 'dynamic-form.property.no-options' | translate }}</span> |
|||
</ng-template> |
|||
@ -0,0 +1,43 @@ |
|||
/** |
|||
* Copyright © 2016-2024 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0 |
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
.tb-dynamic-form-select-items { |
|||
flex: 1; |
|||
.tb-form-table-header-cell { |
|||
&.tb-value-header { |
|||
flex: 1 1 70%; |
|||
} |
|||
&.tb-label-header { |
|||
flex: 1 1 30%; |
|||
} |
|||
&.tb-actions-header { |
|||
width: 80px; |
|||
min-width: 80px; |
|||
&.disabled { |
|||
width: 0; |
|||
min-width: 0; |
|||
} |
|||
} |
|||
} |
|||
.tb-form-table { |
|||
overflow: hidden; |
|||
} |
|||
.tb-form-table-body { |
|||
overflow: auto; |
|||
tb-dynamic-form-select-item-row { |
|||
overflow: hidden; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,204 @@ |
|||
///
|
|||
/// Copyright © 2016-2024 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { |
|||
Component, DestroyRef, |
|||
forwardRef, |
|||
HostBinding, |
|||
Input, |
|||
OnInit, |
|||
QueryList, |
|||
ViewChildren, |
|||
ViewEncapsulation |
|||
} from '@angular/core'; |
|||
import { |
|||
AbstractControl, |
|||
ControlValueAccessor, |
|||
NG_VALIDATORS, |
|||
NG_VALUE_ACCESSOR, |
|||
UntypedFormArray, |
|||
UntypedFormBuilder, |
|||
UntypedFormControl, |
|||
UntypedFormGroup, |
|||
Validator |
|||
} from '@angular/forms'; |
|||
import { CdkDragDrop } from '@angular/cdk/drag-drop'; |
|||
import { TranslateService } from '@ngx-translate/core'; |
|||
import { FormSelectItem } from '@shared/models/dynamic-form.models'; |
|||
import { |
|||
DynamicFormSelectItemRowComponent, |
|||
selectItemValid |
|||
} from '@home/components/widget/lib/settings/common/dynamic-form/dynamic-form-select-item-row.component'; |
|||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-dynamic-form-select-items', |
|||
templateUrl: './dynamic-form-select-items.component.html', |
|||
styleUrls: ['./dynamic-form-select-items.component.scss'], |
|||
providers: [ |
|||
{ |
|||
provide: NG_VALUE_ACCESSOR, |
|||
useExisting: forwardRef(() => DynamicFormSelectItemsComponent), |
|||
multi: true |
|||
}, |
|||
{ |
|||
provide: NG_VALIDATORS, |
|||
useExisting: forwardRef(() => DynamicFormSelectItemsComponent), |
|||
multi: true |
|||
} |
|||
], |
|||
encapsulation: ViewEncapsulation.None |
|||
}) |
|||
export class DynamicFormSelectItemsComponent implements ControlValueAccessor, OnInit, Validator { |
|||
|
|||
@HostBinding('style.display') styleDisplay = 'flex'; |
|||
@HostBinding('style.overflow') styleOverflow = 'hidden'; |
|||
|
|||
@ViewChildren(DynamicFormSelectItemRowComponent) |
|||
selectItemRows: QueryList<DynamicFormSelectItemRowComponent>; |
|||
|
|||
@Input() |
|||
disabled: boolean; |
|||
|
|||
selectItemsFormGroup: UntypedFormGroup; |
|||
|
|||
errorText = ''; |
|||
|
|||
get dragEnabled(): boolean { |
|||
return !this.disabled && this.selectItemsFormArray().controls.length > 1; |
|||
} |
|||
|
|||
private propagateChange = (_val: any) => {}; |
|||
|
|||
constructor(private fb: UntypedFormBuilder, |
|||
private destroyRef: DestroyRef, |
|||
private translate: TranslateService) { |
|||
} |
|||
|
|||
ngOnInit() { |
|||
this.selectItemsFormGroup = this.fb.group({ |
|||
selectItems: this.fb.array([]) |
|||
}); |
|||
this.selectItemsFormGroup.valueChanges.pipe( |
|||
takeUntilDestroyed(this.destroyRef) |
|||
).subscribe( |
|||
() => { |
|||
let items: FormSelectItem[] = this.selectItemsFormGroup.get('selectItems').value; |
|||
if (items) { |
|||
items = items.filter(i => selectItemValid(i)); |
|||
} |
|||
this.propagateChange(items); |
|||
} |
|||
); |
|||
} |
|||
|
|||
registerOnChange(fn: any): void { |
|||
this.propagateChange = fn; |
|||
} |
|||
|
|||
registerOnTouched(fn: any): void { |
|||
} |
|||
|
|||
setDisabledState(isDisabled: boolean): void { |
|||
this.disabled = isDisabled; |
|||
if (isDisabled) { |
|||
this.selectItemsFormGroup.disable({emitEvent: false}); |
|||
} else { |
|||
this.selectItemsFormGroup.enable({emitEvent: false}); |
|||
} |
|||
} |
|||
|
|||
writeValue(value: FormSelectItem[] | undefined): void { |
|||
const items= value || []; |
|||
this.selectItemsFormGroup.setControl('selectItems', this.prepareSelectItemsFormArray(items), {emitEvent: false}); |
|||
} |
|||
|
|||
public validate(c: UntypedFormControl) { |
|||
this.errorText = ''; |
|||
const itemsArray = this.selectItemsFormGroup.get('selectItems') as UntypedFormArray; |
|||
const notUniqueControls = |
|||
itemsArray.controls.filter(control => control.hasError('itemValueNotUnique')); |
|||
for (const control of notUniqueControls) { |
|||
control.updateValueAndValidity({onlySelf: false, emitEvent: false}); |
|||
if (control.hasError('itemValueNotUnique')) { |
|||
this.errorText = this.translate.instant('dynamic-form.property.not-unique-select-option-value-error'); |
|||
} |
|||
} |
|||
let valid = this.selectItemsFormGroup.valid; |
|||
if (valid) { |
|||
const items: FormSelectItem[] = this.selectItemsFormGroup.get('selectItems').value; |
|||
valid = !items.some(item => !selectItemValid(item)); |
|||
} |
|||
|
|||
return valid ? null : { |
|||
selectItems: { |
|||
valid: false, |
|||
}, |
|||
}; |
|||
} |
|||
|
|||
public selectItemValueUnique(value: any, index: number): boolean { |
|||
const itemsArray = this.selectItemsFormGroup.get('selectItems') as UntypedFormArray; |
|||
for (let i = 0; i < itemsArray.controls.length; i++) { |
|||
if (i !== index) { |
|||
const otherControl = itemsArray.controls[i]; |
|||
if (value === otherControl.value.value) { |
|||
return false; |
|||
} |
|||
} |
|||
} |
|||
return true; |
|||
} |
|||
|
|||
selectItemDrop(event: CdkDragDrop<string[]>) { |
|||
const itemsArray = this.selectItemsFormGroup.get('selectItems') as UntypedFormArray; |
|||
const item = itemsArray.at(event.previousIndex); |
|||
itemsArray.removeAt(event.previousIndex); |
|||
itemsArray.insert(event.currentIndex, item); |
|||
} |
|||
|
|||
selectItemsFormArray(): UntypedFormArray { |
|||
return this.selectItemsFormGroup.get('selectItems') as UntypedFormArray; |
|||
} |
|||
|
|||
trackBySelectItem(index: number, selectItemControl: AbstractControl): any { |
|||
return selectItemControl; |
|||
} |
|||
|
|||
removeSelectItem(index: number, emitEvent = true) { |
|||
(this.selectItemsFormGroup.get('selectItems') as UntypedFormArray).removeAt(index, {emitEvent}); |
|||
} |
|||
|
|||
addSelectItem() { |
|||
const item: FormSelectItem = { |
|||
value: '', |
|||
label: '' |
|||
}; |
|||
const itemsArray = this.selectItemsFormGroup.get('selectItems') as UntypedFormArray; |
|||
const itemControl = this.fb.control(item, []); |
|||
itemsArray.push(itemControl); |
|||
} |
|||
|
|||
private prepareSelectItemsFormArray(items: FormSelectItem[] | undefined): UntypedFormArray { |
|||
const selectItemsControls: Array<AbstractControl> = []; |
|||
if (items) { |
|||
items.forEach((item) => { |
|||
selectItemsControls.push(this.fb.control(item, [])); |
|||
}); |
|||
} |
|||
return this.fb.array(selectItemsControls); |
|||
} |
|||
} |
|||
@ -0,0 +1,117 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2024 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
<div [class.!hidden]="!propertyGroups?.length && !propertiesContent.children?.length" class="tb-form-panel" [class.stroked]="stroked" |
|||
[formGroup]="propertiesFormGroup"> |
|||
<div *ngIf="title" class="tb-form-panel-title">{{ title }}</div> |
|||
<ng-container #propertiesContent><ng-content select=".tb-properties-content"></ng-content></ng-container> |
|||
<ng-container *ngFor="let propertyGroup of propertyGroups"> |
|||
<ng-container *ngIf="propertyGroup.visible"> |
|||
<div *ngIf="propertyGroup.title" class="tb-form-panel stroked"> |
|||
<mat-expansion-panel class="tb-settings" expanded> |
|||
<mat-expansion-panel-header class="flex flex-row flex-wrap"> |
|||
<mat-panel-title> |
|||
<div class="tb-form-panel-title">{{ propertyGroup.title | customTranslate }}</div> |
|||
</mat-panel-title> |
|||
</mat-expansion-panel-header> |
|||
<ng-template matExpansionPanelContent> |
|||
<ng-container *ngTemplateOutlet="propertyContainersTpl; context:{propertyContainers: propertyGroup.containers}"></ng-container> |
|||
</ng-template> |
|||
</mat-expansion-panel> |
|||
</div> |
|||
<ng-container *ngIf="!propertyGroup.title"> |
|||
<ng-container *ngTemplateOutlet="propertyContainersTpl; context:{propertyContainers: propertyGroup.containers}"></ng-container> |
|||
</ng-container> |
|||
</ng-container> |
|||
</ng-container> |
|||
</div> |
|||
|
|||
<ng-template #propertyContainersTpl let-propertyContainers="propertyContainers"> |
|||
<ng-container *ngFor="let propertyContainer of propertyContainers" [formGroup]="propertiesFormGroup"> |
|||
<ng-container *ngIf="propertyContainer.type === FormPropertyContainerType.row && propertyContainer.visible"> |
|||
<ng-container *ngTemplateOutlet="propertyRowTpl;context:{propertyRow: propertyContainer}"> |
|||
</ng-container> |
|||
</ng-container> |
|||
<tb-dynamic-form *ngIf="propertyContainer.type === FormPropertyContainerType.fieldset && propertyContainer.visible" |
|||
formControlName="{{ propertyContainer.property.id }}" |
|||
title="{{ propertyContainer.label | customTranslate }}" |
|||
stroked |
|||
[properties]="propertyContainer.properties"> |
|||
</tb-dynamic-form> |
|||
</ng-container> |
|||
</ng-template> |
|||
|
|||
<ng-template #propertyRowTpl let-propertyRow="propertyRow"> |
|||
<div [formGroup]="propertiesFormGroup" class="tb-form-row space-between flex-wrap 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 }} |
|||
</mat-slide-toggle> |
|||
<div *ngIf="!propertyRow.switch" class="fixed-title-width fixed-title-height">{{ propertyRow.label | customTranslate }}</div> |
|||
<div *ngIf="propertyRow.properties.length" class="tb-flex row flex-end align-center"> |
|||
<ng-container *ngFor="let property of propertyRow.properties"> |
|||
<ng-container *ngIf="property.visible"> |
|||
<div *ngIf="property.subLabel" class="tb-small-label">{{ property.subLabel | customTranslate }}</div> |
|||
<mat-form-field *ngIf="property.type === FormPropertyType.text" [class]="property.fieldClass" appearance="outline" subscriptSizing="dynamic"> |
|||
<input matInput formControlName="{{ property.id }}" [required]="property.required" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
<div class="lt-md:!hidden" matSuffix *ngIf="property.fieldSuffix">{{ property.fieldSuffix | customTranslate }}</div> |
|||
</mat-form-field> |
|||
<mat-form-field *ngIf="property.type === FormPropertyType.select" [class]="property.fieldClass || 'medium-width'" appearance="outline" subscriptSizing="dynamic"> |
|||
<mat-select formControlName="{{ property.id }}" [multiple]="property.multiple"> |
|||
<mat-option *ngFor="let item of property.items" [value]="item.value"> |
|||
{{ item.label | customTranslate }} |
|||
</mat-option> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
<tb-color-input *ngIf="property.type === FormPropertyType.color" |
|||
[required]="property.required" |
|||
[class]="property.fieldClass" |
|||
asBoxInput |
|||
colorClearButton |
|||
formControlName="{{ property.id }}"> |
|||
</tb-color-input> |
|||
<tb-color-settings *ngIf="property.type === FormPropertyType.color_settings" |
|||
[class]="property.fieldClass" |
|||
formControlName="{{ property.id }}" |
|||
settingsKey="{{ property.name | customTranslate }}"> |
|||
</tb-color-settings> |
|||
<mat-form-field *ngIf="property.type === FormPropertyType.number" [class]="property.fieldClass" appearance="outline" class="number" subscriptSizing="dynamic"> |
|||
<input matInput formControlName="{{ property.id }}" [required]="property.required" |
|||
[min]="property.min" [max]="property.max" [step]="property.step" |
|||
type="number" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
<div class="lt-md:!hidden" matSuffix *ngIf="property.fieldSuffix">{{ property.fieldSuffix | customTranslate }}</div> |
|||
</mat-form-field> |
|||
<tb-font-settings *ngIf="property.type === FormPropertyType.font" |
|||
[class]="property.fieldClass" |
|||
formControlName="{{ property.id }}" |
|||
clearButton |
|||
disabledLineHeight> |
|||
</tb-font-settings> |
|||
<tb-unit-input *ngIf="property.type === FormPropertyType.units" |
|||
[class]="property.fieldClass" |
|||
[required]="property.required" |
|||
formControlName="{{ property.id }}"></tb-unit-input> |
|||
<tb-material-icon-select *ngIf="property.type === FormPropertyType.icon" |
|||
asBoxInput |
|||
formControlName="{{ property.id }}"> |
|||
</tb-material-icon-select> |
|||
<mat-divider *ngIf="property.divider" vertical></mat-divider> |
|||
</ng-container> |
|||
</ng-container> |
|||
</div> |
|||
</div> |
|||
</ng-template> |
|||
@ -0,0 +1,22 @@ |
|||
/** |
|||
* Copyright © 2016-2024 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0 |
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
:host ::ng-deep { |
|||
.tb-properties-content { |
|||
gap: 16px; |
|||
display: flex; |
|||
flex-direction: column; |
|||
} |
|||
} |
|||
@ -0,0 +1,265 @@ |
|||
///
|
|||
/// Copyright © 2016-2024 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { |
|||
ChangeDetectorRef, |
|||
Component, |
|||
DestroyRef, |
|||
forwardRef, |
|||
Input, |
|||
OnChanges, |
|||
OnInit, |
|||
SimpleChanges |
|||
} from '@angular/core'; |
|||
import { |
|||
ControlValueAccessor, |
|||
NG_VALIDATORS, |
|||
NG_VALUE_ACCESSOR, |
|||
UntypedFormBuilder, |
|||
UntypedFormControl, |
|||
UntypedFormGroup, |
|||
Validator, |
|||
ValidatorFn, |
|||
Validators |
|||
} from '@angular/forms'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { isDefinedAndNotNull, mergeDeep } from '@core/utils'; |
|||
import { |
|||
defaultFormProperties, |
|||
FormProperty, |
|||
FormPropertyContainerType, |
|||
FormPropertyGroup, |
|||
FormPropertyType, |
|||
PropertyConditionFunction, |
|||
toPropertyGroups |
|||
} from '@shared/models/dynamic-form.models'; |
|||
import { coerceBoolean } from '@shared/decorators/coercion'; |
|||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-dynamic-form', |
|||
templateUrl: './dynamic-form.component.html', |
|||
styleUrls: ['./dynamic-form.component.scss'], |
|||
providers: [ |
|||
{ |
|||
provide: NG_VALUE_ACCESSOR, |
|||
useExisting: forwardRef(() => DynamicFormComponent), |
|||
multi: true |
|||
}, |
|||
{ |
|||
provide: NG_VALIDATORS, |
|||
useExisting: forwardRef(() => DynamicFormComponent), |
|||
multi: true |
|||
} |
|||
] |
|||
}) |
|||
export class DynamicFormComponent implements OnInit, OnChanges, ControlValueAccessor, Validator { |
|||
|
|||
FormPropertyContainerType = FormPropertyContainerType; |
|||
|
|||
FormPropertyType = FormPropertyType; |
|||
|
|||
@Input() |
|||
disabled: boolean; |
|||
|
|||
@Input() |
|||
properties: FormProperty[]; |
|||
|
|||
@Input() |
|||
title: string; |
|||
|
|||
@Input() |
|||
@coerceBoolean() |
|||
stroked = false; |
|||
|
|||
private modelValue: {[id: string]: any}; |
|||
|
|||
private propagateChange = null; |
|||
|
|||
private validatorTriggers: string[]; |
|||
|
|||
public propertiesFormGroup: UntypedFormGroup; |
|||
|
|||
propertyGroups: FormPropertyGroup[]; |
|||
|
|||
constructor(protected store: Store<AppState>, |
|||
private destroyRef: DestroyRef, |
|||
private fb: UntypedFormBuilder, |
|||
private cd: ChangeDetectorRef) { |
|||
} |
|||
|
|||
ngOnInit(): void { |
|||
this.propertiesFormGroup = this.fb.group({ |
|||
}); |
|||
this.propertiesFormGroup.valueChanges.pipe( |
|||
takeUntilDestroyed(this.destroyRef) |
|||
).subscribe(() => { |
|||
this.updateModel(); |
|||
}); |
|||
this.loadMetadata(); |
|||
} |
|||
|
|||
ngOnChanges(changes: SimpleChanges): void { |
|||
for (const propName of Object.keys(changes)) { |
|||
const change = changes[propName]; |
|||
if (!change.firstChange && change.currentValue !== change.previousValue) { |
|||
if (['properties'].includes(propName)) { |
|||
this.loadMetadata(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
registerOnChange(fn: any): void { |
|||
this.propagateChange = fn; |
|||
} |
|||
|
|||
registerOnTouched(_fn: any): void { |
|||
} |
|||
|
|||
setDisabledState(isDisabled: boolean): void { |
|||
this.disabled = isDisabled; |
|||
if (isDisabled) { |
|||
this.propertiesFormGroup.disable({emitEvent: false}); |
|||
} else { |
|||
this.propertiesFormGroup.enable({emitEvent: false}); |
|||
this.updateControlsState(); |
|||
} |
|||
} |
|||
|
|||
writeValue(value: {[id: string]: any}): void { |
|||
this.modelValue = value || {}; |
|||
this.setupValue(); |
|||
} |
|||
|
|||
validate(_c: UntypedFormControl) { |
|||
const valid = this.propertiesFormGroup.valid; |
|||
return valid ? null : { |
|||
properties: { |
|||
valid: false, |
|||
}, |
|||
}; |
|||
} |
|||
|
|||
private loadMetadata() { |
|||
this.validatorTriggers = []; |
|||
this.propertyGroups = []; |
|||
|
|||
for (const control of Object.keys(this.propertiesFormGroup.controls)) { |
|||
this.propertiesFormGroup.removeControl(control, {emitEvent: false}); |
|||
} |
|||
if (this.properties) { |
|||
for (let property of this.properties) { |
|||
property.disabled = false; |
|||
property.visible = true; |
|||
if (property.condition) { |
|||
try { |
|||
property.conditionFunction = new Function('property', 'model', property.condition) as PropertyConditionFunction; |
|||
} catch (_e) { |
|||
} |
|||
} |
|||
} |
|||
this.propertyGroups = toPropertyGroups(this.properties); |
|||
for (const property of this.properties) { |
|||
if (property.disableOnProperty) { |
|||
if (!this.validatorTriggers.includes(property.disableOnProperty)) { |
|||
this.validatorTriggers.push(property.disableOnProperty); |
|||
} |
|||
} |
|||
const validators: ValidatorFn[] = []; |
|||
if (property.required) { |
|||
validators.push(Validators.required); |
|||
} |
|||
if (property.type === FormPropertyType.number) { |
|||
if (isDefinedAndNotNull(property.min)) { |
|||
validators.push(Validators.min(property.min)); |
|||
} |
|||
if (isDefinedAndNotNull(property.max)) { |
|||
validators.push(Validators.max(property.max)); |
|||
} |
|||
} |
|||
this.propertiesFormGroup.addControl(property.id, this.fb.control(null, validators), {emitEvent: false}); |
|||
} |
|||
} |
|||
this.setupValue(); |
|||
this.cd.markForCheck(); |
|||
} |
|||
|
|||
private calculateControlsState(updateControls = false) { |
|||
for (const trigger of this.validatorTriggers) { |
|||
const value: boolean = this.propertiesFormGroup.get(trigger).value; |
|||
this.properties.filter(p => p.disableOnProperty === trigger).forEach( |
|||
(p) => { |
|||
p.disabled = !value; |
|||
} |
|||
); |
|||
} |
|||
if (this.properties) { |
|||
for (let property of this.properties) { |
|||
if (property.conditionFunction) { |
|||
property.visible = property.conditionFunction(property, this.modelValue); |
|||
} |
|||
} |
|||
this.propertyGroups.forEach(g => { |
|||
g.containers.forEach(container => { |
|||
if (container.type === FormPropertyContainerType.fieldset) { |
|||
container.visible = container.property.visible; |
|||
} else { |
|||
container.visible = container.switch?.visible || container.properties.some(p => p.visible); |
|||
} |
|||
}); |
|||
g.visible = g.containers.some(c => c.visible); |
|||
}); |
|||
} |
|||
if (updateControls) { |
|||
this.updateControlsState(); |
|||
} |
|||
} |
|||
|
|||
private updateControlsState() { |
|||
if (this.properties) { |
|||
for (let property of this.properties) { |
|||
const control = this.propertiesFormGroup.get(property.id); |
|||
if (property.visible && !property.disabled) { |
|||
control.enable({emitEvent: false}); |
|||
control.updateValueAndValidity({emitEvent: false}); |
|||
} else { |
|||
control.disable({emitEvent: false}); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private setupValue() { |
|||
if (this.properties) { |
|||
const defaults = defaultFormProperties(this.properties); |
|||
this.modelValue = mergeDeep<{[id: string]: any}>(defaults, this.modelValue); |
|||
this.propertiesFormGroup.patchValue( |
|||
this.modelValue, {emitEvent: false} |
|||
); |
|||
this.calculateControlsState(); |
|||
this.setDisabledState(this.disabled); |
|||
} |
|||
} |
|||
|
|||
private updateModel() { |
|||
this.modelValue = this.propertiesFormGroup.getRawValue(); |
|||
this.calculateControlsState(true); |
|||
this.propagateChange(this.modelValue); |
|||
} |
|||
|
|||
} |
|||
@ -1,136 +0,0 @@ |
|||
///
|
|||
/// Copyright © 2016-2024 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; |
|||
import { TbPopoverComponent } from '@shared/components/popover.component'; |
|||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; |
|||
import { |
|||
ScadaSymbolProperty, |
|||
scadaSymbolPropertyFieldClasses, |
|||
scadaSymbolPropertyRowClasses, |
|||
ScadaSymbolPropertyType, |
|||
scadaSymbolPropertyTypes, |
|||
scadaSymbolPropertyTypeTranslations |
|||
} from '@home/components/widget/lib/scada/scada-symbol.models'; |
|||
import { defaultPropertyValue } from '@home/pages/scada-symbol/metadata-components/scada-symbol-property-row.component'; |
|||
import { ValueType } from '@shared/models/constants'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-scada-symbol-property-panel', |
|||
templateUrl: './scada-symbol-property-panel.component.html', |
|||
styleUrls: ['./scada-symbol-property-panel.component.scss'], |
|||
encapsulation: ViewEncapsulation.None |
|||
}) |
|||
export class ScadaSymbolPropertyPanelComponent implements OnInit { |
|||
|
|||
ValueType = ValueType; |
|||
|
|||
ScadaSymbolPropertyType = ScadaSymbolPropertyType; |
|||
|
|||
scadaSymbolPropertyTypes = scadaSymbolPropertyTypes; |
|||
scadaSymbolPropertyTypeTranslations = scadaSymbolPropertyTypeTranslations; |
|||
|
|||
scadaSymbolPropertyRowClasses = scadaSymbolPropertyRowClasses; |
|||
|
|||
scadaSymbolPropertyFieldClasses = scadaSymbolPropertyFieldClasses; |
|||
|
|||
@Input() |
|||
isAdd = false; |
|||
|
|||
@Input() |
|||
property: ScadaSymbolProperty; |
|||
|
|||
@Input() |
|||
booleanPropertyIds: string[]; |
|||
|
|||
@Input() |
|||
disabled: boolean; |
|||
|
|||
@Input() |
|||
popover: TbPopoverComponent<ScadaSymbolPropertyPanelComponent>; |
|||
|
|||
@Output() |
|||
propertySettingsApplied = new EventEmitter<ScadaSymbolProperty>(); |
|||
|
|||
panelTitle: string; |
|||
|
|||
propertyFormGroup: UntypedFormGroup; |
|||
|
|||
private propertyType: ScadaSymbolPropertyType; |
|||
|
|||
constructor(private fb: UntypedFormBuilder) { |
|||
} |
|||
|
|||
ngOnInit(): void { |
|||
this.panelTitle = this.isAdd ? 'scada.property.add-property' : 'scada.property.property-settings'; |
|||
this.propertyType = this.property.type; |
|||
this.propertyFormGroup = this.fb.group( |
|||
{ |
|||
id: [this.property.id, [Validators.required]], |
|||
name: [this.property.name, [Validators.required]], |
|||
type: [this.property.type, [Validators.required]], |
|||
default: [this.property.default, []], |
|||
required: [this.property.required, []], |
|||
subLabel: [this.property.subLabel, []], |
|||
divider: [this.property.divider, []], |
|||
fieldSuffix: [this.property.fieldSuffix, []], |
|||
disableOnProperty: [this.property.disableOnProperty, []], |
|||
rowClass: [(this.property.rowClass || '').split(' '), []], |
|||
fieldClass: [(this.property.fieldClass || '').split(' '), []], |
|||
min: [this.property.min, []], |
|||
max: [this.property.max, []], |
|||
step: [this.property.step, [Validators.min(0)]] |
|||
} |
|||
); |
|||
if (this.disabled) { |
|||
this.propertyFormGroup.disable({emitEvent: false}); |
|||
} else { |
|||
this.propertyFormGroup.get('type').valueChanges.subscribe(() => { |
|||
this.updateValidators(); |
|||
}); |
|||
this.updateValidators(); |
|||
} |
|||
} |
|||
|
|||
cancel() { |
|||
this.popover?.hide(); |
|||
} |
|||
|
|||
applyPropertySettings() { |
|||
const property = this.propertyFormGroup.getRawValue(); |
|||
property.rowClass = (property.rowClass || []).join(' '); |
|||
property.fieldClass = (property.fieldClass || []).join(' '); |
|||
this.propertySettingsApplied.emit(property); |
|||
} |
|||
|
|||
private updateValidators() { |
|||
const type: ScadaSymbolPropertyType = this.propertyFormGroup.get('type').value; |
|||
if (type === ScadaSymbolPropertyType.number) { |
|||
this.propertyFormGroup.get('min').enable({emitEvent: false}); |
|||
this.propertyFormGroup.get('max').enable({emitEvent: false}); |
|||
this.propertyFormGroup.get('step').enable({emitEvent: false}); |
|||
} else { |
|||
this.propertyFormGroup.get('min').disable({emitEvent: false}); |
|||
this.propertyFormGroup.get('max').disable({emitEvent: false}); |
|||
this.propertyFormGroup.get('step').disable({emitEvent: false}); |
|||
} |
|||
if (this.propertyType !== type) { |
|||
const defaultValue = defaultPropertyValue(type); |
|||
this.propertyFormGroup.get('default').patchValue(defaultValue, {emitEvent: false}); |
|||
this.propertyType = type; |
|||
} |
|||
} |
|||
} |
|||
File diff suppressed because it is too large
@ -0,0 +1,385 @@ |
|||
///
|
|||
/// Copyright © 2016-2024 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; |
|||
import { TbEditorCompletion, TbEditorCompletions } from '@shared/models/ace/completion.models'; |
|||
import { isString } from '@core/utils'; |
|||
import { JsonSchema, JsonSettingsSchema } from '@shared/models/widget.models'; |
|||
import JsonFormUtils from '@shared/components/json-form/react/json-form-utils'; |
|||
import { JsonFormData, KeyLabelItem } from '@shared/components/json-form/react/json-form.models'; |
|||
|
|||
export enum FormPropertyType { |
|||
text = 'text', |
|||
number = 'number', |
|||
switch = 'switch', |
|||
select = 'select', |
|||
color = 'color', |
|||
color_settings = 'color_settings', |
|||
font = 'font', |
|||
units = 'units', |
|||
icon = 'icon', |
|||
fieldset = 'fieldset' |
|||
} |
|||
|
|||
export const formPropertyTypes = Object.keys(FormPropertyType) as FormPropertyType[]; |
|||
|
|||
export const formPropertyTypeTranslations = new Map<FormPropertyType, string>( |
|||
[ |
|||
[FormPropertyType.text, 'dynamic-form.property.type-text'], |
|||
[FormPropertyType.number, 'dynamic-form.property.type-number'], |
|||
[FormPropertyType.switch, 'dynamic-form.property.type-switch'], |
|||
[FormPropertyType.select, 'dynamic-form.property.type-select'], |
|||
[FormPropertyType.color, 'dynamic-form.property.type-color'], |
|||
[FormPropertyType.color_settings, 'dynamic-form.property.type-color-settings'], |
|||
[FormPropertyType.font, 'dynamic-form.property.type-font'], |
|||
[FormPropertyType.units, 'dynamic-form.property.type-units'], |
|||
[FormPropertyType.icon, 'dynamic-form.property.type-icon'], |
|||
[FormPropertyType.fieldset, 'dynamic-form.property.type-fieldset'] |
|||
] |
|||
); |
|||
|
|||
export const formPropertyRowClasses = |
|||
['column', 'column-xs', 'column-lt-md', 'align-start', 'no-border', 'no-gap', 'no-padding', 'same-padding']; |
|||
|
|||
export const formPropertyFieldClasses = |
|||
['medium-width', 'flex', 'flex-xs', 'flex-lt-md']; |
|||
|
|||
export type PropertyConditionFunction = (property: FormProperty, model: any) => boolean; |
|||
|
|||
export interface FormPropertyBase { |
|||
id: string; |
|||
name: string; |
|||
group?: string; |
|||
type: FormPropertyType; |
|||
default: any; |
|||
required?: boolean; |
|||
subLabel?: string; |
|||
divider?: boolean; |
|||
fieldSuffix?: string; |
|||
disableOnProperty?: string; |
|||
condition?: string; |
|||
conditionFunction?: PropertyConditionFunction; |
|||
disabled?: boolean; |
|||
visible?: boolean; |
|||
rowClass?: string; |
|||
fieldClass?: string; |
|||
} |
|||
|
|||
export interface FormNumberProperty extends FormPropertyBase { |
|||
min?: number; |
|||
max?: number; |
|||
step?: number; |
|||
} |
|||
|
|||
export interface FormFieldSetProperty extends FormPropertyBase { |
|||
properties?: FormProperty[]; |
|||
} |
|||
|
|||
export interface FormSelectItem { |
|||
value: any; |
|||
label: string; |
|||
} |
|||
|
|||
export interface FormSelectProperty extends FormPropertyBase { |
|||
multiple?: boolean; |
|||
items?: FormSelectItem[]; |
|||
} |
|||
|
|||
export type FormProperty = FormPropertyBase & FormNumberProperty & FormSelectProperty & FormFieldSetProperty; |
|||
|
|||
export enum FormPropertyContainerType { |
|||
row = 'row', |
|||
fieldset = 'fieldset' |
|||
} |
|||
|
|||
export interface FormPropertyContainerBase { |
|||
type: FormPropertyContainerType; |
|||
label: string; |
|||
properties: FormProperty[]; |
|||
visible: boolean; |
|||
} |
|||
|
|||
export interface FormPropertyRow extends FormPropertyContainerBase { |
|||
switch?: FormProperty; |
|||
rowClass?: string; |
|||
} |
|||
|
|||
export interface FormPropertyFieldset extends FormPropertyContainerBase { |
|||
property?: FormProperty; |
|||
} |
|||
|
|||
export type FormPropertyContainer = FormPropertyRow & FormPropertyFieldset; |
|||
|
|||
export interface FormPropertyGroup { |
|||
title?: string; |
|||
containers: FormPropertyContainer[]; |
|||
visible: boolean; |
|||
} |
|||
|
|||
export const toPropertyGroups = (properties: FormProperty[]): FormPropertyGroup[] => { |
|||
const groups: {title: string, properties: FormProperty[]}[] = []; |
|||
for (let property of properties) { |
|||
if (!property.group) { |
|||
groups.push({ |
|||
title: null, |
|||
properties: [property] |
|||
}); |
|||
} else { |
|||
let propertyGroup = groups.find(g => g.title === property.group); |
|||
if (!propertyGroup) { |
|||
propertyGroup = { |
|||
title: property.group, |
|||
properties: [] |
|||
}; |
|||
groups.push(propertyGroup); |
|||
} |
|||
propertyGroup.properties.push(property); |
|||
} |
|||
} |
|||
return groups.map(g => ({ |
|||
title: g.title, |
|||
containers: toPropertyContainers(g.properties), |
|||
visible: true |
|||
})); |
|||
}; |
|||
|
|||
const toPropertyContainers = (properties: FormProperty[]): FormPropertyContainer[] => { |
|||
const result: FormPropertyContainer[] = []; |
|||
for (let property of properties) { |
|||
if (property.type === FormPropertyType.fieldset) { |
|||
const propertyFieldset: FormPropertyFieldset = { |
|||
property, |
|||
label: property.name, |
|||
type: FormPropertyContainerType.fieldset, |
|||
properties: property.properties, |
|||
visible: true |
|||
}; |
|||
result.push(propertyFieldset); |
|||
} else { |
|||
let propertyRow = |
|||
result.find(r => r.type === FormPropertyContainerType.row && r.label === property.name); |
|||
if (!propertyRow) { |
|||
propertyRow = { |
|||
label: property.name, |
|||
type: FormPropertyContainerType.row, |
|||
properties: [], |
|||
rowClass: property.rowClass, |
|||
visible: true |
|||
}; |
|||
result.push(propertyRow); |
|||
} |
|||
if (property.type === FormPropertyType.switch) { |
|||
propertyRow.switch = property; |
|||
} else { |
|||
propertyRow.properties.push(property); |
|||
} |
|||
} |
|||
} |
|||
return result; |
|||
} |
|||
|
|||
export const defaultFormProperties = (properties: FormProperty[]): {[id: string]: any} => { |
|||
const formProperties: {[id: string]: any} = {}; |
|||
for (const property of properties) { |
|||
formProperties[property.id] = defaultFormPropertyValue(property); |
|||
} |
|||
return formProperties; |
|||
}; |
|||
|
|||
export const defaultFormPropertyValue = (property: FormProperty): any => { |
|||
if (property.type === FormPropertyType.fieldset) { |
|||
const propertyValue: {[id: string]: any} = {}; |
|||
for (const childProperty of property.properties) { |
|||
propertyValue[childProperty.id] = defaultFormPropertyValue(childProperty); |
|||
} |
|||
return propertyValue; |
|||
} else { |
|||
return property.default; |
|||
} |
|||
} |
|||
|
|||
export const formPropertyCompletions = (properties: FormProperty[], customTranslate: CustomTranslatePipe): TbEditorCompletions => { |
|||
const propertiesCompletions: TbEditorCompletions = {}; |
|||
for (const property of properties) { |
|||
propertiesCompletions[property.id] = formPropertyCompletion(property, customTranslate); |
|||
} |
|||
return propertiesCompletions; |
|||
} |
|||
|
|||
export const formPropertyCompletion = (property: FormProperty, customTranslate: CustomTranslatePipe): TbEditorCompletion => { |
|||
let description = customTranslate.transform(property.name, property.name); |
|||
if (property.subLabel) { |
|||
description += ` <small>${customTranslate.transform(property.subLabel, property.subLabel)}</small>`; |
|||
} |
|||
if (property.type === FormPropertyType.select) { |
|||
if (property.multiple) { |
|||
description += '<br/><br/><code class="title"><b>Possible values of array element:</b></code>'; |
|||
} else { |
|||
description += '<br/><br/><code class="title"><b>Possible values:</b></code>'; |
|||
} |
|||
description += `<ul>${property.items.map(item => `<li>${item.value} <code style="font-size: 11px;">${typeof item.value}</code></li>`).join('\n')}</ul>`; |
|||
} |
|||
const completion: TbEditorCompletion = { |
|||
meta: 'property', |
|||
description, |
|||
type: formPropertyCompletionType(property) |
|||
}; |
|||
if (property.type === FormPropertyType.fieldset) { |
|||
completion.children = {}; |
|||
for (const childProperty of property.properties) { |
|||
completion.children[childProperty.id] = formPropertyCompletion(childProperty, customTranslate); |
|||
} |
|||
} |
|||
return completion; |
|||
}; |
|||
|
|||
const formPropertyCompletionType = (property: FormProperty): string => { |
|||
switch (property.type) { |
|||
case FormPropertyType.text: |
|||
return 'string'; |
|||
case FormPropertyType.number: |
|||
return 'number'; |
|||
case FormPropertyType.switch: |
|||
return 'boolean'; |
|||
case FormPropertyType.select: |
|||
const items = property.items || []; |
|||
const types: string[] = []; |
|||
items.forEach(item => { |
|||
const type = typeof item.value; |
|||
if (!types.includes(type)) { |
|||
types.push(type); |
|||
} |
|||
}); |
|||
const typesString = types.length ? types.join(' | ') : 'string'; |
|||
if (property.multiple) { |
|||
return `Array<${typesString}>`; |
|||
} else { |
|||
return typesString; |
|||
} |
|||
case FormPropertyType.color: |
|||
return 'color string'; |
|||
case FormPropertyType.color_settings: |
|||
return 'ColorProcessor'; |
|||
case FormPropertyType.font: |
|||
return 'Font'; |
|||
case FormPropertyType.units: |
|||
return 'units string'; |
|||
case FormPropertyType.icon: |
|||
return 'icon string'; |
|||
case FormPropertyType.fieldset: |
|||
return 'object'; |
|||
} |
|||
}; |
|||
|
|||
|
|||
export const jsonFormSchemaToFormProperties = (rawSchema: string | any) : FormProperty[] => { |
|||
const properties: FormProperty[] = []; |
|||
let settingsSchema: JsonSettingsSchema; |
|||
if (!rawSchema || rawSchema === '') { |
|||
settingsSchema = {}; |
|||
} else { |
|||
settingsSchema = isString(rawSchema) ? JSON.parse(rawSchema) : rawSchema; |
|||
} |
|||
if (settingsSchema.schema) { |
|||
const schema = settingsSchema.schema; |
|||
const form = settingsSchema.form || ['*']; |
|||
const groupInfoes = settingsSchema.groupInfoes || []; |
|||
if (form.length > 0) { |
|||
if (groupInfoes.length) { |
|||
for (const info of groupInfoes) { |
|||
const theForm: any[] = form[info.formIndex]; |
|||
properties.push(...schemaFormToProperties(schema, theForm, info.GroupTitle)); |
|||
} |
|||
} else { |
|||
properties.push(...schemaFormToProperties(schema, form)); |
|||
} |
|||
} |
|||
} |
|||
return properties; |
|||
} |
|||
|
|||
const schemaFormToProperties = (schema: JsonSchema, theForm: any[], groupTitle?: string): FormProperty[] => { |
|||
const merged = JsonFormUtils.merge(schema, theForm, {}, { |
|||
formDefaults: { |
|||
startEmpty: true |
|||
} |
|||
}); |
|||
return merged.map((form: JsonFormData) => jsonFormDataToProperty(form, 0, groupTitle)).filter(p => p != null); |
|||
} |
|||
|
|||
const jsonFormDataToProperty = (form: JsonFormData, level: number, groupTitle?: string): FormProperty => { |
|||
if (form.key && form.key.length > level) { |
|||
const property: FormProperty = { |
|||
id: form.key[level] + '', |
|||
name: form.title, |
|||
group: groupTitle, |
|||
type: null, |
|||
default: form.schema.default, |
|||
required: form.required |
|||
}; |
|||
if (form.condition?.length) { |
|||
property.condition = `return ${form.condition};`; |
|||
} |
|||
switch (form.type) { |
|||
case 'number': |
|||
property.type = FormPropertyType.number; |
|||
break; |
|||
case 'text': |
|||
property.type = FormPropertyType.text; |
|||
property.fieldClass = 'flex'; |
|||
break; |
|||
case 'checkbox': |
|||
property.type = FormPropertyType.switch; |
|||
break; |
|||
case 'rc-select': |
|||
property.type = FormPropertyType.select; |
|||
if (form.items?.length) { |
|||
property.items = (form.items as KeyLabelItem[]).map(item => ({value: item.value, label: item.label})); |
|||
} else { |
|||
property.items = []; |
|||
} |
|||
property.multiple = form.multiple; |
|||
property.fieldClass = 'flex'; |
|||
break; |
|||
case 'select': |
|||
property.type = FormPropertyType.select; |
|||
if (form.titleMap?.length) { |
|||
property.items = form.titleMap.map(item => ({value: item.value, label: item.name})); |
|||
} else { |
|||
property.items = []; |
|||
} |
|||
property.multiple = false; |
|||
property.fieldClass = 'flex'; |
|||
break; |
|||
case 'color': |
|||
property.type = FormPropertyType.color; |
|||
break; |
|||
case 'icon': |
|||
property.type = FormPropertyType.icon; |
|||
break; |
|||
case 'fieldset': |
|||
property.type = FormPropertyType.fieldset; |
|||
property.properties = form.items ? (form.items as JsonFormData[]).map(item => |
|||
jsonFormDataToProperty(item, level+1)).filter(p => p !== null) : []; |
|||
break; |
|||
} |
|||
if (!property.type) { |
|||
return null; |
|||
} |
|||
return property; |
|||
} |
|||
return null; |
|||
} |
|||
Loading…
Reference in new issue