UI: Add unit conversion in dashboard filter

This commit is contained in:
Vladyslav_Prykhodko 2025-05-14 13:32:51 +03:00
parent 19405416be
commit 59f23ba25d
7 changed files with 128 additions and 61 deletions

View File

@ -26,24 +26,36 @@
</button>
</mat-toolbar>
<div mat-dialog-content>
<fieldset [disabled]="isLoading$ | async" class="flex flex-col">
<mat-checkbox formControlName="editable" style="margin-bottom: 16px;">
{{ 'filter.editable' | translate }}
</mat-checkbox>
<div class="flex flex-row items-center justify-start gap-2">
<mat-form-field class="mat-block flex-1">
<mat-label translate>filter.display-label</mat-label>
<input matInput formControlName="label">
</mat-form-field>
<mat-checkbox formControlName="autogeneratedLabel">
{{ 'filter.autogenerated-label' | translate }}
</mat-checkbox>
<div class="tb-form-panel no-padding no-border">
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="editable">
<div tb-hint-tooltip-icon="{{ 'filter.editable-hint' | translate }}">
{{ 'filter.editable' | translate }}
</div>
</mat-slide-toggle>
</div>
<mat-form-field class="mat-block">
<mat-label translate>filter.order-priority</mat-label>
<input matInput type="number" formControlName="order">
</mat-form-field>
</fieldset>
<div class="tb-form-row space-between">
<mat-slide-toggle class="mat-slide" formControlName="autogeneratedLabel">
<div tb-hint-tooltip-icon="{{ 'filter.custom-label-hint' | translate }}">
{{ 'filter.custom-label' | translate }}
</div>
</mat-slide-toggle>
<mat-form-field subscriptSizing="dynamic" appearance="outline" class="medium-width flex-xs">
<input matInput placeholder="{{ 'filter.display-label' | translate}}" formControlName="label">
</mat-form-field>
</div>
<div class="tb-form-row space-between">
<div class="min-w-44" translate>filter.order-priority</div>
<mat-form-field subscriptSizing="dynamic" appearance="outline" class="medium-width flex-xs">
<input matInput type="number" formControlName="order">
</mat-form-field>
</div>
<div class="tb-form-row space-between" *ngIf="showUniInput">
<div class="min-w-44" translate>filter.unit</div>
<tb-unit-input supportsUnitConversion formControlName="unit" class="medium-width flex-xs">
</tb-unit-input>
</div>
</div>
</div>
<div mat-dialog-actions class="flex items-center justify-end">
<button mat-button color="primary"

View File

@ -14,18 +14,21 @@
/// limitations under the License.
///
import { Component, Inject, OnInit, SkipSelf } from '@angular/core';
import { Component, Inject, SkipSelf } from '@angular/core';
import { ErrorStateMatcher } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms';
import { FormGroupDirective, NgForm, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup } from '@angular/forms';
import { Router } from '@angular/router';
import { DialogComponent } from '@app/shared/components/dialog.component';
import {
BooleanOperation, createDefaultFilterPredicateUserInfo,
EntityKeyValueType, generateUserFilterValueLabel,
KeyFilterPredicateUserInfo, NumericOperation,
BooleanOperation,
createDefaultFilterPredicateUserInfo,
EntityKeyValueType,
generateUserFilterValueLabel,
KeyFilterPredicateUserInfo,
NumericOperation,
StringOperation
} from '@shared/models/query/query.models';
import { TranslateService } from '@ngx-translate/core';
@ -47,11 +50,12 @@ export interface FilterUserInfoDialogData {
})
export class FilterUserInfoDialogComponent extends
DialogComponent<FilterUserInfoDialogComponent, KeyFilterPredicateUserInfo>
implements OnInit, ErrorStateMatcher {
implements ErrorStateMatcher {
filterUserInfoFormGroup: UntypedFormGroup;
submitted = false;
showUniInput = false;
constructor(protected store: Store<AppState>,
protected router: Router,
@ -68,10 +72,14 @@ export class FilterUserInfoDialogComponent extends
{
editable: [userInfo.editable],
label: [userInfo.label],
autogeneratedLabel: [userInfo.autogeneratedLabel],
autogeneratedLabel: [!userInfo.autogeneratedLabel],
order: [userInfo.order]
}
);
if (this.data.valueType === EntityKeyValueType.NUMERIC) {
this.showUniInput = true;
this.filterUserInfoFormGroup.addControl('unit', this.fb.control(userInfo.unit), {emitEvent: false});
}
this.onAutogeneratedLabelChange();
if (!this.data.readonly) {
this.filterUserInfoFormGroup.get('autogeneratedLabel').valueChanges.pipe(
@ -85,7 +93,7 @@ export class FilterUserInfoDialogComponent extends
}
private onAutogeneratedLabelChange() {
const autogeneratedLabel: boolean = this.filterUserInfoFormGroup.get('autogeneratedLabel').value;
const autogeneratedLabel: boolean = !this.filterUserInfoFormGroup.get('autogeneratedLabel').value;
if (autogeneratedLabel) {
const generatedLabel = generateUserFilterValueLabel(this.data.key, this.data.valueType, this.data.operation, this.translate);
this.filterUserInfoFormGroup.get('label').patchValue(generatedLabel, {emitEvent: false});
@ -95,9 +103,6 @@ export class FilterUserInfoDialogComponent extends
}
}
ngOnInit(): void {
}
isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const originalErrorState = this.errorStateMatcher.isErrorState(control, form);
const customErrorState = !!(control && control.invalid && this.submitted);
@ -112,6 +117,7 @@ export class FilterUserInfoDialogComponent extends
this.submitted = true;
if (this.filterUserInfoFormGroup.valid) {
const keyFilterPredicateUserInfo: KeyFilterPredicateUserInfo = this.filterUserInfoFormGroup.getRawValue();
keyFilterPredicateUserInfo.autogeneratedLabel = !keyFilterPredicateUserInfo.autogeneratedLabel;;
this.dialogRef.close(keyFilterPredicateUserInfo);
}
}

View File

@ -15,7 +15,7 @@
limitations under the License.
-->
<form [formGroup]="userFilterFormGroup" (ngSubmit)="save()" style="width: 400px;">
<form [formGroup]="userFilterFormGroup" (ngSubmit)="save()" style="width: 400px;" class="user-filter-dialog">
<mat-toolbar color="primary">
<h2>{{ filter.filter }}</h2>
<span class="flex-1"></span>
@ -42,6 +42,10 @@
<mat-form-field class="mat-block">
<mat-label>{{ userInputControl.get('label').value }}</mat-label>
<input required type="number" matInput [formControl]="userInputControl.get('value')">
<div matSuffix class="mr-2"
[class.!hidden]="!userInputControl.get('unitSymbol').value">
{{ userInputControl.get('unitSymbol').value }}
</div>
</mat-form-field>
</ng-template>
<ng-template [ngSwitchCase]="valueTypeEnum.DATE_TIME">

View File

@ -0,0 +1,30 @@
/**
* Copyright © 2016-2025 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 {
.user-filter-dialog {
.mat-mdc-form-field.mat-mdc-form-field-has-icon-suffix.mat-form-field-hide-placeholder {
.mat-mdc-form-field-icon-suffix {
place-self: center;
}
}
.mat-mdc-form-field.mat-mdc-form-field-has-icon-suffix {
.mat-mdc-form-field-icon-suffix {
place-self: baseline;
}
}
}
}

View File

@ -14,31 +14,26 @@
/// limitations under the License.
///
import { Component, DestroyRef, Inject, OnInit, SkipSelf } from '@angular/core';
import { Component, DestroyRef, Inject, SkipSelf } from '@angular/core';
import { ErrorStateMatcher } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import {
AbstractControl, UntypedFormArray,
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
FormGroupDirective,
NgForm,
Validators
} from '@angular/forms';
import { FormArray, FormBuilder, FormControl, FormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { DialogComponent } from '@app/shared/components/dialog.component';
import { TranslateService } from '@ngx-translate/core';
import {
EntityKeyValueType,
Filter, FilterPredicateValue,
Filter,
FilterPredicateValue,
filterToUserFilterInfoList,
UserFilterInputInfo
} from '@shared/models/query/query.models';
import { isDefinedAndNotNull } from '@core/utils';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { UnitService } from '@core/services/unit.service';
import { getSourceTbUnitSymbol, TbUnitConverter } from '@shared/models/unit.models';
export interface UserFilterDialogData {
filter: Filter;
@ -48,14 +43,14 @@ export interface UserFilterDialogData {
selector: 'tb-user-filter-dialog',
templateUrl: './user-filter-dialog.component.html',
providers: [{provide: ErrorStateMatcher, useExisting: UserFilterDialogComponent}],
styleUrls: []
styleUrls: ['./user-filter-dialog.component.scss'],
})
export class UserFilterDialogComponent extends DialogComponent<UserFilterDialogComponent, Filter>
implements OnInit, ErrorStateMatcher {
implements ErrorStateMatcher {
filter: Filter;
userFilterFormGroup: UntypedFormGroup;
userFilterFormGroup: FormGroup;
valueTypeEnum = EntityKeyValueType;
@ -66,14 +61,15 @@ export class UserFilterDialogComponent extends DialogComponent<UserFilterDialogC
@Inject(MAT_DIALOG_DATA) public data: UserFilterDialogData,
@SkipSelf() private errorStateMatcher: ErrorStateMatcher,
public dialogRef: MatDialogRef<UserFilterDialogComponent, Filter>,
private fb: UntypedFormBuilder,
public translate: TranslateService,
private destroyRef: DestroyRef) {
private fb: FormBuilder,
private translate: TranslateService,
private destroyRef: DestroyRef,
private unitService: UnitService) {
super(store, router, dialogRef);
this.filter = data.filter;
const userInputs = filterToUserFilterInfoList(this.filter, translate);
const userInputs = filterToUserFilterInfoList(this.filter, this.translate);
const userInputControls: Array<AbstractControl> = [];
const userInputControls: Array<FormGroup> = [];
for (const userInput of userInputs) {
userInputControls.push(this.createUserInputFormControl(userInput));
}
@ -83,12 +79,21 @@ export class UserFilterDialogComponent extends DialogComponent<UserFilterDialogC
});
}
private createUserInputFormControl(userInput: UserFilterInputInfo): AbstractControl {
private createUserInputFormControl(userInput: UserFilterInputInfo): FormGroup {
const predicateValue: FilterPredicateValue<string | number | boolean> = (userInput.info.keyFilterPredicate as any).value;
const value = isDefinedAndNotNull(predicateValue.userValue) ? predicateValue.userValue : predicateValue.defaultValue;
let value = isDefinedAndNotNull(predicateValue.userValue) ? predicateValue.userValue : predicateValue.defaultValue;
let unitSymbol = '';
let valueConvertor: TbUnitConverter;
if (userInput.valueType === EntityKeyValueType.NUMERIC) {
unitSymbol = this.unitService.getTargetUnitSymbol(userInput.unit);
const sourceUnit = getSourceTbUnitSymbol(userInput.unit);
value = this.unitService.convertUnitValue(value as number, userInput.unit);
valueConvertor = this.unitService.geUnitConverter(unitSymbol, sourceUnit);
}
const userInputControl = this.fb.group({
label: [userInput.label],
valueType: [userInput.valueType],
unitSymbol: [unitSymbol],
value: [value,
userInput.valueType === EntityKeyValueType.NUMERIC ||
userInput.valueType === EntityKeyValueType.DATE_TIME ? [Validators.required] : []]
@ -96,19 +101,20 @@ export class UserFilterDialogComponent extends DialogComponent<UserFilterDialogC
userInputControl.get('value').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(userValue => {
(userInput.info.keyFilterPredicate as any).value.userValue = userValue;
let value = userValue;
if (valueConvertor) {
value = valueConvertor(value as number);
}
(userInput.info.keyFilterPredicate as any).value.userValue = value;
});
return userInputControl;
}
userInputsFormArray(): UntypedFormArray {
return this.userFilterFormGroup.get('userInputs') as UntypedFormArray;
userInputsFormArray(): FormArray {
return this.userFilterFormGroup.get('userInputs') as FormArray;
}
ngOnInit(): void {
}
isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean {
isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const originalErrorState = this.errorStateMatcher.isErrorState(control, form);
const customErrorState = !!(control && control.invalid && this.submitted);
return originalErrorState || customErrorState;

View File

@ -37,6 +37,7 @@ import { AlarmInfo, AlarmSearchStatus, AlarmSeverity } from '../alarm.models';
import { DatePipe } from '@angular/common';
import { UserId } from '../id/user-id';
import { Direction } from '@shared/models/page/sort-order';
import { TbUnit } from '@shared/models/unit.models';
export enum EntityKeyType {
ATTRIBUTE = 'ATTRIBUTE',
@ -179,7 +180,8 @@ export function createDefaultFilterPredicateUserInfo(): KeyFilterPredicateUserIn
editable: true,
label: '',
autogeneratedLabel: true,
order: 0
order: 0,
unit: ''
};
}
@ -373,6 +375,7 @@ export interface KeyFilterPredicateUserInfo {
label: string;
autogeneratedLabel: boolean;
order?: number;
unit?: TbUnit;
}
export interface KeyFilterPredicateInfo {
@ -621,6 +624,7 @@ export interface UserFilterInputInfo {
label: string;
valueType: EntityKeyValueType;
info: KeyFilterPredicateInfo;
unit: TbUnit;
}
export function filterToUserFilterInfoList(filter: Filter, translate: TranslateService): Array<UserFilterInputInfo> {
@ -654,6 +658,7 @@ export function predicateInfoToUserFilterInfoList(key: EntityKey,
const userInput: UserFilterInputInfo = {
info: predicateInfo,
label: predicateInfo.userInfo.label,
unit: predicateInfo.userInfo.unit,
valueType
};
if (predicateInfo.userInfo.autogeneratedLabel) {
@ -882,7 +887,7 @@ export function entityDataToEntityInfo(entityData: EntityData): EntityInfo {
if (additionalInfoJson && additionalInfoJson.description) {
entityInfo.entityDescription = additionalInfoJson.description;
}
} catch (e) {}
} catch (e) {/**/}
}
}
if (fields.queueName && fields.serviceId) {

View File

@ -2951,6 +2951,7 @@
"missing-key-filters-error": "Key filters is missing for filter '{{filter}}'.",
"filter": "Filter",
"editable": "Editable",
"editable-hint": "Allow user to change the filter value in dashboards.",
"no-filters-found": "No filters found.",
"no-filter-text": "No filter specified",
"add-filter-prompt": "Please add filter",
@ -2991,7 +2992,9 @@
"user-parameters": "User parameters",
"display-label": "Label to display",
"autogenerated-label": "Auto generate label",
"order-priority": "Field order priority",
"custom-label": "Custom label",
"custom-label-hint": "Enable to set your own label for the filter. When disabled, a label will be generated automatically.",
"order-priority": "Display order",
"key-filter": "Key filter",
"key-filters": "Key filters",
"key-name": "Key name",
@ -3035,7 +3038,8 @@
"switch-to-dynamic-value": "Switch to dynamic value",
"switch-to-default-value": "Switch to default value",
"inherit-owner": "Inherit from owner",
"source-attribute-not-set": "If source attribute isn't set"
"source-attribute-not-set": "If source attribute isn't set",
"unit": "Unit"
},
"fullscreen": {
"expand": "Expand to fullscreen",