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> </button>
</mat-toolbar> </mat-toolbar>
<div mat-dialog-content> <div mat-dialog-content>
<fieldset [disabled]="isLoading$ | async" class="flex flex-col"> <div class="tb-form-panel no-padding no-border">
<mat-checkbox formControlName="editable" style="margin-bottom: 16px;"> <div class="tb-form-row">
{{ 'filter.editable' | translate }} <mat-slide-toggle class="mat-slide" formControlName="editable">
</mat-checkbox> <div tb-hint-tooltip-icon="{{ 'filter.editable-hint' | translate }}">
<div class="flex flex-row items-center justify-start gap-2"> {{ 'filter.editable' | translate }}
<mat-form-field class="mat-block flex-1"> </div>
<mat-label translate>filter.display-label</mat-label> </mat-slide-toggle>
<input matInput formControlName="label">
</mat-form-field>
<mat-checkbox formControlName="autogeneratedLabel">
{{ 'filter.autogenerated-label' | translate }}
</mat-checkbox>
</div> </div>
<mat-form-field class="mat-block"> <div class="tb-form-row space-between">
<mat-label translate>filter.order-priority</mat-label> <mat-slide-toggle class="mat-slide" formControlName="autogeneratedLabel">
<input matInput type="number" formControlName="order"> <div tb-hint-tooltip-icon="{{ 'filter.custom-label-hint' | translate }}">
</mat-form-field> {{ 'filter.custom-label' | translate }}
</fieldset> </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>
<div mat-dialog-actions class="flex items-center justify-end"> <div mat-dialog-actions class="flex items-center justify-end">
<button mat-button color="primary" <button mat-button color="primary"

View File

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

View File

@ -15,7 +15,7 @@
limitations under the License. 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"> <mat-toolbar color="primary">
<h2>{{ filter.filter }}</h2> <h2>{{ filter.filter }}</h2>
<span class="flex-1"></span> <span class="flex-1"></span>
@ -42,6 +42,10 @@
<mat-form-field class="mat-block"> <mat-form-field class="mat-block">
<mat-label>{{ userInputControl.get('label').value }}</mat-label> <mat-label>{{ userInputControl.get('label').value }}</mat-label>
<input required type="number" matInput [formControl]="userInputControl.get('value')"> <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> </mat-form-field>
</ng-template> </ng-template>
<ng-template [ngSwitchCase]="valueTypeEnum.DATE_TIME"> <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. /// 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 { ErrorStateMatcher } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { import { FormArray, FormBuilder, FormControl, FormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms';
AbstractControl, UntypedFormArray,
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
FormGroupDirective,
NgForm,
Validators
} from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { DialogComponent } from '@app/shared/components/dialog.component'; import { DialogComponent } from '@app/shared/components/dialog.component';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { import {
EntityKeyValueType, EntityKeyValueType,
Filter, FilterPredicateValue, Filter,
FilterPredicateValue,
filterToUserFilterInfoList, filterToUserFilterInfoList,
UserFilterInputInfo UserFilterInputInfo
} from '@shared/models/query/query.models'; } from '@shared/models/query/query.models';
import { isDefinedAndNotNull } from '@core/utils'; import { isDefinedAndNotNull } from '@core/utils';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; 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 { export interface UserFilterDialogData {
filter: Filter; filter: Filter;
@ -48,14 +43,14 @@ export interface UserFilterDialogData {
selector: 'tb-user-filter-dialog', selector: 'tb-user-filter-dialog',
templateUrl: './user-filter-dialog.component.html', templateUrl: './user-filter-dialog.component.html',
providers: [{provide: ErrorStateMatcher, useExisting: UserFilterDialogComponent}], providers: [{provide: ErrorStateMatcher, useExisting: UserFilterDialogComponent}],
styleUrls: [] styleUrls: ['./user-filter-dialog.component.scss'],
}) })
export class UserFilterDialogComponent extends DialogComponent<UserFilterDialogComponent, Filter> export class UserFilterDialogComponent extends DialogComponent<UserFilterDialogComponent, Filter>
implements OnInit, ErrorStateMatcher { implements ErrorStateMatcher {
filter: Filter; filter: Filter;
userFilterFormGroup: UntypedFormGroup; userFilterFormGroup: FormGroup;
valueTypeEnum = EntityKeyValueType; valueTypeEnum = EntityKeyValueType;
@ -66,14 +61,15 @@ export class UserFilterDialogComponent extends DialogComponent<UserFilterDialogC
@Inject(MAT_DIALOG_DATA) public data: UserFilterDialogData, @Inject(MAT_DIALOG_DATA) public data: UserFilterDialogData,
@SkipSelf() private errorStateMatcher: ErrorStateMatcher, @SkipSelf() private errorStateMatcher: ErrorStateMatcher,
public dialogRef: MatDialogRef<UserFilterDialogComponent, Filter>, public dialogRef: MatDialogRef<UserFilterDialogComponent, Filter>,
private fb: UntypedFormBuilder, private fb: FormBuilder,
public translate: TranslateService, private translate: TranslateService,
private destroyRef: DestroyRef) { private destroyRef: DestroyRef,
private unitService: UnitService) {
super(store, router, dialogRef); super(store, router, dialogRef);
this.filter = data.filter; 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) { for (const userInput of userInputs) {
userInputControls.push(this.createUserInputFormControl(userInput)); 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 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({ const userInputControl = this.fb.group({
label: [userInput.label], label: [userInput.label],
valueType: [userInput.valueType], valueType: [userInput.valueType],
unitSymbol: [unitSymbol],
value: [value, value: [value,
userInput.valueType === EntityKeyValueType.NUMERIC || userInput.valueType === EntityKeyValueType.NUMERIC ||
userInput.valueType === EntityKeyValueType.DATE_TIME ? [Validators.required] : []] userInput.valueType === EntityKeyValueType.DATE_TIME ? [Validators.required] : []]
@ -96,19 +101,20 @@ export class UserFilterDialogComponent extends DialogComponent<UserFilterDialogC
userInputControl.get('value').valueChanges.pipe( userInputControl.get('value').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
).subscribe(userValue => { ).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; return userInputControl;
} }
userInputsFormArray(): UntypedFormArray { userInputsFormArray(): FormArray {
return this.userFilterFormGroup.get('userInputs') as UntypedFormArray; return this.userFilterFormGroup.get('userInputs') as FormArray;
} }
ngOnInit(): void { isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
}
isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const originalErrorState = this.errorStateMatcher.isErrorState(control, form); const originalErrorState = this.errorStateMatcher.isErrorState(control, form);
const customErrorState = !!(control && control.invalid && this.submitted); const customErrorState = !!(control && control.invalid && this.submitted);
return originalErrorState || customErrorState; return originalErrorState || customErrorState;

View File

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

View File

@ -2951,6 +2951,7 @@
"missing-key-filters-error": "Key filters is missing for filter '{{filter}}'.", "missing-key-filters-error": "Key filters is missing for filter '{{filter}}'.",
"filter": "Filter", "filter": "Filter",
"editable": "Editable", "editable": "Editable",
"editable-hint": "Allow user to change the filter value in dashboards.",
"no-filters-found": "No filters found.", "no-filters-found": "No filters found.",
"no-filter-text": "No filter specified", "no-filter-text": "No filter specified",
"add-filter-prompt": "Please add filter", "add-filter-prompt": "Please add filter",
@ -2991,7 +2992,9 @@
"user-parameters": "User parameters", "user-parameters": "User parameters",
"display-label": "Label to display", "display-label": "Label to display",
"autogenerated-label": "Auto generate label", "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-filter": "Key filter",
"key-filters": "Key filters", "key-filters": "Key filters",
"key-name": "Key name", "key-name": "Key name",
@ -3035,7 +3038,8 @@
"switch-to-dynamic-value": "Switch to dynamic value", "switch-to-dynamic-value": "Switch to dynamic value",
"switch-to-default-value": "Switch to default value", "switch-to-default-value": "Switch to default value",
"inherit-owner": "Inherit from owner", "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": { "fullscreen": {
"expand": "Expand to fullscreen", "expand": "Expand to fullscreen",