Browse Source

UI: Improve material icons selector

pull/8946/head
Igor Kulikov 3 years ago
parent
commit
d44f5fda5f
  1. 3
      ui-ngx/src/app/core/services/dialog.service.ts
  2. 2142
      ui-ngx/src/app/core/services/material-icons-codepoints.raw
  3. 7
      ui-ngx/src/app/core/services/resources.service.ts
  4. 49
      ui-ngx/src/app/core/services/utils.service.ts
  5. 2
      ui-ngx/src/app/modules/common/modules-map.ts
  6. 46
      ui-ngx/src/app/modules/home/components/widget/lib/home-page/home-page-widget.scss
  7. 71
      ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.html
  8. 35
      ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.scss
  9. 57
      ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.ts
  10. 12
      ui-ngx/src/app/shared/components/material-icon-select.component.html
  11. 31
      ui-ngx/src/app/shared/components/material-icon-select.component.scss
  12. 36
      ui-ngx/src/app/shared/components/material-icon-select.component.ts
  13. 65
      ui-ngx/src/app/shared/components/material-icons.component.html
  14. 61
      ui-ngx/src/app/shared/components/material-icons.component.scss
  15. 123
      ui-ngx/src/app/shared/components/material-icons.component.ts
  16. 20
      ui-ngx/src/app/shared/components/popover.component.ts
  17. 4
      ui-ngx/src/app/shared/components/popover.service.ts
  18. 1
      ui-ngx/src/app/shared/components/public-api.ts
  19. 50
      ui-ngx/src/app/shared/models/icon.models.ts
  20. 3
      ui-ngx/src/app/shared/shared.module.ts
  21. 8
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  22. 58
      ui-ngx/src/form.scss

3
ui-ngx/src/app/core/services/dialog.service.ts

@ -114,7 +114,8 @@ export class DialogService {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: { data: {
icon icon
} },
autoFocus: false
}).afterClosed(); }).afterClosed();
} }

2142
ui-ngx/src/app/core/services/material-icons-codepoints.raw

File diff suppressed because it is too large

7
ui-ngx/src/app/core/services/resources.service.ts

@ -62,15 +62,18 @@ export class ResourcesService {
this.store.pipe(select(selectIsAuthenticated)).subscribe(() => this.clearModulesCache()); this.store.pipe(select(selectIsAuthenticated)).subscribe(() => this.clearModulesCache());
} }
public loadJsonResource<T>(url: string): Observable<T> { public loadJsonResource<T>(url: string, postProcess?: (data: T) => T): Observable<T> {
if (this.loadedJsonResources[url]) { if (this.loadedJsonResources[url]) {
return this.loadedJsonResources[url].asObservable(); return this.loadedJsonResources[url].asObservable();
} }
const subject = new ReplaySubject<any>(); const subject = new ReplaySubject<any>();
this.loadedJsonResources[url] = subject; this.loadedJsonResources[url] = subject;
this.http.get(url).subscribe( this.http.get<T>(url).subscribe(
{ {
next: (o) => { next: (o) => {
if (postProcess) {
o = postProcess(o);
}
this.loadedJsonResources[url].next(o); this.loadedJsonResources[url].next(o);
this.loadedJsonResources[url].complete(); this.loadedJsonResources[url].complete();
}, },

49
ui-ngx/src/app/core/services/utils.service.ts

@ -21,32 +21,31 @@ import { Inject, Injectable, NgZone } from '@angular/core';
import { WINDOW } from '@core/services/window.service'; import { WINDOW } from '@core/services/window.service';
import { ExceptionData } from '@app/shared/models/error.models'; import { ExceptionData } from '@app/shared/models/error.models';
import { import {
base64toObj,
base64toString,
baseUrl, baseUrl,
createLabelFromDatasource, createLabelFromDatasource,
deepClone, deepClone,
deleteNullProperties, deleteNullProperties,
guid, hashCode, guid,
hashCode,
isDefined, isDefined,
isDefinedAndNotNull, isDefinedAndNotNull,
isString, isString,
isUndefined, isUndefined,
objToBase64, objToBase64,
objToBase64URI, objToBase64URI
base64toString,
base64toObj
} from '@core/utils'; } from '@core/utils';
import { WindowMessage } from '@shared/models/window-message.model'; import { WindowMessage } from '@shared/models/window-message.model';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { customTranslationsPrefix, i18nPrefix } from '@app/shared/models/constants'; import { customTranslationsPrefix, i18nPrefix } from '@app/shared/models/constants';
import { DataKey, Datasource, DatasourceType, KeyInfo } from '@shared/models/widget.models'; import { DataKey, Datasource, DatasourceType, KeyInfo } from '@shared/models/widget.models';
import { EntityType } from '@shared/models/entity-type.models';
import { DataKeyType } from '@app/shared/models/telemetry/telemetry.models'; import { DataKeyType } from '@app/shared/models/telemetry/telemetry.models';
import { alarmFields } from '@shared/models/alarm.models'; import { alarmFields } from '@shared/models/alarm.models';
import { materialColors } from '@app/shared/models/material.models'; import { materialColors } from '@app/shared/models/material.models';
import { WidgetInfo } from '@home/models/widget-component.models'; import { WidgetInfo } from '@home/models/widget-component.models';
import jsonSchemaDefaults from 'json-schema-defaults'; import jsonSchemaDefaults from 'json-schema-defaults';
import materialIconsCodepoints from '!raw-loader!./material-icons-codepoints.raw'; import { Observable } from 'rxjs';
import { Observable, of, ReplaySubject } from 'rxjs';
import { publishReplay, refCount } from 'rxjs/operators'; import { publishReplay, refCount } from 'rxjs/operators';
import { WidgetContext } from '@app/modules/home/models/widget-component.models'; import { WidgetContext } from '@app/modules/home/models/widget-component.models';
import { import {
@ -86,13 +85,6 @@ const defaultAlarmFields: Array<string> = [
alarmFields.status.keyName alarmFields.status.keyName
]; ];
const commonMaterialIcons: Array<string> = ['more_horiz', 'more_vert', 'open_in_new',
'visibility', 'play_arrow', 'arrow_back', 'arrow_downward',
'arrow_forward', 'arrow_upwards', 'close', 'refresh', 'menu', 'show_chart', 'multiline_chart', 'pie_chart', 'insert_chart', 'people',
'person', 'domain', 'devices_other', 'now_widgets', 'dashboards', 'map', 'pin_drop', 'my_location', 'extension', 'search',
'settings', 'notifications', 'notifications_active', 'info', 'info_outline', 'warning', 'list', 'file_download', 'import_export',
'share', 'add', 'edit', 'done', 'delete'];
// @dynamic // @dynamic
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -121,8 +113,6 @@ export class UtilsService {
defaultAlarmDataKeys: Array<DataKey> = []; defaultAlarmDataKeys: Array<DataKey> = [];
materialIcons: Array<string> = [];
constructor(@Inject(WINDOW) private window: Window, constructor(@Inject(WINDOW) private window: Window,
private zone: NgZone, private zone: NgZone,
private translate: TranslateService) { private translate: TranslateService) {
@ -306,31 +296,6 @@ export class UtilsService {
return datasources; return datasources;
} }
public getMaterialIcons(): Observable<Array<string>> {
if (this.materialIcons.length) {
return of(this.materialIcons);
} else {
const materialIconsSubject = new ReplaySubject<Array<string>>();
this.zone.runOutsideAngular(() => {
const codepointsArray = materialIconsCodepoints
.split('\n')
.filter((codepoint) => codepoint && codepoint.length);
codepointsArray.forEach((codepoint) => {
const values = codepoint.split(' ');
if (values && values.length === 2) {
this.materialIcons.push(values[0]);
}
});
materialIconsSubject.next(this.materialIcons);
});
return materialIconsSubject.asObservable();
}
}
public getCommonMaterialIcons(): Array<string> {
return commonMaterialIcons;
}
public getMaterialColor(index: number) { public getMaterialColor(index: number) {
const colorIndex = index % materialColors.length; const colorIndex = index % materialColors.length;
return materialColors[colorIndex].value; return materialColors[colorIndex].value;
@ -411,7 +376,7 @@ export class UtilsService {
public stringToHslColor(str: string, saturationPercentage: number, lightnessPercentage: number): string { public stringToHslColor(str: string, saturationPercentage: number, lightnessPercentage: number): string {
if (str && str.length) { if (str && str.length) {
let hue = hashCode(str) % 360; const hue = hashCode(str) % 360;
return `hsl(${hue}, ${saturationPercentage}%, ${lightnessPercentage}%)`; return `hsl(${hue}, ${saturationPercentage}%, ${lightnessPercentage}%)`;
} }
} }

2
ui-ngx/src/app/modules/common/modules-map.ts

@ -181,6 +181,7 @@ import * as StringItemsListComponent from '@shared/components/string-items-list.
import * as ToggleHeaderComponent from '@shared/components/toggle-header.component'; import * as ToggleHeaderComponent from '@shared/components/toggle-header.component';
import * as ToggleSelectComponent from '@shared/components/toggle-select.component'; import * as ToggleSelectComponent from '@shared/components/toggle-select.component';
import * as UnitInputComponent from '@shared/components/unit-input.component'; import * as UnitInputComponent from '@shared/components/unit-input.component';
import * as MaterialIconsComponent from '@shared/components/material-icons.component';
import * as AddEntityDialogComponent from '@home/components/entity/add-entity-dialog.component'; import * as AddEntityDialogComponent from '@home/components/entity/add-entity-dialog.component';
import * as EntitiesTableComponent from '@home/components/entity/entities-table.component'; import * as EntitiesTableComponent from '@home/components/entity/entities-table.component';
@ -482,6 +483,7 @@ class ModulesMap implements IModulesMap {
'@shared/components/toggle-header.component': ToggleHeaderComponent, '@shared/components/toggle-header.component': ToggleHeaderComponent,
'@shared/components/toggle-select.component': ToggleSelectComponent, '@shared/components/toggle-select.component': ToggleSelectComponent,
'@shared/components/unit-input.component': UnitInputComponent, '@shared/components/unit-input.component': UnitInputComponent,
'@shared/components/material-icons.component': MaterialIconsComponent,
'@home/components/entity/add-entity-dialog.component': AddEntityDialogComponent, '@home/components/entity/add-entity-dialog.component': AddEntityDialogComponent,
'@home/components/entity/entities-table.component': EntitiesTableComponent, '@home/components/entity/entities-table.component': EntitiesTableComponent,

46
ui-ngx/src/app/modules/home/components/widget/lib/home-page/home-page-widget.scss

@ -67,50 +67,4 @@
color: inherit; color: inherit;
} }
} }
.tb-no-data-available {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.tb-no-data-bg {
margin: 10px;
position: relative;
flex: 1;
width: 100%;
max-height: 100px;
&:before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #305680;
-webkit-mask-image: url(/assets/home/no_data_folder_bg.svg);
-webkit-mask-repeat: no-repeat;
-webkit-mask-size: contain;
-webkit-mask-position: center;
mask-image: url(/assets/home/no_data_folder_bg.svg);
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
}
}
.tb-no-data-text {
font-weight: 500;
font-size: 14px;
line-height: 20px;
letter-spacing: 0.25px;
color: rgba(0, 0, 0, 0.54);
@media #{$mat-md-lg} {
font-size: 12px;
line-height: 16px;
}
}
} }

71
ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.html

@ -15,63 +15,14 @@
limitations under the License. limitations under the License.
--> -->
<form class="tb-material-icons-dialog" style="min-width: 600px;"> <div mat-dialog-content>
<mat-toolbar fxLayout="row" color="primary"> <button class="tb-close-button"
<h2>{{ 'icon.select-icon' | translate }}</h2> mat-icon-button
<span fxFlex></span> (click)="cancel()"
<section fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> type="button">
<mat-slide-toggle [formControl]="showAllControl"> <mat-icon>close</mat-icon>
</mat-slide-toggle> </button>
<label translate>icon.show-all</label> <tb-material-icons [selectedIcon]="selectedIcon"
</section> (iconSelected)="selectIcon($event)">
<button mat-icon-button </tb-material-icons>
(click)="cancel()" </div>
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div class="tb-absolute-fill tb-icons-load" *ngIf="loadingIcons$ | async" fxLayout="column" fxLayoutAlign="center center">
<mat-spinner color="accent" mode="indeterminate" diameter="40"></mat-spinner>
</div>
<div mat-dialog-content>
<div class="mat-content mat-padding" fxLayout="column">
<fieldset [disabled]="(isLoading$ | async)">
<ng-template ngFor let-icon [ngForOf]="icons$ | async" let-last="last">
<ng-container #iconButtons>
<button *ngIf="icon === selectedIcon"
class="tb-select-icon-button"
mat-raised-button
color="primary"
(click)="selectIcon(icon)"
matTooltip="{{ icon }}"
matTooltipPosition="above"
type="button">
<mat-icon>{{icon}}</mat-icon>
</button>
<button *ngIf="icon !== selectedIcon"
class="tb-select-icon-button"
mat-button
(click)="selectIcon(icon)"
matTooltip="{{ icon }}"
matTooltipPosition="above"
type="button">
<mat-icon>{{icon}}</mat-icon>
</button>
</ng-container>
</ng-template>
</fieldset>
</div>
</div>
<div mat-dialog-actions fxLayout="row">
<span fxFlex></span>
<button mat-button
type="button"
[disabled]="(isLoading$ | async)"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
</div>
</form>

35
ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.scss

@ -14,36 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
:host { :host {
.tb-material-icons-dialog { .tb-close-button {
position: relative; position: absolute;
} top: 6px;
.tb-icons-load { right: 6px;
top: 64px;
z-index: 3;
background: rgba(255, 255, 255, .75);
}
}
:host ::ng-deep {
.tb-material-icons-dialog {
button.mat-mdc-button-base.tb-select-icon-button {
width: 56px;
min-width: 56px;
height: 56px;
padding: 16px;
margin: 10px;
border: solid 1px #ffa500;
border-radius: 0;
line-height: 0;
display: inline-block;
vertical-align: baseline;
.mat-icon {
width: 24px;
margin: 0;
height: 24px;
vertical-align: initial;
font-size: 24px;
}
}
} }
} }

57
ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.ts

@ -14,18 +14,12 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { AfterViewInit, Component, Inject, OnInit, QueryList, ViewChildren } from '@angular/core'; import { Component, Inject } from '@angular/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 { Router } from '@angular/router'; import { Router } from '@angular/router';
import { DialogComponent } from '@shared/components/dialog.component'; import { DialogComponent } from '@shared/components/dialog.component';
import { UtilsService } from '@core/services/utils.service';
import { UntypedFormControl } from '@angular/forms';
import { merge, Observable } from 'rxjs';
import { delay, map, mapTo, mergeMap, share, startWith, tap } from 'rxjs/operators';
import { ResourcesService } from '@core/services/resources.service';
import { getMaterialIcons } from '@shared/models/icon.models';
export interface MaterialIconsDialogData { export interface MaterialIconsDialogData {
icon: string; icon: string;
@ -37,63 +31,16 @@ export interface MaterialIconsDialogData {
providers: [], providers: [],
styleUrls: ['./material-icons-dialog.component.scss'] styleUrls: ['./material-icons-dialog.component.scss']
}) })
export class MaterialIconsDialogComponent extends DialogComponent<MaterialIconsDialogComponent, string> export class MaterialIconsDialogComponent extends DialogComponent<MaterialIconsDialogComponent, string> {
implements OnInit, AfterViewInit {
@ViewChildren('iconButtons') iconButtons: QueryList<HTMLElement>;
selectedIcon: string; selectedIcon: string;
icons$: Observable<Array<string>>;
loadingIcons$: Observable<boolean>;
showAllControl: UntypedFormControl;
constructor(protected store: Store<AppState>, constructor(protected store: Store<AppState>,
protected router: Router, protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: MaterialIconsDialogData, @Inject(MAT_DIALOG_DATA) public data: MaterialIconsDialogData,
private utils: UtilsService,
private resourcesService: ResourcesService,
public dialogRef: MatDialogRef<MaterialIconsDialogComponent, string>) { public dialogRef: MatDialogRef<MaterialIconsDialogComponent, string>) {
super(store, router, dialogRef); super(store, router, dialogRef);
this.selectedIcon = data.icon; this.selectedIcon = data.icon;
this.showAllControl = new UntypedFormControl(false);
}
ngOnInit(): void {
this.icons$ = this.showAllControl.valueChanges.pipe(
map((showAll) => ({firstTime: false, showAll})),
startWith<{firstTime: boolean; showAll: boolean}>({firstTime: true, showAll: false}),
mergeMap((data) => {
const res = getMaterialIcons(this.resourcesService, data.showAll, '');
if (data.showAll) {
return res.pipe(delay(100));
} else {
return data.firstTime ? res : res.pipe(delay(50));
}
}),
share()
);
}
ngAfterViewInit(): void {
this.loadingIcons$ = merge(
this.showAllControl.valueChanges.pipe(
mapTo(true),
),
this.iconButtons.changes.pipe(
delay(100),
mapTo( false),
)
).pipe(
tap((loadingIcons) => {
if (loadingIcons) {
this.showAllControl.disable({emitEvent: false});
} else {
this.showAllControl.enable({emitEvent: false});
}
}),
share()
);
} }
selectIcon(icon: string) { selectIcon(icon: string) {

12
ui-ngx/src/app/shared/components/material-icon-select.component.html

@ -29,7 +29,13 @@
</mat-form-field> </mat-form-field>
</div> </div>
<ng-template #boxInput> <ng-template #boxInput>
<mat-icon class="icon-box" [ngStyle]="color && !disabled ? { color: color } : {}" <button type="button"
[ngClass]="{'disabled': disabled}" mat-stroked-button
(click)="openIconDialog()">{{materialIconFormGroup.get('icon').value}}</mat-icon> class="icon-box"
[ngStyle]="color && !disabled ? { color: color } : {}"
[disabled]="disabled"
#matButton
(click)="openIconPopup($event, matButton)">
<mat-icon>{{materialIconFormGroup.get('icon').value}}</mat-icon>
</button>
</ng-template> </ng-template>

31
ui-ngx/src/app/shared/components/material-icon-select.component.scss

@ -22,20 +22,23 @@
border: solid 1px rgba(0, 0, 0, .27); border: solid 1px rgba(0, 0, 0, .27);
box-sizing: initial; box-sizing: initial;
} }
&.icon-box { }
border: 1px solid rgba(0, 0, 0, 0.12); }
border-radius: 4px;
cursor: pointer; :host ::ng-deep {
box-sizing: border-box; button.mat-mdc-button-base.icon-box {
padding: 8px; width: 40px;
height: 40px; min-width: 40px;
width: 40px; height: 40px;
font-size: 22px; padding: 7px;
vertical-align: middle; &:not(:disabled) {
&.disabled { color: rgba(0, 0, 0, 0.87);
cursor: initial; }
color: rgba(0, 0, 0, 0.38); > .mat-icon {
} width: 24px;
height: 24px;
font-size: 24px;
margin: 0;
} }
} }
} }

36
ui-ngx/src/app/shared/components/material-icon-select.component.ts

@ -14,15 +14,18 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, forwardRef, Input, OnInit, Renderer2, ViewContainerRef } from '@angular/core';
import { PageComponent } from '@shared/components/page.component'; import { PageComponent } from '@shared/components/page.component';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { DialogService } from '@core/services/dialog.service'; import { DialogService } from '@core/services/dialog.service';
import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { coerceBoolean } from '@shared/decorators/coercion'; import { coerceBoolean } from '@shared/decorators/coercion';
import { TbPopoverService } from '@shared/components/popover.service';
import { MaterialIconsComponent } from '@shared/components/material-icons.component';
import { MatButton } from '@angular/material/button';
@Component({ @Component({
selector: 'tb-material-icon-select', selector: 'tb-material-icon-select',
@ -81,6 +84,9 @@ export class MaterialIconSelectComponent extends PageComponent implements OnInit
constructor(protected store: Store<AppState>, constructor(protected store: Store<AppState>,
private dialogs: DialogService, private dialogs: DialogService,
private translate: TranslateService, private translate: TranslateService,
private popoverService: TbPopoverService,
private renderer: Renderer2,
private viewContainerRef: ViewContainerRef,
private fb: UntypedFormBuilder, private fb: UntypedFormBuilder,
private cd: ChangeDetectorRef) { private cd: ChangeDetectorRef) {
super(store); super(store);
@ -142,6 +148,32 @@ export class MaterialIconSelectComponent extends PageComponent implements OnInit
} }
} }
openIconPopup($event: Event, matButton: MatButton) {
if ($event) {
$event.stopPropagation();
}
const trigger = matButton._elementRef.nativeElement;
if (this.popoverService.hasPopover(trigger)) {
this.popoverService.hidePopover(trigger);
} else {
const materialIconsPopover = this.popoverService.displayPopover(trigger, this.renderer,
this.viewContainerRef, MaterialIconsComponent, 'left', true, null,
{
selectedIcon: this.materialIconFormGroup.get('icon').value
},
{},
{}, {}, true);
materialIconsPopover.tbComponentRef.instance.popover = materialIconsPopover;
materialIconsPopover.tbComponentRef.instance.iconSelected.subscribe((icon) => {
materialIconsPopover.hide();
this.materialIconFormGroup.patchValue(
{icon}, {emitEvent: true}
);
this.cd.markForCheck();
});
}
}
clear() { clear() {
this.materialIconFormGroup.get('icon').patchValue(null, {emitEvent: true}); this.materialIconFormGroup.get('icon').patchValue(null, {emitEvent: true});
this.cd.markForCheck(); this.cd.markForCheck();

65
ui-ngx/src/app/shared/components/material-icons.component.html

@ -0,0 +1,65 @@
<!--
Copyright © 2016-2023 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-material-icons-panel">
<div class="tb-material-icons-title" translate>icon.icons</div>
<mat-form-field class="tb-material-icons-search tb-inline-field" appearance="outline" subscriptSizing="dynamic">
<mat-icon matPrefix>search</mat-icon>
<input matInput [formControl]="searchIconControl" placeholder="{{ 'icon.search-icon' | translate }}"/>
<button *ngIf="searchIconControl.value"
type="button"
matSuffix mat-icon-button aria-label="Clear"
(click)="clearSearch()">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-form-field>
<cdk-virtual-scroll-viewport [fxShow]="!notFound" #iconsPanel
[itemSize]="iconsRowHeight" class="tb-material-icons-viewport" [ngStyle]="{width: iconsPanelWidth, height: iconsPanelHeight}">
<div *cdkVirtualFor="let iconRow of iconRows$ | async" class="tb-material-icons-row">
<ng-container *ngFor="let icon of iconRow">
<button *ngIf="icon.name === selectedIcon"
class="tb-select-icon-button"
mat-raised-button
color="primary"
(click)="selectIcon(icon)"
matTooltip="{{ icon.displayName }}"
matTooltipPosition="above"
type="button">
<mat-icon>{{icon.name}}</mat-icon>
</button>
<button *ngIf="icon.name !== selectedIcon"
class="tb-select-icon-button"
mat-button
(click)="selectIcon(icon)"
matTooltip="{{ icon.displayName }}"
matTooltipPosition="above"
type="button">
<mat-icon>{{icon.name}}</mat-icon>
</button>
</ng-container>
</div>
</cdk-virtual-scroll-viewport>
<button *ngIf="!showAllSubject.value" class="tb-material-icons-show-more" mat-button color="primary" (click)="showAllSubject.next(true)">
{{ 'action.show-more' | translate }}
</button>
<ng-container *ngIf="notFound">
<div class="tb-no-data-available" [ngStyle]="{width: iconsPanelWidth}">
<div class="tb-no-data-bg"></div>
<div class="tb-no-data-text">{{ 'icon.no-icons-found' | translate:{iconSearch: searchIconControl.value} }}</div>
</div>
</ng-container>
</div>

61
ui-ngx/src/app/shared/components/material-icons.component.scss

@ -0,0 +1,61 @@
/**
* Copyright © 2016-2023 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-material-icons-panel {
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
.tb-material-icons-title {
font-size: 16px;
font-weight: 500;
line-height: 24px;
letter-spacing: 0.25px;
color: rgba(0, 0, 0, 0.87);
}
.tb-material-icons-title, .tb-material-icons-search, .tb-material-icons-show-more {
width: 100%;
}
.tb-material-icons-viewport {
min-height: 144px;
}
.tb-material-icons-row {
display: flex;
flex-direction: row;
gap: 12px;
}
.tb-material-icons-row + .tb-material-icons-row {
margin-top: 12px;
}
.tb-no-data-available {
min-height: 144px;
}
button.mat-mdc-button-base.tb-select-icon-button {
width: 36px;
min-width: 36px;
height: 36px;
padding: 6px;
&:not(.mat-primary) {
color: rgba(0, 0, 0, 0.54);
}
> .mat-icon {
width: 24px;
height: 24px;
font-size: 24px;
margin: 0;
}
}
}

123
ui-ngx/src/app/shared/components/material-icons.component.ts

@ -1,24 +1,135 @@
///
/// Copyright © 2016-2023 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 { PageComponent } from '@shared/components/page.component'; import { PageComponent } from '@shared/components/page.component';
import { OnInit } from '@angular/core'; import {
ChangeDetectorRef,
Component,
EventEmitter,
Input,
OnInit,
Output,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { UntypedFormControl } from '@angular/forms'; import { UntypedFormControl } from '@angular/forms';
import { BehaviorSubject, Observable, ReplaySubject } from 'rxjs'; import { BehaviorSubject, combineLatest, debounce, Observable, of, timer } from 'rxjs';
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
import { getMaterialIcons, MaterialIcon } from '@shared/models/icon.models';
import { distinctUntilChanged, map, mergeMap, share, startWith, tap } from 'rxjs/operators';
import { ResourcesService } from '@core/services/resources.service';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { BreakpointObserver } from '@angular/cdk/layout';
import { MediaBreakpoints } from '@shared/models/constants';
@Component({
selector: 'tb-material-icons',
templateUrl: './material-icons.component.html',
providers: [],
styleUrls: ['./material-icons.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class MaterialIconsComponent extends PageComponent implements OnInit { export class MaterialIconsComponent extends PageComponent implements OnInit {
searchIconsControl: UntypedFormControl; @ViewChild('iconsPanel')
iconsPanel: CdkVirtualScrollViewport;
@Input()
selectedIcon: string;
@Input()
popover: TbPopoverComponent<MaterialIconsComponent>;
@Output()
iconSelected = new EventEmitter<string>();
iconRows$: Observable<MaterialIcon[][]>;
showAllSubject = new BehaviorSubject<boolean>(false); showAllSubject = new BehaviorSubject<boolean>(false);
searchIconControl: UntypedFormControl;
iconsRowHeight = 48;
iconsPanelHeight: string;
iconsPanelWidth: string;
icons$: Observable<Array<string>>; notFound = false;
constructor(protected store: Store<AppState>) { constructor(protected store: Store<AppState>,
private resourcesService: ResourcesService,
private breakpointObserver: BreakpointObserver,
private cd: ChangeDetectorRef) {
super(store); super(store);
this.searchIconsControl = new UntypedFormControl(''); this.searchIconControl = new UntypedFormControl('');
} }
ngOnInit(): void { ngOnInit(): void {
const iconsRowSize = this.breakpointObserver.isMatched(MediaBreakpoints['lt-md']) ? 8 : 11;
this.calculatePanelSize(iconsRowSize);
const iconsRowSizeObservable = this.breakpointObserver
.observe(MediaBreakpoints['lt-md']).pipe(
map((state) => state.matches ? 8 : 11),
startWith(iconsRowSize),
);
this.iconRows$ = combineLatest({showAll: this.showAllSubject.asObservable(),
rowSize: iconsRowSizeObservable,
searchText: this.searchIconControl.valueChanges.pipe(
startWith(''),
debounce((searchText) => searchText ? timer(150) : of({})),
)}).pipe(
map((data) => {
if (data.searchText && !data.showAll) {
data.showAll = true;
this.showAllSubject.next(true);
}
return data;
}),
distinctUntilChanged((p, c) => c.showAll === p.showAll && c.searchText === p.searchText && c.rowSize === p.rowSize),
mergeMap((data) => getMaterialIcons(this.resourcesService, data.rowSize, data.showAll, data.searchText).pipe(
map(iconRows => ({iconRows, iconsRowSize: data.rowSize}))
)),
tap((data) => {
this.notFound = !data.iconRows.length;
this.calculatePanelSize(data.iconsRowSize, data.iconRows.length);
this.cd.markForCheck();
setTimeout(() => {
this.checkSize();
}, 0);
}),
map((data) => data.iconRows),
share()
);
}
clearSearch() {
this.searchIconControl.patchValue('', {emitEvent: true});
}
selectIcon(icon: MaterialIcon) {
this.iconSelected.emit(icon.name);
} }
private calculatePanelSize(iconsRowSize: number, iconRows = 4) {
this.iconsPanelHeight = Math.min(iconRows * this.iconsRowHeight, 10 * this.iconsRowHeight) + 'px';
this.iconsPanelWidth = (iconsRowSize * 36 + (iconsRowSize - 1) * 12 + 6) + 'px';
}
private checkSize() {
this.iconsPanel?.checkViewportSize();
this.popover?.updatePosition();
}
} }

20
ui-ngx/src/app/shared/components/popover.component.ts

@ -63,8 +63,10 @@ import { coerceBoolean } from '@shared/decorators/coercion';
export type TbPopoverTrigger = 'click' | 'focus' | 'hover' | null; export type TbPopoverTrigger = 'click' | 'focus' | 'hover' | null;
@Directive({ @Directive({
// eslint-disable-next-line @angular-eslint/directive-selector
selector: '[tb-popover]', selector: '[tb-popover]',
exportAs: 'tbPopover', exportAs: 'tbPopover',
// eslint-disable-next-line @angular-eslint/no-host-metadata-property
host: { host: {
'[class.tb-popover-open]': 'visible' '[class.tb-popover-open]': 'visible'
} }
@ -265,12 +267,20 @@ export class TbPopoverDirective implements OnChanges, OnDestroy, AfterViewInit {
} else if (delay > 0) { } else if (delay > 0) {
this.delayTimer = setTimeout(() => { this.delayTimer = setTimeout(() => {
this.delayTimer = undefined; this.delayTimer = undefined;
isEnter ? this.show() : this.hide(); if (isEnter) {
this.show();
} else {
this.hide();
}
}, delay * 1000); }, delay * 1000);
} else { } else {
// `isOrigin` is used due to the tooltip will not hide immediately // `isOrigin` is used due to the tooltip will not hide immediately
// (may caused by the fade-out animation). // (may caused by the fade-out animation).
isEnter && isOrigin ? this.show() : this.hide(); if (isEnter && isOrigin) {
this.show();
} else {
this.hide();
}
} }
} }
@ -345,15 +355,15 @@ export class TbPopoverDirective implements OnChanges, OnDestroy, AfterViewInit {
</ng-template> </ng-template>
` `
}) })
export class TbPopoverComponent implements OnDestroy, OnInit { export class TbPopoverComponent<T = any> implements OnDestroy, OnInit {
@ViewChild('overlay', { static: false }) overlay!: CdkConnectedOverlay; @ViewChild('overlay', { static: false }) overlay!: CdkConnectedOverlay;
@ViewChild('popoverRoot', { static: false }) popoverRoot!: ElementRef<HTMLElement>; @ViewChild('popoverRoot', { static: false }) popoverRoot!: ElementRef<HTMLElement>;
@ViewChild('popover', { static: false }) popover!: ElementRef<HTMLElement>; @ViewChild('popover', { static: false }) popover!: ElementRef<HTMLElement>;
tbContent: string | TemplateRef<void> | null = null; tbContent: string | TemplateRef<void> | null = null;
tbComponentFactory: ComponentFactory<any> | null = null; tbComponentFactory: ComponentFactory<T> | null = null;
tbComponentRef: ComponentRef<any> | null = null; tbComponentRef: ComponentRef<T> | null = null;
tbComponentContext: any; tbComponentContext: any;
tbComponentInjector: Injector | null = null; tbComponentInjector: Injector | null = null;
tbComponentStyle: { [klass: string]: any } = {}; tbComponentStyle: { [klass: string]: any } = {};

4
ui-ngx/src/app/shared/components/popover.service.ts

@ -65,7 +65,7 @@ export class TbPopoverService {
displayPopover<T>(trigger: Element, renderer: Renderer2, hostView: ViewContainerRef, displayPopover<T>(trigger: Element, renderer: Renderer2, hostView: ViewContainerRef,
componentType: Type<T>, preferredPlacement: PopoverPlacement = 'top', hideOnClickOutside = true, componentType: Type<T>, preferredPlacement: PopoverPlacement = 'top', hideOnClickOutside = true,
injector?: Injector, context?: any, overlayStyle: any = {}, popoverStyle: any = {}, style?: any, injector?: Injector, context?: any, overlayStyle: any = {}, popoverStyle: any = {}, style?: any,
showCloseButton = true): TbPopoverComponent { showCloseButton = true): TbPopoverComponent<T> {
const componentRef = this.createPopoverRef(hostView); const componentRef = this.createPopoverRef(hostView);
return this.displayPopoverWithComponentRef(componentRef, trigger, renderer, componentType, preferredPlacement, hideOnClickOutside, return this.displayPopoverWithComponentRef(componentRef, trigger, renderer, componentType, preferredPlacement, hideOnClickOutside,
injector, context, overlayStyle, popoverStyle, style, showCloseButton); injector, context, overlayStyle, popoverStyle, style, showCloseButton);
@ -74,7 +74,7 @@ export class TbPopoverService {
displayPopoverWithComponentRef<T>(componentRef: ComponentRef<TbPopoverComponent>, trigger: Element, renderer: Renderer2, displayPopoverWithComponentRef<T>(componentRef: ComponentRef<TbPopoverComponent>, trigger: Element, renderer: Renderer2,
componentType: Type<T>, preferredPlacement: PopoverPlacement = 'top', componentType: Type<T>, preferredPlacement: PopoverPlacement = 'top',
hideOnClickOutside = true, injector?: Injector, context?: any, overlayStyle: any = {}, hideOnClickOutside = true, injector?: Injector, context?: any, overlayStyle: any = {},
popoverStyle: any = {}, style?: any, showCloseButton = true): TbPopoverComponent { popoverStyle: any = {}, style?: any, showCloseButton = true): TbPopoverComponent<T> {
const component = componentRef.instance; const component = componentRef.instance;
this.popoverWithTriggers.push({ this.popoverWithTriggers.push({
trigger, trigger,

1
ui-ngx/src/app/shared/components/public-api.ts

@ -26,3 +26,4 @@ export * from './resource/resource-autocomplete.component';
export * from './toggle-header.component'; export * from './toggle-header.component';
export * from './toggle-select.component'; export * from './toggle-select.component';
export * from './unit-input.component'; export * from './unit-input.component';
export * from './material-icons.component';

50
ui-ngx/src/app/shared/models/icon.models.ts

@ -1,11 +1,27 @@
import { Unit, units } from '@shared/models/unit.models'; ///
/// Copyright © 2016-2023 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 { ResourcesService } from '@core/services/resources.service'; import { ResourcesService } from '@core/services/resources.service';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { isEmptyStr, isNotEmptyStr } from '@core/utils'; import { isNotEmptyStr } from '@core/utils';
export interface MaterialIcon { export interface MaterialIcon {
name: string; name: string;
displayName?: string;
tags: string[]; tags: string[];
} }
@ -16,21 +32,41 @@ const searchIconTags = (icon: MaterialIcon, searchText: string): boolean =>
const searchIcons = (_icons: Array<MaterialIcon>, searchText: string): Array<MaterialIcon> => _icons.filter( const searchIcons = (_icons: Array<MaterialIcon>, searchText: string): Array<MaterialIcon> => _icons.filter(
i => i.name.toUpperCase().includes(searchText.toUpperCase()) || i => i.name.toUpperCase().includes(searchText.toUpperCase()) ||
i.displayName.toUpperCase().includes(searchText.toUpperCase()) ||
searchIconTags(i, searchText) searchIconTags(i, searchText)
); );
const getCommonMaterialIcons = (icons: Array<MaterialIcon>): Array<MaterialIcon> => icons.slice(0, 44); const getCommonMaterialIcons = (icons: Array<MaterialIcon>, chunkSize: number): Array<MaterialIcon> => icons.slice(0, chunkSize * 4);
export const getMaterialIcons = (resourcesService: ResourcesService, all = false, searchText: string): Observable<string[]> => export const getMaterialIcons = (resourcesService: ResourcesService, chunkSize = 11,
resourcesService.loadJsonResource<Array<MaterialIcon>>('/assets/metadata/material-icons.json').pipe( all = false, searchText: string): Observable<MaterialIcon[][]> =>
resourcesService.loadJsonResource<Array<MaterialIcon>>('/assets/metadata/material-icons.json',
(icons) => {
for (const icon of icons) {
const words = icon.name.replace(/_/g, ' ').split(' ');
for (let i = 0; i < words.length; i++) {
words[i] = words[i].charAt(0).toUpperCase() + words[i].slice(1);
}
icon.displayName = words.join(' ');
}
return icons;
}
).pipe(
map((icons) => { map((icons) => {
if (isNotEmptyStr(searchText)) { if (isNotEmptyStr(searchText)) {
return searchIcons(icons, searchText); return searchIcons(icons, searchText);
} else if (!all) { } else if (!all) {
return getCommonMaterialIcons(icons); return getCommonMaterialIcons(icons, chunkSize);
} else { } else {
return icons; return icons;
} }
}), }),
map((icons) => icons.map(icon => icon.name)) map((icons) => {
const iconChunks: MaterialIcon[][] = [];
for (let i = 0; i < icons.length; i += chunkSize) {
const chunk = icons.slice(i, i + chunkSize);
iconChunks.push(chunk);
}
return iconChunks;
})
); );

3
ui-ngx/src/app/shared/shared.module.ts

@ -195,6 +195,7 @@ import { ToggleHeaderComponent, ToggleOption } from '@shared/components/toggle-h
import { RuleChainSelectComponent } from '@shared/components/rule-chain/rule-chain-select.component'; import { RuleChainSelectComponent } from '@shared/components/rule-chain/rule-chain-select.component';
import { ToggleSelectComponent } from '@shared/components/toggle-select.component'; import { ToggleSelectComponent } from '@shared/components/toggle-select.component';
import { UnitInputComponent } from '@shared/components/unit-input.component'; import { UnitInputComponent } from '@shared/components/unit-input.component';
import { MaterialIconsComponent } from '@shared/components/material-icons.component';
export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) {
return markedOptionsService; return markedOptionsService;
@ -369,6 +370,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
ToggleOption, ToggleOption,
ToggleSelectComponent, ToggleSelectComponent,
UnitInputComponent, UnitInputComponent,
MaterialIconsComponent,
RuleChainSelectComponent RuleChainSelectComponent
], ],
imports: [ imports: [
@ -600,6 +602,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
ToggleOption, ToggleOption,
ToggleSelectComponent, ToggleSelectComponent,
UnitInputComponent, UnitInputComponent,
MaterialIconsComponent,
RuleChainSelectComponent RuleChainSelectComponent
] ]
}) })

8
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -68,7 +68,8 @@
"less": "Less", "less": "Less",
"skip": "Skip", "skip": "Skip",
"send": "Send", "send": "Send",
"reset": "Reset" "reset": "Reset",
"show-more": "Show more"
}, },
"aggregation": { "aggregation": {
"aggregation": "Aggregation", "aggregation": "Aggregation",
@ -5501,9 +5502,12 @@
}, },
"icon": { "icon": {
"icon": "Icon", "icon": "Icon",
"icons": "Icons",
"select-icon": "Select icon", "select-icon": "Select icon",
"material-icons": "Material icons", "material-icons": "Material icons",
"show-all": "Show all icons" "show-all": "Show all icons",
"search-icon": "Search icon",
"no-icons-found": "No icons found for '{{iconSearch}}'"
}, },
"phone-input": { "phone-input": {
"phone-input-label": "Phone number", "phone-input-label": "Phone number",

58
ui-ngx/src/form.scss

@ -13,6 +13,9 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
@import './scss/constants';
.tb-default, .tb-dark { .tb-default, .tb-dark {
.tb-form-panel { .tb-form-panel {
box-shadow: 0 0 10px 6px rgba(11, 17, 51, 0.04); box-shadow: 0 0 10px 6px rgba(11, 17, 51, 0.04);
@ -177,6 +180,13 @@
opacity: 0; opacity: 0;
} }
} }
&:not(.mat-mdc-form-field-has-icon-prefix) {
.mat-mdc-text-field-wrapper {
&.mdc-text-field--outlined, &:not(.mdc-text-field--outlined) {
padding-left: 12px;
}
}
}
&:not(.mat-mdc-form-field-has-icon-suffix) { &:not(.mat-mdc-form-field-has-icon-suffix) {
.mat-mdc-text-field-wrapper { .mat-mdc-text-field-wrapper {
&.mdc-text-field--outlined, &:not(.mdc-text-field--outlined) { &.mdc-text-field--outlined, &:not(.mdc-text-field--outlined) {
@ -186,7 +196,6 @@
} }
.mat-mdc-text-field-wrapper { .mat-mdc-text-field-wrapper {
&.mdc-text-field--outlined, &:not(.mdc-text-field--outlined) { &.mdc-text-field--outlined, &:not(.mdc-text-field--outlined) {
padding-left: 12px;
&:not(.mdc-text-field--focused):not(.mdc-text-field--disabled):not(:hover) { &:not(.mdc-text-field--focused):not(.mdc-text-field--disabled):not(:hover) {
.mdc-notched-outline__leading, .mdc-notched-outline__trailing { .mdc-notched-outline__leading, .mdc-notched-outline__trailing {
border-color: rgba(0, 0, 0, 0.12); border-color: rgba(0, 0, 0, 0.12);
@ -203,7 +212,7 @@
line-height: 20px; line-height: 20px;
} }
} }
.mat-mdc-form-field-icon-suffix { .mat-mdc-form-field-icon-prefix, .mat-mdc-form-field-icon-suffix {
height: 40px; height: 40px;
font-size: 14px; font-size: 14px;
line-height: 40px; line-height: 40px;
@ -336,4 +345,49 @@
} }
} }
} }
.tb-no-data-available {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.tb-no-data-bg {
margin: 10px;
position: relative;
flex: 1;
width: 100%;
max-height: 100px;
&:before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #305680;
-webkit-mask-image: url(/assets/home/no_data_folder_bg.svg);
-webkit-mask-repeat: no-repeat;
-webkit-mask-size: contain;
-webkit-mask-position: center;
mask-image: url(/assets/home/no_data_folder_bg.svg);
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
}
}
.tb-no-data-text {
font-weight: 500;
font-size: 14px;
line-height: 20px;
letter-spacing: 0.25px;
color: rgba(0, 0, 0, 0.54);
@media #{$mat-md-lg} {
font-size: 12px;
line-height: 16px;
}
}
} }

Loading…
Cancel
Save