Browse Source

feat(iot-hub): add dynamic filter search, grouping, and scrollbar to widget select filter panel

Add inline search inputs for filter panels with >= 10 items, "Most popular"
grouping for use cases with >= 11 items, scrollable filter items containers
with hover-visible scrollbar, and totalItems filtering. Convert *ngFor/*ngIf
to new Angular @for/@if flow syntax in the filter panel.
pull/15508/head
Igor Kulikov 6 months ago
parent
commit
33775d390e
  1. 111
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html
  2. 122
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss
  3. 37
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts

111
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html

@ -200,19 +200,41 @@
<div class="tb-iot-hub-filter-header"> <div class="tb-iot-hub-filter-header">
<span class="tb-iot-hub-filter-title">{{ 'iot-hub.filters' | translate }}</span> <span class="tb-iot-hub-filter-title">{{ 'iot-hub.filters' | translate }}</span>
</div> </div>
<div class="tb-iot-hub-filter-sections"> <mat-divider></mat-divider>
<div class="tb-iot-hub-filter-sections" [class.filter-items-hovered]="iotHubFilterItemsHovered">
<mat-expansion-panel [expanded]="true"> <mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
{{ 'iot-hub.type' | translate }} {{ 'iot-hub.type' | translate }}
<span *ngIf="iotHubPendingWidgetTypes.size" class="tb-iot-hub-filter-badge">{{ iotHubPendingWidgetTypes.size }}</span> @if (iotHubPendingWidgetTypes.size) {
<span class="tb-iot-hub-filter-badge">{{ iotHubPendingWidgetTypes.size }}</span>
}
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<div *ngFor="let opt of iotHubWidgetTypeOptions" class="tb-iot-hub-filter-option"> @if (iotHubWidgetTypeOptions.length >= 10) {
<mat-checkbox [checked]="iotHubPendingWidgetTypes.has(opt.key)" <div class="tb-iot-hub-filter-search">
(change)="toggleIotHubWidgetType(opt.key)"> <mat-icon>search</mat-icon>
{{ getIotHubWidgetTypeLabel(opt.key) }} <input [placeholder]="'iot-hub.search' | translate"
</mat-checkbox> [ngModel]="iotHubFilterSearch['widgetType']"
(ngModelChange)="iotHubFilterSearch['widgetType'] = $event">
@if (iotHubFilterSearch['widgetType']) {
<button mat-icon-button class="tb-iot-hub-filter-search-clear" (click)="iotHubFilterSearch['widgetType'] = ''">
<mat-icon>close</mat-icon>
</button>
}
</div>
}
<div class="tb-iot-hub-filter-items" [class.scrollable]="getFilteredIotHubItems(iotHubWidgetTypeOptions, 'widgetType').length > 8"
(mouseenter)="getFilteredIotHubItems(iotHubWidgetTypeOptions, 'widgetType').length > 8 && (iotHubFilterItemsHovered = true)"
(mouseleave)="iotHubFilterItemsHovered = false">
@for (opt of getFilteredIotHubItems(iotHubWidgetTypeOptions, 'widgetType'); track opt.key) {
<div class="tb-iot-hub-filter-option">
<mat-checkbox [checked]="iotHubPendingWidgetTypes.has(opt.key)"
(change)="toggleIotHubWidgetType(opt.key)">
{{ getIotHubWidgetTypeLabel(opt.key) }}
</mat-checkbox>
</div>
}
</div> </div>
</mat-expansion-panel> </mat-expansion-panel>
<mat-divider></mat-divider> <mat-divider></mat-divider>
@ -220,14 +242,35 @@
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
{{ 'iot-hub.category' | translate }} {{ 'iot-hub.category' | translate }}
<span *ngIf="iotHubPendingCategories.size" class="tb-iot-hub-filter-badge">{{ iotHubPendingCategories.size }}</span> @if (iotHubPendingCategories.size) {
<span class="tb-iot-hub-filter-badge">{{ iotHubPendingCategories.size }}</span>
}
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<div *ngFor="let opt of iotHubCategoryOptions" class="tb-iot-hub-filter-option"> @if (iotHubCategoryOptions.length >= 10) {
<mat-checkbox [checked]="iotHubPendingCategories.has(opt.key)" <div class="tb-iot-hub-filter-search">
(change)="toggleIotHubCategory(opt.key)"> <mat-icon>search</mat-icon>
{{ opt.key }} <input [placeholder]="'iot-hub.search' | translate"
</mat-checkbox> [ngModel]="iotHubFilterSearch['category']"
(ngModelChange)="iotHubFilterSearch['category'] = $event">
@if (iotHubFilterSearch['category']) {
<button mat-icon-button class="tb-iot-hub-filter-search-clear" (click)="iotHubFilterSearch['category'] = ''">
<mat-icon>close</mat-icon>
</button>
}
</div>
}
<div class="tb-iot-hub-filter-items" [class.scrollable]="getFilteredIotHubItems(iotHubCategoryOptions, 'category').length > 8"
(mouseenter)="getFilteredIotHubItems(iotHubCategoryOptions, 'category').length > 8 && (iotHubFilterItemsHovered = true)"
(mouseleave)="iotHubFilterItemsHovered = false">
@for (opt of getFilteredIotHubItems(iotHubCategoryOptions, 'category'); track opt.key) {
<div class="tb-iot-hub-filter-option">
<mat-checkbox [checked]="iotHubPendingCategories.has(opt.key)"
(change)="toggleIotHubCategory(opt.key)">
{{ opt.key }}
</mat-checkbox>
</div>
}
</div> </div>
</mat-expansion-panel> </mat-expansion-panel>
<mat-divider></mat-divider> <mat-divider></mat-divider>
@ -235,19 +278,47 @@
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
{{ 'iot-hub.use-case' | translate }} {{ 'iot-hub.use-case' | translate }}
<span *ngIf="iotHubPendingUseCases.size" class="tb-iot-hub-filter-badge">{{ iotHubPendingUseCases.size }}</span> @if (iotHubPendingUseCases.size) {
<span class="tb-iot-hub-filter-badge">{{ iotHubPendingUseCases.size }}</span>
}
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<div *ngFor="let opt of iotHubUseCaseOptions" class="tb-iot-hub-filter-option"> @if (iotHubUseCaseOptions.length >= 10) {
<mat-checkbox [checked]="iotHubPendingUseCases.has(opt.key)" <div class="tb-iot-hub-filter-search">
(change)="toggleIotHubUseCase(opt.key)"> <mat-icon>search</mat-icon>
{{ opt.key }} <input [placeholder]="'iot-hub.search' | translate"
</mat-checkbox> [ngModel]="iotHubFilterSearch['useCase']"
(ngModelChange)="iotHubFilterSearch['useCase'] = $event">
@if (iotHubFilterSearch['useCase']) {
<button mat-icon-button class="tb-iot-hub-filter-search-clear" (click)="iotHubFilterSearch['useCase'] = ''">
<mat-icon>close</mat-icon>
</button>
}
</div>
}
<div class="tb-iot-hub-filter-items" [class.scrollable]="getFilteredIotHubItems(iotHubUseCaseOptions, 'useCase').length > 8"
(mouseenter)="getFilteredIotHubItems(iotHubUseCaseOptions, 'useCase').length > 8 && (iotHubFilterItemsHovered = true)"
(mouseleave)="iotHubFilterItemsHovered = false">
@for (group of getGroupedIotHubFilterItems(iotHubUseCaseOptions, 'useCase'); track group.label) {
@if (group.label) {
<div class="tb-iot-hub-filter-group-label">{{ group.label | translate }}</div>
}
@for (opt of group.items; track opt.key) {
<div class="tb-iot-hub-filter-option">
<mat-checkbox [checked]="iotHubPendingUseCases.has(opt.key)"
(change)="toggleIotHubUseCase(opt.key)">
{{ opt.key }}
</mat-checkbox>
</div>
}
}
</div> </div>
</mat-expansion-panel> </mat-expansion-panel>
</div> </div>
<div class="tb-iot-hub-filter-actions"> <div class="tb-iot-hub-filter-actions">
<button mat-stroked-button *ngIf="iotHubPendingWidgetTypes.size || iotHubPendingCategories.size || iotHubPendingUseCases.size" (click)="clearIotHubFilters()">{{ 'iot-hub.clear-all' | translate }}</button> @if (iotHubPendingWidgetTypes.size || iotHubPendingCategories.size || iotHubPendingUseCases.size) {
<button mat-stroked-button (click)="clearIotHubFilters()">{{ 'iot-hub.clear-all' | translate }}</button>
}
<span class="flex-1"></span> <span class="flex-1"></span>
<button mat-button (click)="popover?.hide()">{{ 'action.cancel' | translate }}</button> <button mat-button (click)="popover?.hide()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary" [disabled]="!iotHubFilterDirty" (click)="applyIotHubFilters(); popover?.hide()">{{ 'action.apply' | translate }}</button> <button mat-flat-button color="primary" [disabled]="!iotHubFilterDirty" (click)="applyIotHubFilters(); popover?.hide()">{{ 'action.apply' | translate }}</button>

122
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss

@ -224,6 +224,34 @@
.tb-iot-hub-filter-sections { .tb-iot-hub-filter-sections {
overflow-y: auto; overflow-y: auto;
padding-right: 8px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background-color: rgba(0, 0, 0, 0.04);
}
&::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
&:hover {
background-color: rgba(0, 0, 0, 0.35);
}
}
&.filter-items-hovered {
&::-webkit-scrollbar-track {
background-color: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
}
mat-expansion-panel { mat-expansion-panel {
box-shadow: none; box-shadow: none;
@ -245,11 +273,105 @@
} }
} }
.tb-iot-hub-filter-search {
display: flex;
align-items: center;
gap: 8px;
height: 40px;
margin: 4px 8px;
padding: 8px 12px;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
background: white;
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
color: rgba(0, 0, 0, 0.54);
flex-shrink: 0;
}
input {
flex: 1;
border: none;
outline: none;
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: 0.2px;
color: rgba(0, 0, 0, 0.87);
background: transparent;
padding: 0;
min-width: 0;
&::placeholder {
color: rgba(0, 0, 0, 0.38);
}
}
.tb-iot-hub-filter-search-clear.mat-mdc-icon-button {
width: 24px;
height: 24px;
padding: 0;
flex-shrink: 0;
color: rgba(0, 0, 0, 0.38);
--mdc-icon-button-state-layer-size: 24px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
}
.tb-iot-hub-filter-items {
max-height: 340px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
border-radius: 3px;
}
&:hover {
&::-webkit-scrollbar-track {
background-color: rgba(0, 0, 0, 0.04);
}
&::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
&:hover {
background-color: rgba(0, 0, 0, 0.35);
}
}
}
}
.tb-iot-hub-filter-group-label {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.54);
letter-spacing: 0.5px;
padding: 12px 0 4px 12px;
}
.tb-iot-hub-filter-option { .tb-iot-hub-filter-option {
height: 40px; height: 40px;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 8px 0; padding: 8px 0;
overflow: hidden;
} }
.tb-iot-hub-filter-badge { .tb-iot-hub-filter-badge {

37
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts

@ -213,6 +213,7 @@ export class DashboardWidgetSelectComponent implements OnInit {
iotHubCategoryOptions: FilterParamInfo[] = []; iotHubCategoryOptions: FilterParamInfo[] = [];
iotHubUseCaseOptions: FilterParamInfo[] = []; iotHubUseCaseOptions: FilterParamInfo[] = [];
iotHubFilterCount = 0; iotHubFilterCount = 0;
iotHubFilterSearch: Record<string, string> = {};
constructor(private widgetsService: WidgetService, constructor(private widgetsService: WidgetService,
private iotHubApiService: IotHubApiService, private iotHubApiService: IotHubApiService,
@ -401,11 +402,39 @@ export class DashboardWidgetSelectComponent implements OnInit {
this.iotHubFilterDirty = false; this.iotHubFilterDirty = false;
} }
iotHubFilterItemsHovered = false;
getIotHubWidgetTypeLabel(key: string): string { getIotHubWidgetTypeLabel(key: string): string {
const translationKey = widgetTypeTranslations.get(key); const translationKey = widgetTypeTranslations.get(key);
return translationKey ? this.translate.instant(translationKey) : key; return translationKey ? this.translate.instant(translationKey) : key;
} }
getFilteredIotHubItems(items: FilterParamInfo[], searchKey: string): FilterParamInfo[] {
const search = (this.iotHubFilterSearch[searchKey] || '').toLowerCase();
if (!search) { return items; }
return items.filter(item => item.key.toLowerCase().includes(search));
}
getGroupedIotHubFilterItems(items: FilterParamInfo[], searchKey: string): { label: string; items: FilterParamInfo[] }[] {
const filtered = this.getFilteredIotHubItems(items, searchKey);
if (items.length < 11) {
return [{ label: null, items: filtered }];
}
const topKeys = new Set(
[...items].sort((a, b) => b.totalInstallCount - a.totalInstallCount).slice(0, 10).map(i => i.key)
);
const popular = filtered.filter(i => topKeys.has(i.key));
const rest = filtered.filter(i => !topKeys.has(i.key));
const groups: { label: string; items: FilterParamInfo[] }[] = [];
if (popular.length) {
groups.push({ label: 'iot-hub.most-popular', items: popular });
}
if (rest.length) {
groups.push({ label: 'iot-hub.all', items: rest });
}
return groups;
}
toggleIotHubWidgetType(key: string): void { toggleIotHubWidgetType(key: string): void {
if (this.iotHubPendingWidgetTypes.has(key)) { if (this.iotHubPendingWidgetTypes.has(key)) {
this.iotHubPendingWidgetTypes.delete(key); this.iotHubPendingWidgetTypes.delete(key);
@ -437,6 +466,7 @@ export class DashboardWidgetSelectComponent implements OnInit {
this.iotHubPendingWidgetTypes.clear(); this.iotHubPendingWidgetTypes.clear();
this.iotHubPendingCategories.clear(); this.iotHubPendingCategories.clear();
this.iotHubPendingUseCases.clear(); this.iotHubPendingUseCases.clear();
this.iotHubFilterSearch = {};
this.updateFilterDirty(); this.updateFilterDirty();
} }
@ -498,10 +528,11 @@ export class DashboardWidgetSelectComponent implements OnInit {
} }
private loadIotHubFilterInfo(): void { private loadIotHubFilterInfo(): void {
const hasItems = (i: FilterParamInfo) => i.totalItems > 0;
this.iotHubApiService.getFilterInfo(ItemType.WIDGET, { ignoreLoading: true }).subscribe(info => { this.iotHubApiService.getFilterInfo(ItemType.WIDGET, { ignoreLoading: true }).subscribe(info => {
this.iotHubWidgetTypeOptions = info.types || []; this.iotHubWidgetTypeOptions = (info.types || []).filter(hasItems);
this.iotHubCategoryOptions = info.categories || []; this.iotHubCategoryOptions = (info.categories || []).filter(hasItems);
this.iotHubUseCaseOptions = info.useCases || []; this.iotHubUseCaseOptions = (info.useCases || []).filter(hasItems);
}); });
} }

Loading…
Cancel
Save