Browse Source
Search component: - New TbIotHubSearchComponent in home/components/iot-hub/ with search toolbar, pagination, grouped results, sort, loading/empty states - Inputs: searchText (two-way), creatorId, showCreator; ng-content for title - Custom search input (320x40px, border, search icon, clear button) - Sort icon-only button on lt-sm, mobile results count in title row - Divider before categories section Search page: - Renamed to TbIotHubSearchPageComponent, embeds TbIotHubSearchComponent - Title projected via ng-content, responsive inset padding per breakpoint - Full-height layout with flex Creator profile: - Two-column layout: creator info (360px) + vertical divider + published items - Uses TbIotHubSearchComponent with creatorId and showCreator=false - 140px avatar, 28px name, description, link rows with hover styles - Responsive: column layout on lt-lg, adjustable padding per breakpoint - Back button inside creator info column Item detail dialog: - showCreator parameter (default true), hides author section when false - openItemDetail service method accepts showCreator argument Item card: - Respects showCreator input, hides author in both compact and big layoutspull/15508/head
16 changed files with 838 additions and 359 deletions
@ -0,0 +1,143 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2026 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-search-title-row"> |
|||
<ng-content></ng-content> |
|||
@if (!isLoading && totalElements > 0) { |
|||
<span class="tb-search-results-count tb-search-results-count-mobile"> |
|||
{{ 'iot-hub.results-count' | translate:{ count: totalElements } }} |
|||
</span> |
|||
} |
|||
</div> |
|||
<!-- Search + Sort row --> |
|||
<div class="tb-search-toolbar"> |
|||
<div class="tb-search-input-field"> |
|||
<mat-icon class="tb-search-input-icon">search</mat-icon> |
|||
<input class="tb-search-input" |
|||
[placeholder]="'iot-hub.search' | translate" |
|||
[(ngModel)]="searchText" |
|||
(input)="onSearchInput()" |
|||
(keyup.enter)="onSearchEnter()"> |
|||
@if (searchText) { |
|||
<button mat-icon-button class="tb-search-input-clear" (click)="clearSearch()"> |
|||
<mat-icon>close</mat-icon> |
|||
</button> |
|||
} |
|||
</div> |
|||
<button mat-button color="primary" class="tb-search-sort-btn tb-search-sort-btn-full" [matMenuTriggerFor]="sortMenu"> |
|||
<mat-icon>filter_list</mat-icon> |
|||
{{ sortOptions[selectedSortIndex].label | translate }} |
|||
</button> |
|||
<button mat-icon-button color="primary" class="tb-search-sort-btn tb-search-sort-btn-icon" [matMenuTriggerFor]="sortMenu"> |
|||
<mat-icon>filter_list</mat-icon> |
|||
</button> |
|||
<mat-menu #sortMenu="matMenu"> |
|||
@for (option of sortOptions; track option.value; let i = $index) { |
|||
<button mat-menu-item (click)="onSortChange(i)">{{ option.label | translate }}</button> |
|||
} |
|||
</mat-menu> |
|||
</div> |
|||
|
|||
<!-- Results count + Pagination (shown when results exist) --> |
|||
@if (!isLoading && totalElements > 0) { |
|||
<div class="tb-search-pagination-row"> |
|||
<span class="tb-search-results-count"> |
|||
{{ 'iot-hub.results-count' | translate:{ count: totalElements } }} |
|||
</span> |
|||
<div class="tb-search-pagination"> |
|||
<div class="tb-search-pagination-pages"> |
|||
<button mat-icon-button [disabled]="pageIndex === 0" (click)="goToPage(pageIndex - 1)"> |
|||
<mat-icon>chevron_left</mat-icon> |
|||
</button> |
|||
@for (p of getPageNumbers(); track p) { |
|||
<button class="tb-search-page-btn" [class.active]="p === pageIndex" (click)="goToPage(p)"> |
|||
{{ p + 1 }} |
|||
</button> |
|||
} |
|||
<button mat-icon-button [disabled]="pageIndex >= totalPages - 1" (click)="goToPage(pageIndex + 1)"> |
|||
<mat-icon>chevron_right</mat-icon> |
|||
</button> |
|||
</div> |
|||
<div class="tb-search-page-size"> |
|||
<span>{{ 'iot-hub.items-per-page' | translate }}:</span> |
|||
<mat-select [value]="pageSize" (selectionChange)="onPageSizeChange($event.value)" class="tb-search-page-size-select"> |
|||
@for (size of pageSizeOptions; track size) { |
|||
<mat-option [value]="size">{{ size }}</mat-option> |
|||
} |
|||
</mat-select> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
} |
|||
|
|||
<!-- No results count when 0 --> |
|||
@if (!isLoading && totalElements === 0 && searchText?.trim()) { |
|||
<div class="tb-search-pagination-row"> |
|||
<span class="tb-search-results-count"> |
|||
{{ 'iot-hub.results-count' | translate:{ count: 0 } }} |
|||
</span> |
|||
</div> |
|||
} |
|||
|
|||
<!-- Loading --> |
|||
@if (isLoading) { |
|||
<div class="tb-search-loading"> |
|||
<mat-spinner diameter="40"></mat-spinner> |
|||
</div> |
|||
} |
|||
|
|||
<!-- No results --> |
|||
@if (!isLoading && totalElements === 0 && searchText?.trim()) { |
|||
<div class="tb-search-empty"> |
|||
<mat-icon class="tb-search-empty-icon">search_off</mat-icon> |
|||
<h3>{{ 'iot-hub.no-items-found' | translate }}</h3> |
|||
<p>{{ 'iot-hub.try-adjusting-search' | translate }}</p> |
|||
</div> |
|||
} |
|||
|
|||
<!-- Results grouped by type --> |
|||
@if (!isLoading && resultGroups.length > 0) { |
|||
<div class="tb-search-divider"></div> |
|||
<div class="tb-search-categories"> |
|||
@for (group of resultGroups; track group.type) { |
|||
<div class="tb-search-section"> |
|||
<!-- Section header --> |
|||
<button mat-button class="tb-search-section-header" (click)="navigateToType(group.type)"> |
|||
{{ getTypeLabel(group.type) }} |
|||
<mat-icon>chevron_right</mat-icon> |
|||
</button> |
|||
<!-- Card grid --> |
|||
<div class="tb-search-card-grid" [class.tb-search-card-grid-compact]="isCompactType(group.type)"> |
|||
@for (item of group.items; track item.id) { |
|||
<tb-iot-hub-item-card |
|||
[item]="item" |
|||
[installedItem]="getInstalledItem(item)" |
|||
[showTypeChip]="false" |
|||
[showSubtype]="false" |
|||
[showCreator]="showCreator" |
|||
(cardClick)="openItemDetail($event)" |
|||
(creatorClick)="navigateToCreator($event)" |
|||
(installClick)="installItem($event)" |
|||
(updateClick)="updateItem($event)" |
|||
(deleteClick)="deleteInstalledItem($event)"> |
|||
</tb-iot-hub-item-card> |
|||
} |
|||
</div> |
|||
</div> |
|||
} |
|||
</div> |
|||
} |
|||
@ -0,0 +1,36 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2026 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-search-page"> |
|||
<!-- Back --> |
|||
<button mat-button color="primary" class="tb-search-back-btn" (click)="navigateBack()"> |
|||
<mat-icon>chevron_left</mat-icon> |
|||
{{ 'iot-hub.back-to-iot-hub' | translate }} |
|||
</button> |
|||
|
|||
<!-- Search component --> |
|||
<tb-iot-hub-search [(searchText)]="searchText"> |
|||
<!-- Title --> |
|||
<h1 class="tb-search-title"> |
|||
@if (searchText?.trim()) { |
|||
{{ 'iot-hub.search-results-for' | translate }}<span class="tb-search-title-query">'{{ searchText.trim() }}'</span> |
|||
} @else { |
|||
{{ 'iot-hub.search-results' | translate }} |
|||
} |
|||
</h1> |
|||
</tb-iot-hub-search> |
|||
</div> |
|||
@ -0,0 +1,155 @@ |
|||
/** |
|||
* Copyright © 2016-2026 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 "../../../../../scss/constants"; |
|||
|
|||
:host { |
|||
display: block; |
|||
padding: 16px; |
|||
height: 100%; |
|||
} |
|||
|
|||
.tb-search-page { |
|||
background: white; |
|||
border-radius: 8px; |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 40px; |
|||
height: 100%; |
|||
} |
|||
|
|||
// Back button |
|||
.tb-search-back-btn { |
|||
align-self: flex-start; |
|||
margin-top: 40px; |
|||
margin-left: 123px; |
|||
} |
|||
|
|||
// Title |
|||
.tb-search-title { |
|||
font-size: 28px; |
|||
font-weight: 500; |
|||
line-height: 36px; |
|||
letter-spacing: 0.25px; |
|||
color: rgba(0, 0, 0, 0.87); |
|||
margin: 0; |
|||
} |
|||
|
|||
.tb-search-title-query { |
|||
color: rgba(0, 0, 0, 0.54); |
|||
} |
|||
|
|||
tb-iot-hub-search { |
|||
flex: 1; |
|||
min-height: 0; |
|||
padding-left: 123px; |
|||
padding-right: 123px; |
|||
|
|||
::ng-deep { |
|||
.tb-search-divider { |
|||
margin-left: -123px; |
|||
margin-right: -123px; |
|||
} |
|||
|
|||
.tb-search-categories { |
|||
padding-right: 123px; |
|||
padding-bottom: 40px; |
|||
margin-right: -123px; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Responsive |
|||
@media #{$mat-lt-xxl} { |
|||
.tb-search-back-btn { |
|||
margin-left: 40px; |
|||
} |
|||
|
|||
tb-iot-hub-search { |
|||
padding-left: 40px; |
|||
padding-right: 40px; |
|||
|
|||
::ng-deep { |
|||
.tb-search-divider { |
|||
margin-left: -40px; |
|||
margin-right: -40px; |
|||
} |
|||
|
|||
.tb-search-categories { |
|||
padding-right: 40px; |
|||
margin-right: -40px; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
@media #{$mat-lt-lg} { |
|||
.tb-search-page { |
|||
gap: 24px; |
|||
} |
|||
|
|||
.tb-search-back-btn { |
|||
margin-top: 24px; |
|||
margin-left: 24px; |
|||
} |
|||
|
|||
tb-iot-hub-search { |
|||
padding-left: 24px; |
|||
padding-right: 24px; |
|||
|
|||
::ng-deep { |
|||
.tb-search-divider { |
|||
margin-left: -24px; |
|||
margin-right: -24px; |
|||
} |
|||
|
|||
.tb-search-categories { |
|||
padding-right: 24px; |
|||
padding-bottom: 24px; |
|||
margin-right: -24px; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
@media #{$mat-lt-sm} { |
|||
.tb-search-page { |
|||
gap: 16px; |
|||
} |
|||
|
|||
.tb-search-back-btn { |
|||
margin-top: 16px; |
|||
margin-left: 16px; |
|||
} |
|||
|
|||
tb-iot-hub-search { |
|||
padding-left: 16px; |
|||
padding-right: 16px; |
|||
|
|||
::ng-deep { |
|||
.tb-search-divider { |
|||
margin-left: -16px; |
|||
margin-right: -16px; |
|||
} |
|||
|
|||
.tb-search-categories { |
|||
padding-right: 16px; |
|||
padding-bottom: 16px; |
|||
margin-right: -16px; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,45 @@ |
|||
///
|
|||
/// Copyright © 2016-2026 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 { Component, OnInit } from '@angular/core'; |
|||
import { ActivatedRoute, Router } from '@angular/router'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-iot-hub-search-page', |
|||
standalone: false, |
|||
templateUrl: './iot-hub-search-page.component.html', |
|||
styleUrls: ['./iot-hub-search-page.component.scss'] |
|||
}) |
|||
export class TbIotHubSearchPageComponent implements OnInit { |
|||
|
|||
searchText = ''; |
|||
|
|||
constructor( |
|||
private route: ActivatedRoute, |
|||
private router: Router |
|||
) {} |
|||
|
|||
ngOnInit(): void { |
|||
const search = this.route.snapshot.queryParamMap.get('search'); |
|||
if (search) { |
|||
this.searchText = search; |
|||
} |
|||
} |
|||
|
|||
navigateBack(): void { |
|||
this.router.navigate(['/iot-hub']); |
|||
} |
|||
} |
|||
@ -1,148 +0,0 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2026 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-search-page"> |
|||
<!-- Back --> |
|||
<button mat-button color="primary" class="tb-search-back-btn" (click)="navigateBack()"> |
|||
<mat-icon>chevron_left</mat-icon> |
|||
{{ 'iot-hub.back-to-iot-hub' | translate }} |
|||
</button> |
|||
|
|||
<!-- Title --> |
|||
<h1 class="tb-search-title"> |
|||
@if (searchText?.trim()) { |
|||
{{ 'iot-hub.search-results-for' | translate }}<span class="tb-search-title-query">'{{ searchText.trim() }}'</span> |
|||
} @else { |
|||
{{ 'iot-hub.search-results' | translate }} |
|||
} |
|||
</h1> |
|||
|
|||
<!-- Search + Sort row --> |
|||
<div class="tb-search-toolbar"> |
|||
<mat-form-field appearance="outline" class="tb-search-input-field" subscriptSizing="dynamic"> |
|||
<input matInput |
|||
[(ngModel)]="searchText" |
|||
(input)="onSearchInput()" |
|||
(keyup.enter)="onSearchEnter()"> |
|||
@if (searchText) { |
|||
<button matSuffix mat-icon-button (click)="clearSearch()"> |
|||
<mat-icon>close</mat-icon> |
|||
</button> |
|||
} |
|||
<button matSuffix mat-icon-button (click)="$event.stopPropagation(); onSearchEnter()"> |
|||
<mat-icon>search</mat-icon> |
|||
</button> |
|||
</mat-form-field> |
|||
<button mat-button color="primary" class="tb-search-sort-btn" [matMenuTriggerFor]="sortMenu"> |
|||
<mat-icon>filter_list</mat-icon> |
|||
{{ sortOptions[selectedSortIndex].label | translate }} |
|||
</button> |
|||
<mat-menu #sortMenu="matMenu"> |
|||
@for (option of sortOptions; track option.value; let i = $index) { |
|||
<button mat-menu-item (click)="onSortChange(i)">{{ option.label | translate }}</button> |
|||
} |
|||
</mat-menu> |
|||
</div> |
|||
|
|||
<!-- Results count + Pagination (shown when results exist) --> |
|||
@if (!isLoading && totalElements > 0) { |
|||
<div class="tb-search-pagination-row"> |
|||
<span class="tb-search-results-count"> |
|||
{{ 'iot-hub.results-count' | translate:{ count: totalElements } }} |
|||
</span> |
|||
<div class="tb-search-pagination"> |
|||
<div class="tb-search-pagination-pages"> |
|||
<button mat-icon-button [disabled]="pageIndex === 0" (click)="goToPage(pageIndex - 1)"> |
|||
<mat-icon>chevron_left</mat-icon> |
|||
</button> |
|||
@for (p of getPageNumbers(); track p) { |
|||
<button class="tb-search-page-btn" [class.active]="p === pageIndex" (click)="goToPage(p)"> |
|||
{{ p + 1 }} |
|||
</button> |
|||
} |
|||
<button mat-icon-button [disabled]="pageIndex >= totalPages - 1" (click)="goToPage(pageIndex + 1)"> |
|||
<mat-icon>chevron_right</mat-icon> |
|||
</button> |
|||
</div> |
|||
<div class="tb-search-page-size"> |
|||
<span>{{ 'iot-hub.items-per-page' | translate }}:</span> |
|||
<mat-select [value]="pageSize" (selectionChange)="onPageSizeChange($event.value)" class="tb-search-page-size-select"> |
|||
@for (size of pageSizeOptions; track size) { |
|||
<mat-option [value]="size">{{ size }}</mat-option> |
|||
} |
|||
</mat-select> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
} |
|||
|
|||
<!-- No results count when 0 --> |
|||
@if (!isLoading && totalElements === 0 && searchText?.trim()) { |
|||
<div class="tb-search-pagination-row"> |
|||
<span class="tb-search-results-count"> |
|||
{{ 'iot-hub.results-count' | translate:{ count: 0 } }} |
|||
</span> |
|||
</div> |
|||
} |
|||
|
|||
<!-- Loading --> |
|||
@if (isLoading) { |
|||
<div class="tb-search-loading"> |
|||
<mat-spinner diameter="40"></mat-spinner> |
|||
</div> |
|||
} |
|||
|
|||
<!-- No results --> |
|||
@if (!isLoading && totalElements === 0 && searchText?.trim()) { |
|||
<div class="tb-search-empty"> |
|||
<mat-icon class="tb-search-empty-icon">search_off</mat-icon> |
|||
<h3>{{ 'iot-hub.no-items-found' | translate }}</h3> |
|||
<p>{{ 'iot-hub.try-adjusting-search' | translate }}</p> |
|||
</div> |
|||
} |
|||
|
|||
<!-- Results grouped by type --> |
|||
@if (!isLoading && resultGroups.length > 0) { |
|||
<div class="tb-search-categories"> |
|||
@for (group of resultGroups; track group.type) { |
|||
<div class="tb-search-section"> |
|||
<!-- Section header --> |
|||
<button mat-button class="tb-search-section-header" (click)="navigateToType(group.type)"> |
|||
{{ getTypeLabel(group.type) }} |
|||
<mat-icon>chevron_right</mat-icon> |
|||
</button> |
|||
<!-- Card grid --> |
|||
<div class="tb-search-card-grid" [class.tb-search-card-grid-compact]="isCompactType(group.type)"> |
|||
@for (item of group.items; track item.id) { |
|||
<tb-iot-hub-item-card |
|||
[item]="item" |
|||
[installedItem]="getInstalledItem(item)" |
|||
[showTypeChip]="false" |
|||
[showSubtype]="false" |
|||
(cardClick)="openItemDetail($event)" |
|||
(creatorClick)="navigateToCreator($event)" |
|||
(installClick)="installItem($event)" |
|||
(updateClick)="updateItem($event)" |
|||
(deleteClick)="deleteInstalledItem($event)"> |
|||
</tb-iot-hub-item-card> |
|||
} |
|||
</div> |
|||
</div> |
|||
} |
|||
</div> |
|||
} |
|||
</div> |
|||
Loading…
Reference in new issue