Browse Source

Add IoT Hub widget filters, All/Installed toggle, and installed badge

- Add All/Installed toggle in IoT Hub mode header
- Installed mode fetches published versions for installed widgets with in-memory filtering
- Filter panel with Type, Category, Use Case sections via tb-popover
- Filter badge count on filter button
- Installed badge (check + label) on widget cards in All mode
- Widget type badge from dataDescriptor on IoT Hub widget cards
- Filters apply to both All (server-side) and Installed (in-memory) modes
- Load installed widgets on entering IoT Hub mode
- Skip detail dialog for already-installed widgets, emit directly
pull/15508/head
Igor Kulikov 3 months ago
parent
commit
4bb857bf3f
  1. 23
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html
  2. 99
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html
  3. 94
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.scss
  4. 209
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts
  5. 2
      ui-ngx/src/assets/locale/locale.constant-en_US.json

23
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html

@ -455,6 +455,29 @@
<tb-toggle-option value="ACTUAL">{{ 'widget.actual' | translate }}</tb-toggle-option>
<tb-toggle-option value="DEPRECATED">{{ 'widget.deprecated' | translate }}</tb-toggle-option>
</tb-toggle-select>
<button mat-icon-button type="button"
*ngIf="dashboardWidgetSelectComponent?.selectWidgetMode === 'iotHub'"
[tb-popover]="dashboardWidgetSelectComponent?.iotHubFilterPanel"
[tbPopoverContent]="dashboardWidgetSelectComponent?.iotHubFilterPanel"
tbPopoverTrigger="click"
[tbPopoverOverlayStyle]="{maxWidth: '380px'}"
tbPopoverShowCloseButton="true"
matTooltip="{{ 'iot-hub.filter' | translate }}"
matTooltipPosition="above">
<mat-icon [matBadge]="dashboardWidgetSelectComponent?.iotHubFilterCount || null"
[matBadgeHidden]="!dashboardWidgetSelectComponent?.iotHubFilterCount"
matBadgeColor="warn"
matBadgeSize="small">filter_list</mat-icon>
</button>
<tb-toggle-select *ngIf="dashboardWidgetSelectComponent?.selectWidgetMode === 'iotHub'"
appearance="fill-invert"
disablePagination
selectMediaBreakpoint="xs"
[ngModel]="dashboardWidgetSelectComponent.iotHubInstalledMode"
(ngModelChange)="dashboardWidgetSelectComponent.onIotHubInstalledModeChange($event)">
<tb-toggle-option value="all">{{ 'widget.all' | translate }}</tb-toggle-option>
<tb-toggle-option value="installed">{{ 'iot-hub.installed' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<tb-dashboard-widget-select #dashboardWidgetSelect
*ngIf="isAddingWidget"

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

@ -125,16 +125,30 @@
</ng-template>
<ng-template #iotHubWidgets>
<tb-scroll-grid
[columns]="columns"
[itemSize]="gridWidgetsItemSizeStrategy"
[fetchFunction]="iotHubWidgetsFetchFunction"
[filter]="iotHubWidgetsFilter"
[itemCard]="iotHubWidgetCard"
[loadingCell]="widgetLoadingCard"
[dataLoading]="loadingIotHubWidgets"
[noData]="noIotHubWidgets">
</tb-scroll-grid>
<ng-container *ngIf="iotHubInstalledMode === 'all'; else iotHubInstalledGrid">
<tb-scroll-grid
[columns]="columns"
[itemSize]="gridWidgetsItemSizeStrategy"
[fetchFunction]="iotHubWidgetsFetchFunction"
[filter]="iotHubWidgetsFilter"
[itemCard]="iotHubWidgetCard"
[loadingCell]="widgetLoadingCard"
[dataLoading]="loadingIotHubWidgets"
[noData]="noIotHubWidgets">
</tb-scroll-grid>
</ng-container>
<ng-template #iotHubInstalledGrid>
<tb-scroll-grid
[columns]="columns"
[itemSize]="gridWidgetsItemSizeStrategy"
[fetchFunction]="iotHubInstalledWidgetsFetchFunction"
[filter]="iotHubInstalledWidgetsFilter"
[itemCard]="iotHubWidgetCard"
[loadingCell]="widgetLoadingCard"
[dataLoading]="loadingIotHubWidgets"
[noData]="noIotHubWidgets">
</tb-scroll-grid>
</ng-template>
<ng-template #iotHubWidgetCard let-item="item">
<mat-card class="tb-widget-preview-card tb-iot-hub-widget-card flex flex-col gap-2" appearance="raised" (click)="onIotHubWidgetClicked($event, item)">
<div class="title-container">
@ -142,6 +156,7 @@
{{item.name}}
</div>
<div class="title-items">
<div *ngIf="item.dataDescriptor?.widgetType" class="widget-type">{{ 'widget.' + item.dataDescriptor.widgetType + '-short' | translate }}</div>
<div tb-popover [tbPopoverContent]="item.description" [tbPopoverOverlayStyle]="{maxWidth: '300px'}" tbPopoverShowCloseButton="false" class="info-banner tb-primary-fill"><span>i</span></div>
</div>
</div>
@ -155,6 +170,10 @@
<mat-icon>person</mat-icon>
{{ item.creatorDisplayName }}
</span>
<span *ngIf="iotHubInstalledMode !== 'installed' && isIotHubWidgetInstalled(item)" class="tb-iot-hub-widget-installed-badge">
<mat-icon>check</mat-icon>
{{ 'iot-hub.installed' | translate }}
</span>
<span class="tb-iot-hub-widget-installs">
<mat-icon>file_download</mat-icon>
{{ item.totalInstallCount | shortNumber }}
@ -174,4 +193,64 @@
<span class="mat-headline-5 tb-absolute-fill flex items-center justify-center">{{ 'iot-hub.no-items-found' | translate }}</span>
</ng-template>
</ng-template>
<!-- IoT Hub Filter Panel -->
<ng-template #iotHubFilterPanel>
<div class="tb-iot-hub-filter-panel">
<div class="tb-iot-hub-filter-header">
<span class="tb-iot-hub-filter-title">{{ 'iot-hub.filters' | translate }}</span>
</div>
<div class="tb-iot-hub-filter-sections">
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
{{ 'iot-hub.type' | translate }}
<span *ngIf="iotHubActiveWidgetTypes.size" class="tb-iot-hub-filter-badge">{{ iotHubActiveWidgetTypes.size }}</span>
</mat-panel-title>
</mat-expansion-panel-header>
<div *ngFor="let entry of iotHubWidgetTypesMap | keyvalue" class="tb-iot-hub-filter-option">
<mat-checkbox [checked]="iotHubActiveWidgetTypes.has(entry.key)"
(change)="toggleIotHubWidgetType(entry.key)">
{{ entry.value | translate }}
</mat-checkbox>
</div>
</mat-expansion-panel>
<mat-divider></mat-divider>
<mat-expansion-panel [expanded]="false">
<mat-expansion-panel-header>
<mat-panel-title>
{{ 'iot-hub.category' | translate }}
<span *ngIf="iotHubActiveCategories.size" class="tb-iot-hub-filter-badge">{{ iotHubActiveCategories.size }}</span>
</mat-panel-title>
</mat-expansion-panel-header>
<div *ngFor="let entry of iotHubCategoriesMap | keyvalue" class="tb-iot-hub-filter-option">
<mat-checkbox [checked]="iotHubActiveCategories.has(entry.key)"
(change)="toggleIotHubCategory(entry.key)">
{{ entry.value | translate }}
</mat-checkbox>
</div>
</mat-expansion-panel>
<mat-divider></mat-divider>
<mat-expansion-panel [expanded]="false">
<mat-expansion-panel-header>
<mat-panel-title>
{{ 'iot-hub.use-case' | translate }}
<span *ngIf="iotHubActiveUseCases.size" class="tb-iot-hub-filter-badge">{{ iotHubActiveUseCases.size }}</span>
</mat-panel-title>
</mat-expansion-panel-header>
<div *ngFor="let entry of iotHubUseCasesMap | keyvalue" class="tb-iot-hub-filter-option">
<mat-checkbox [checked]="iotHubActiveUseCases.has(entry.key)"
(change)="toggleIotHubUseCase(entry.key)">
{{ entry.value | translate }}
</mat-checkbox>
</div>
</mat-expansion-panel>
</div>
<div class="tb-iot-hub-filter-actions">
<button mat-stroked-button (click)="clearIotHubFilters()">{{ 'iot-hub.clear-all' | translate }}</button>
<span class="flex-1"></span>
<button mat-flat-button color="primary" (click)="applyIotHubFilters()">{{ 'action.apply' | translate }}</button>
</div>
</div>
</ng-template>
</div>

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

@ -137,7 +137,6 @@
height: 100%;
}
}
}
// IoT Hub widget card footer
.tb-iot-hub-widget-card {
@ -167,6 +166,28 @@
}
}
.tb-iot-hub-widget-installed-badge {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 1px 6px;
border-radius: 10px;
background: rgba(25, 128, 56, 0.06);
color: #198038;
font-size: 10px;
font-weight: 500;
line-height: 14px;
letter-spacing: 0.2px;
white-space: nowrap;
mat-icon {
font-size: 12px;
width: 12px;
height: 12px;
color: #198038;
}
}
.tb-iot-hub-widget-installs {
font-weight: 500;
@ -179,6 +200,77 @@
}
}
// IoT Hub filter panel
.tb-iot-hub-filter-panel {
padding: 24px;
display: flex;
flex-direction: column;
gap: 12px;
max-height: 500px;
.tb-iot-hub-filter-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.tb-iot-hub-filter-title {
font-size: 16px;
font-weight: 500;
line-height: 24px;
letter-spacing: 0.25px;
color: rgba(0, 0, 0, 0.87);
}
.tb-iot-hub-filter-sections {
display: flex;
flex-direction: column;
overflow-y: auto;
mat-expansion-panel {
box-shadow: none;
background: transparent;
&::ng-deep .mat-expansion-panel-body {
padding: 0;
}
}
mat-divider {
margin: 0;
}
}
.tb-iot-hub-filter-option {
height: 40px;
display: flex;
align-items: center;
padding: 8px 0;
}
.tb-iot-hub-filter-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
border-radius: 9px;
background: #FF5722;
color: white;
font-size: 11px;
font-weight: 600;
margin-left: 8px;
padding: 0 4px;
}
.tb-iot-hub-filter-actions {
display: flex;
align-items: center;
gap: 8px;
padding-top: 8px;
}
}
@keyframes shine {
to {
background-position-x: -200%;

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

@ -14,7 +14,7 @@
/// limitations under the License.
///
import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core';
import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core';
import { WidgetsBundle } from '@shared/models/widgets-bundle.model';
import { IAliasController } from '@core/api/widget-api.models';
import { NULL_UUID } from '@shared/models/id/has-uuid';
@ -26,8 +26,8 @@ import {
widgetType,
WidgetTypeInfo
} from '@shared/models/widget.models';
import { debounceTime, distinctUntilChanged, map, skip } from 'rxjs/operators';
import { BehaviorSubject, combineLatest } from 'rxjs';
import { debounceTime, distinctUntilChanged, map, skip, switchMap } from 'rxjs/operators';
import { BehaviorSubject, combineLatest, forkJoin, of } from 'rxjs';
import { isObject } from '@core/utils';
import { PageLink } from '@shared/models/page/page-link';
import { Direction, SortOrder } from '@shared/models/page/sort-order';
@ -35,8 +35,9 @@ import { GridEntitiesFetchFunction, ScrollGridColumns } from '@shared/components
import { ItemSizeStrategy } from '@shared/components/grid/scroll-grid.component';
import { coerceBoolean } from '@shared/decorators/coercion';
import { MatDialog } from '@angular/material/dialog';
import { MpItemVersionQuery, MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models';
import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models';
import { MpItemVersionQuery, MpItemVersionView, widgetTypeTranslations as iotHubWidgetTypeTranslations } from '@shared/models/iot-hub/iot-hub-version.models';
import { ItemType, getCategoriesForType, useCaseTranslations } from '@shared/models/iot-hub/iot-hub-item.models';
import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { IotHubApiService } from '@core/http/iot-hub-api.service';
import {
TbIotHubItemDetailDialogComponent,
@ -114,6 +115,16 @@ export class DashboardWidgetSelectComponent implements OnInit {
this.filterWidgetTypes$.next(null);
this.deprecatedFilter$.next(DeprecatedFilter.ACTUAL);
this.selectWidgetMode$.next(mode);
if (mode === 'iotHub') {
this.loadInstalledWidgets();
} else {
this.iotHubInstalledMode = 'all';
this.installedWidgetVersions = null;
this.iotHubActiveWidgetTypes.clear();
this.iotHubActiveCategories.clear();
this.iotHubActiveUseCases.clear();
this.iotHubFilterCount = 0;
}
}
}
@ -149,6 +160,8 @@ export class DashboardWidgetSelectComponent implements OnInit {
return this.widgetsBundle$.value;
}
@ViewChild('iotHubFilterPanel', {static: true}) iotHubFilterPanel: TemplateRef<void>;
@Output()
widgetSelected: EventEmitter<WidgetInfo> = new EventEmitter<WidgetInfo>();
@ -177,6 +190,20 @@ export class DashboardWidgetSelectComponent implements OnInit {
allWidgetsFilter: WidgetsFilter = {search: '', filter: null, deprecatedFilter: DeprecatedFilter.ACTUAL};
widgetsFilter: BundleWidgetsFilter = {search: '', filter: null, deprecatedFilter: DeprecatedFilter.ACTUAL, widgetsBundleId: null};
iotHubWidgetsFilter = '';
iotHubInstalledMode: 'all' | 'installed' = 'all';
iotHubInstalledWidgetsFetchFunction: GridEntitiesFetchFunction<MpItemVersionView, string>;
private installedWidgets: IotHubInstalledItem[] = null;
private installedWidgetVersions: MpItemVersionView[] = null;
iotHubInstalledWidgetsFilter = '';
// IoT Hub filter model
iotHubActiveWidgetTypes = new Set<string>();
iotHubActiveCategories = new Set<string>();
iotHubActiveUseCases = new Set<string>();
iotHubWidgetTypesMap: Map<string, string> = iotHubWidgetTypeTranslations;
iotHubCategoriesMap: Map<string, string> = getCategoriesForType(ItemType.WIDGET);
iotHubUseCasesMap: Map<string, string> = useCaseTranslations as Map<string, string>;
iotHubFilterCount = 0;
constructor(private widgetsService: WidgetService,
private iotHubApiService: IotHubApiService,
@ -210,19 +237,41 @@ export class DashboardWidgetSelectComponent implements OnInit {
};
this.iotHubWidgetsFetchFunction = (pageSize, page, filter) => {
const search = typeof filter === 'string' ? filter.split('|')[0] : filter;
const sortOrder: SortOrder = { property: 'totalInstallCount', direction: Direction.DESC };
const pageLink = new PageLink(pageSize, page, filter || null, sortOrder);
const query = new MpItemVersionQuery(pageLink, ItemType.WIDGET);
const pageLink = new PageLink(pageSize, page, search || null, sortOrder);
const query = new MpItemVersionQuery(pageLink, ItemType.WIDGET,
undefined, undefined,
this.iotHubActiveCategories.size > 0 ? Array.from(this.iotHubActiveCategories) : undefined,
this.iotHubActiveUseCases.size > 0 ? Array.from(this.iotHubActiveUseCases) : undefined,
undefined,
this.iotHubActiveWidgetTypes.size > 0 ? Array.from(this.iotHubActiveWidgetTypes) : undefined
);
return this.iotHubApiService.getPublishedVersions(query, { ignoreLoading: true });
};
this.iotHubInstalledWidgetsFetchFunction = (pageSize, page, filter) => {
if (this.installedWidgetVersions === null) {
return this.fetchInstalledWidgetVersions().pipe(
map(versions => this.filterAndPaginateInstalledVersions(versions, pageSize, page, filter))
);
}
return of(this.filterAndPaginateInstalledVersions(this.installedWidgetVersions, pageSize, page, filter));
};
this.search$.pipe(
distinctUntilChanged(),
skip(1)
).subscribe(
(search) => {
this.widgetsBundleFilter = search;
this.iotHubWidgetsFilter = search;
if (this.selectWidgetMode$.value === 'iotHub') {
if (this.iotHubInstalledMode === 'installed') {
this.iotHubInstalledWidgetsFilter = search;
} else {
this.iotHubWidgetsFilter = search;
}
}
this.cd.markForCheck();
}
);
@ -274,6 +323,25 @@ export class DashboardWidgetSelectComponent implements OnInit {
onIotHubWidgetClicked($event: Event, item: MpItemVersionView): void {
$event.preventDefault();
const installedItem = this.installedWidgets?.find(i => i.itemId === item.itemId);
if (installedItem) {
const widgetTypeId = installedItem.descriptor?.type === 'WIDGET' ? installedItem.descriptor.widgetTypeId?.id : null;
if (widgetTypeId) {
this.widgetsService.getWidgetTypeInfoById(widgetTypeId).subscribe(wt => {
if (wt) {
this.widgetSelected.emit({
typeFullFqn: fullWidgetTypeFqn(wt),
type: wt.widgetType,
title: wt.name,
image: wt.image,
description: wt.description,
deprecated: wt.deprecated
});
}
});
}
return;
}
const dialogRef = this.dialog.open(TbIotHubItemDetailDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
autoFocus: false,
@ -290,6 +358,10 @@ export class DashboardWidgetSelectComponent implements OnInit {
});
}
isIotHubWidgetInstalled(item: MpItemVersionView): boolean {
return this.installedWidgets?.some(i => i.itemId === item.itemId) ?? false;
}
getIotHubItemImage(item: MpItemVersionView): string | null {
if (item.image) {
return this.iotHubApiService.resolveResourceUrl(item.image);
@ -301,6 +373,60 @@ export class DashboardWidgetSelectComponent implements OnInit {
return null;
}
onIotHubInstalledModeChange(mode: 'all' | 'installed'): void {
this.iotHubInstalledMode = mode;
if (mode === 'installed') {
this.installedWidgetVersions = null;
}
this.cd.markForCheck();
}
toggleIotHubWidgetType(key: string): void {
if (this.iotHubActiveWidgetTypes.has(key)) {
this.iotHubActiveWidgetTypes.delete(key);
} else {
this.iotHubActiveWidgetTypes.add(key);
}
}
toggleIotHubCategory(key: string): void {
if (this.iotHubActiveCategories.has(key)) {
this.iotHubActiveCategories.delete(key);
} else {
this.iotHubActiveCategories.add(key);
}
}
toggleIotHubUseCase(key: string): void {
if (this.iotHubActiveUseCases.has(key)) {
this.iotHubActiveUseCases.delete(key);
} else {
this.iotHubActiveUseCases.add(key);
}
}
clearIotHubFilters(): void {
this.iotHubActiveWidgetTypes.clear();
this.iotHubActiveCategories.clear();
this.iotHubActiveUseCases.clear();
this.applyIotHubFilters();
}
applyIotHubFilters(): void {
this.iotHubFilterCount = this.iotHubActiveWidgetTypes.size + this.iotHubActiveCategories.size + this.iotHubActiveUseCases.size;
this.reloadIotHubWidgets();
}
private reloadIotHubWidgets(): void {
if (this.iotHubInstalledMode === 'installed') {
this.installedWidgetVersions = null;
this.iotHubInstalledWidgetsFilter = this.searchSubject.value + '|' + Date.now();
} else {
this.iotHubWidgetsFilter = this.searchSubject.value + '|' + Date.now();
}
this.cd.markForCheck();
}
isObject(value: any): boolean {
return isObject(value);
}
@ -310,18 +436,12 @@ export class DashboardWidgetSelectComponent implements OnInit {
this.iotHubApiService.installItemVersion(versionId, { ignoreLoading: true }).subscribe({
next: (result) => {
if (result.success && result.descriptor?.type === 'WIDGET') {
this.loadInstalledWidgets();
const widgetTypeId = result.descriptor.widgetTypeId?.id;
if (widgetTypeId) {
this.widgetsService.getWidgetTypeInfoById(widgetTypeId).subscribe(widgetType => {
if (widgetType) {
this.widgetSelected.emit({
typeFullFqn: fullWidgetTypeFqn(widgetType),
type: widgetType.widgetType,
title: widgetType.name,
image: widgetType.image,
description: widgetType.description,
deprecated: widgetType.deprecated
});
this.widgetsService.getWidgetTypeInfoById(widgetTypeId).subscribe(wt => {
if (wt) {
this.widgetSelected.emit(this.toWidgetInfo(wt));
}
});
}
@ -330,6 +450,59 @@ export class DashboardWidgetSelectComponent implements OnInit {
});
}
private loadInstalledWidgets(): void {
if (this.installedWidgets === null) {
this.installedWidgets = [];
}
const pageLink = new PageLink(10000, 0);
this.iotHubApiService.getInstalledItems(pageLink, ItemType.WIDGET, { ignoreLoading: true }).subscribe(data => {
this.installedWidgets = data.data;
});
}
private fetchInstalledWidgetVersions() {
const itemIds = (this.installedWidgets || []).map(i => i.itemId);
if (itemIds.length === 0) {
this.installedWidgetVersions = [];
return of([]);
}
return this.iotHubApiService.getItemsPublishedVersions(itemIds, { ignoreLoading: true }).pipe(
switchMap(infos => {
if (infos.length === 0) {
return of([]);
}
const versionRequests = infos.map(info =>
this.iotHubApiService.getVersionInfo(info.publishedVersionId, { ignoreLoading: true })
);
return forkJoin(versionRequests);
}),
map(versions => {
this.installedWidgetVersions = versions.sort((a, b) => b.totalInstallCount - a.totalInstallCount);
return this.installedWidgetVersions;
})
);
}
private filterAndPaginateInstalledVersions(versions: MpItemVersionView[], pageSize: number, page: number, filter: string) {
let filtered = versions;
const search = typeof filter === 'string' ? filter.split('|')[0] : '';
if (search) {
filtered = filtered.filter(v => v.name.toLowerCase().includes(search.toLowerCase()));
}
if (this.iotHubActiveWidgetTypes.size > 0) {
filtered = filtered.filter(v => this.iotHubActiveWidgetTypes.has(v.dataDescriptor?.widgetType));
}
if (this.iotHubActiveCategories.size > 0) {
filtered = filtered.filter(v => v.categories?.some(c => this.iotHubActiveCategories.has(c)));
}
if (this.iotHubActiveUseCases.size > 0) {
filtered = filtered.filter(v => v.useCases?.some(u => this.iotHubActiveUseCases.has(u)));
}
const start = page * pageSize;
const data = filtered.slice(start, start + pageSize);
return { data, totalPages: Math.ceil(filtered.length / pageSize), totalElements: filtered.length, hasNext: start + pageSize < filtered.length };
}
private toWidgetInfo(widgetTypeInfo: WidgetTypeInfo): WidgetInfo {
return {
typeFullFqn: fullWidgetTypeFqn(widgetTypeInfo),

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

@ -3711,6 +3711,8 @@
"search-results-for": "Search results for ",
"try-adjusting-search": "Try adjusting your search",
"add-widget-from-iot-hub": "Add widget from IoT Hub",
"filters": "Filters",
"type": "Type",
"items-per-page": "Items per page",
"filter": "Filter",
"available": "Available",

Loading…
Cancel
Save