Browse Source
- New TbIotHubInstalledItemsDialogComponent opened from detail dialog Manage section - Extract TbIotHubInstalledItemsTableComponent with itemVersion mode for single-item view - Add installed item counts API (GET /installedItems/counts) and itemId filter to installed items list - Wire installed counts into home, browse, search, and detail dialog (replaces installedDevices list) - Propagate ChipOverflowDirective with minChips input; use on detail dialog meta chips - openEntity opens in new tab; "View item details" swaps to "Review device instructions" for DEVICE itemspull/15508/head
29 changed files with 1446 additions and 701 deletions
@ -0,0 +1,41 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
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-iot-hub-installed-items-dialog"> |
||||
|
<div class="dlg-header"> |
||||
|
<h2 class="dlg-title" tbTruncateWithTooltip> |
||||
|
{{ 'iot-hub.installed-items-for' | translate:{ name: item.name } }} |
||||
|
</h2> |
||||
|
<button mat-icon-button |
||||
|
class="dlg-close" |
||||
|
type="button" |
||||
|
(click)="close()"> |
||||
|
<mat-icon>close</mat-icon> |
||||
|
</button> |
||||
|
</div> |
||||
|
<div mat-dialog-content> |
||||
|
<tb-iot-hub-installed-items-table [itemVersion]="item"> |
||||
|
</tb-iot-hub-installed-items-table> |
||||
|
</div> |
||||
|
<div mat-dialog-actions class="flex items-center justify-end"> |
||||
|
<button mat-button |
||||
|
type="button" |
||||
|
(click)="close()"> |
||||
|
{{ 'action.close' | translate }} |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
@ -0,0 +1,96 @@ |
|||||
|
/** |
||||
|
* 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: flex; |
||||
|
flex-direction: column; |
||||
|
height: 100%; |
||||
|
min-height: 0; |
||||
|
} |
||||
|
|
||||
|
.tb-iot-hub-installed-items-dialog { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
height: 100%; |
||||
|
min-height: 0; |
||||
|
} |
||||
|
|
||||
|
.dlg-header { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 8px; |
||||
|
height: 64px; |
||||
|
padding: 12px 12px 12px 24px; |
||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.12); |
||||
|
flex-shrink: 0; |
||||
|
} |
||||
|
|
||||
|
.dlg-title { |
||||
|
flex: 1 1 auto; |
||||
|
min-width: 0; |
||||
|
margin: 0; |
||||
|
font-size: 20px; |
||||
|
font-weight: 600; |
||||
|
line-height: 24px; |
||||
|
letter-spacing: 0.1px; |
||||
|
color: rgba(0, 0, 0, 0.87); |
||||
|
white-space: nowrap; |
||||
|
overflow: hidden; |
||||
|
text-overflow: ellipsis; |
||||
|
} |
||||
|
|
||||
|
.dlg-close { |
||||
|
flex-shrink: 0; |
||||
|
} |
||||
|
|
||||
|
:host-context(.mat-mdc-dialog-container) { |
||||
|
.mat-mdc-dialog-content { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
flex: 1 1 auto; |
||||
|
min-height: 0; |
||||
|
padding: 24px; |
||||
|
overflow: hidden; |
||||
|
|
||||
|
tb-iot-hub-installed-items-table { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
flex: 1 1 auto; |
||||
|
min-height: 0; |
||||
|
width: 100%; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
@media #{$mat-gt-xs} { |
||||
|
height: 500px; |
||||
|
} |
||||
|
|
||||
|
@media #{$mat-gt-sm} { |
||||
|
width: 700px; |
||||
|
} |
||||
|
|
||||
|
@media #{$mat-gt-md} { |
||||
|
width: 800px; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.mat-mdc-dialog-actions { |
||||
|
border-top: 1px solid rgba(0, 0, 0, 0.12); |
||||
|
padding: 8px; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,52 @@ |
|||||
|
///
|
||||
|
/// 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, Inject } from '@angular/core'; |
||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; |
||||
|
import { Router } from '@angular/router'; |
||||
|
import { Store } from '@ngrx/store'; |
||||
|
import { AppState } from '@core/core.state'; |
||||
|
import { DialogComponent } from '@shared/components/dialog.component'; |
||||
|
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; |
||||
|
|
||||
|
export interface IotHubInstalledItemsDialogData { |
||||
|
item: MpItemVersionView; |
||||
|
} |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'tb-iot-hub-installed-items-dialog', |
||||
|
standalone: false, |
||||
|
templateUrl: './iot-hub-installed-items-dialog.component.html', |
||||
|
styleUrls: ['./iot-hub-installed-items-dialog.component.scss'] |
||||
|
}) |
||||
|
export class TbIotHubInstalledItemsDialogComponent extends DialogComponent<TbIotHubInstalledItemsDialogComponent> { |
||||
|
|
||||
|
item: MpItemVersionView; |
||||
|
|
||||
|
constructor( |
||||
|
protected store: Store<AppState>, |
||||
|
protected router: Router, |
||||
|
protected dialogRef: MatDialogRef<TbIotHubInstalledItemsDialogComponent>, |
||||
|
@Inject(MAT_DIALOG_DATA) public data: IotHubInstalledItemsDialogData |
||||
|
) { |
||||
|
super(store, router, dialogRef); |
||||
|
this.item = data.item; |
||||
|
} |
||||
|
|
||||
|
close(): void { |
||||
|
this.dialogRef.close(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,162 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
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-installed-table-container"> |
||||
|
<div class="tb-installed-table-wrapper"> |
||||
|
<table mat-table [dataSource]="dataSource" matSort matSortActive="createdTime" matSortDirection="desc"> |
||||
|
<!-- Name --> |
||||
|
<ng-container matColumnDef="itemName"> |
||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'iot-hub.item-name' | translate }}</th> |
||||
|
<td mat-cell *matCellDef="let item">{{ item.itemName }}</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<!-- Type --> |
||||
|
<ng-container matColumnDef="itemType"> |
||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'iot-hub.item-type' | translate }}</th> |
||||
|
<td mat-cell *matCellDef="let item"> |
||||
|
<span class="tb-installed-type" [ngClass]="getItemTypeChipClass(item.itemType)"> |
||||
|
<mat-icon class="tb-installed-type-icon">{{ getItemTypeIcon(item.itemType) }}</mat-icon> |
||||
|
{{ getItemTypeLabel(item.itemType) }} |
||||
|
</span> |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<!-- Version --> |
||||
|
<ng-container matColumnDef="version"> |
||||
|
<th mat-header-cell *matHeaderCellDef>{{ 'iot-hub.item-version' | translate }}</th> |
||||
|
<td mat-cell *matCellDef="let item">{{ item.version }}</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<!-- Installed date --> |
||||
|
<ng-container matColumnDef="createdTime"> |
||||
|
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'iot-hub.installed-date' | translate }}</th> |
||||
|
<td mat-cell *matCellDef="let item">{{ item.createdTime | date:'mediumDate' }}</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<!-- Updates --> |
||||
|
<ng-container matColumnDef="updates"> |
||||
|
<th mat-header-cell *matHeaderCellDef>@if (updatesChecked) { {{ 'iot-hub.updates' | translate }} }</th> |
||||
|
<td mat-cell *matCellDef="let item"> |
||||
|
@if (updatesChecked) { |
||||
|
@if (getPublishedVersionInfo(item); as pubInfo) { |
||||
|
@if (pubInfo.publishedVersionId !== item.itemVersionId) { |
||||
|
<div class="tb-installed-update-row"> |
||||
|
@if (!itemVersion) { |
||||
|
<button class="tb-installed-version-btn" (click)="viewUpdateDetails(pubInfo, item)"> |
||||
|
<mat-icon>arrow_upward</mat-icon> |
||||
|
v{{ pubInfo.publishedVersion }} {{ 'iot-hub.available' | translate }} |
||||
|
</button> |
||||
|
} |
||||
|
<button mat-stroked-button color="warn" class="tb-installed-update-btn" (click)="updateItem(item, pubInfo)"> |
||||
|
@if (itemVersion) { |
||||
|
{{ 'iot-hub.update-to-version' | translate:{ version: pubInfo.publishedVersion } }} |
||||
|
} @else { |
||||
|
{{ 'iot-hub.update' | translate }} |
||||
|
} |
||||
|
</button> |
||||
|
</div> |
||||
|
} @else { |
||||
|
<span class="tb-installed-up-to-date"> |
||||
|
<mat-icon>check</mat-icon> |
||||
|
{{ 'iot-hub.up-to-date' | translate }} |
||||
|
</span> |
||||
|
} |
||||
|
} @else { |
||||
|
<span class="tb-installed-up-to-date"> |
||||
|
<mat-icon>check</mat-icon> |
||||
|
{{ 'iot-hub.up-to-date' | translate }} |
||||
|
</span> |
||||
|
} |
||||
|
} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<!-- Actions --> |
||||
|
<ng-container matColumnDef="actions" stickyEnd> |
||||
|
<th mat-header-cell *matHeaderCellDef> |
||||
|
<div [ngClass]="itemVersion ? 'gt-sm:!hidden' : 'gt-md:!hidden'" style="min-width: 48px;"></div> |
||||
|
<div [ngClass]="itemVersion ? 'lt-md:!hidden' : 'lt-lg:!hidden'" style="min-width: 144px;"></div> |
||||
|
</th> |
||||
|
<td mat-cell *matCellDef="let item"> |
||||
|
<!-- Desktop: individual buttons --> |
||||
|
<div class="tb-installed-actions" [ngClass]="itemVersion ? 'lt-md:!hidden' : 'lt-lg:!hidden'"> |
||||
|
@if (!itemVersion || item.itemType === 'DEVICE') { |
||||
|
<button mat-icon-button (click)="viewItemDetails(item)" matTooltip="{{ (item.itemType === 'DEVICE' ? 'iot-hub.review-device-instructions' : 'iot-hub.view-item-details') | translate }}"> |
||||
|
<mat-icon>info_outline</mat-icon> |
||||
|
</button> |
||||
|
} |
||||
|
<button mat-icon-button (click)="openEntity(item)" matTooltip="{{ (item.itemType === 'SOLUTION_TEMPLATE' ? 'iot-hub.goto-main-dashboard' : 'iot-hub.open-item-type') | translate:{ type: getItemTypeLabel(item.itemType) } }}"> |
||||
|
<mat-icon>open_in_new</mat-icon> |
||||
|
</button> |
||||
|
<button mat-icon-button (click)="deleteItem(item)" matTooltip="{{ 'iot-hub.remove' | translate }}"> |
||||
|
<tb-icon>mdi:trash-can-outline</tb-icon> |
||||
|
</button> |
||||
|
</div> |
||||
|
<!-- Mobile: dropdown menu --> |
||||
|
<div [ngClass]="itemVersion ? 'gt-sm:!hidden' : 'gt-md:!hidden'"> |
||||
|
<button mat-icon-button (click)="$event.stopPropagation()" [matMenuTriggerFor]="actionsMenu"> |
||||
|
<mat-icon>more_vert</mat-icon> |
||||
|
</button> |
||||
|
<mat-menu #actionsMenu="matMenu" xPosition="before"> |
||||
|
@if (!itemVersion || item.itemType === 'DEVICE') { |
||||
|
<button mat-menu-item (click)="viewItemDetails(item)"> |
||||
|
<mat-icon>info_outline</mat-icon> |
||||
|
<span>{{ (item.itemType === 'DEVICE' ? 'iot-hub.review-device-instructions' : 'iot-hub.view-item-details') | translate }}</span> |
||||
|
</button> |
||||
|
} |
||||
|
<button mat-menu-item (click)="openEntity(item)"> |
||||
|
<mat-icon>open_in_new</mat-icon> |
||||
|
<span>{{ (item.itemType === 'SOLUTION_TEMPLATE' ? 'iot-hub.goto-main-dashboard' : 'iot-hub.open-item-type') | translate:{ type: getItemTypeLabel(item.itemType) } }}</span> |
||||
|
</button> |
||||
|
<button mat-menu-item (click)="deleteItem(item)"> |
||||
|
<tb-icon>mdi:trash-can-outline</tb-icon> |
||||
|
<span>{{ 'iot-hub.remove' | translate }}</span> |
||||
|
</button> |
||||
|
</mat-menu> |
||||
|
</div> |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr> |
||||
|
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr> |
||||
|
</table> |
||||
|
</div> |
||||
|
|
||||
|
@if (isLoading) { |
||||
|
<div class="tb-installed-loading"> |
||||
|
<mat-spinner diameter="40"></mat-spinner> |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
@if (!isLoading && dataSource.length === 0) { |
||||
|
<div class="tb-installed-empty"> |
||||
|
{{ 'iot-hub.no-installed-items' | translate }} |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
<!-- Paginator inside border --> |
||||
|
<mat-divider></mat-divider> |
||||
|
<mat-paginator |
||||
|
[length]="totalElements" |
||||
|
[pageSize]="pageSize" |
||||
|
[pageIndex]="pageIndex" |
||||
|
[pageSizeOptions]="[10, 25, 50]" |
||||
|
[hidePageSize]="hidePageSize" |
||||
|
(page)="onPageChange($event)" |
||||
|
showFirstLastButtons> |
||||
|
</mat-paginator> |
||||
|
</div> |
||||
@ -0,0 +1,274 @@ |
|||||
|
/** |
||||
|
* 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: flex; |
||||
|
flex-direction: column; |
||||
|
flex: 1; |
||||
|
min-height: 0; |
||||
|
} |
||||
|
|
||||
|
// Table + paginator container — unified border |
||||
|
.tb-installed-table-container { |
||||
|
border: 1px solid rgba(0, 0, 0, 0.12); |
||||
|
border-radius: 8px; |
||||
|
overflow: hidden; |
||||
|
flex: 1; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
} |
||||
|
|
||||
|
.tb-installed-table-wrapper { |
||||
|
overflow: auto; |
||||
|
flex: 1; |
||||
|
|
||||
|
table { |
||||
|
width: 100%; |
||||
|
} |
||||
|
|
||||
|
// Header cells — Design: 16px Medium, rgba(0,0,0,0.87), tracking 0.25 |
||||
|
th.mat-mdc-header-cell { |
||||
|
font-size: 16px; |
||||
|
font-weight: 500; |
||||
|
line-height: 24px; |
||||
|
letter-spacing: 0.25px; |
||||
|
color: rgba(0, 0, 0, 0.87); |
||||
|
} |
||||
|
|
||||
|
// Body cells — Design: 14px Regular, rgba(0,0,0,0.76), tracking 0.2 |
||||
|
td.mat-mdc-cell { |
||||
|
font-size: 14px; |
||||
|
font-weight: 400; |
||||
|
letter-spacing: 0.2px; |
||||
|
color: rgba(0, 0, 0, 0.76); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
|
||||
|
// Column sizing |
||||
|
.mat-column-itemName { |
||||
|
width: 50%; |
||||
|
min-width: 200px; |
||||
|
max-width: 50%; |
||||
|
} |
||||
|
|
||||
|
.mat-column-itemType { |
||||
|
width: 50%; |
||||
|
min-width: 50%; |
||||
|
max-width: 50%; |
||||
|
} |
||||
|
|
||||
|
.mat-column-version { |
||||
|
width: 80px; |
||||
|
min-width: 80px; |
||||
|
max-width: 80px; |
||||
|
} |
||||
|
|
||||
|
.mat-column-createdTime { |
||||
|
width: 100px; |
||||
|
min-width: 100px; |
||||
|
max-width: 100px; |
||||
|
} |
||||
|
|
||||
|
.mat-column-updates { |
||||
|
width: 250px; |
||||
|
min-width: 250px; |
||||
|
max-width: 250px; |
||||
|
} |
||||
|
|
||||
|
.mat-column-actions { |
||||
|
width: 108px; |
||||
|
min-width: 108px; |
||||
|
max-width: 108px; |
||||
|
} |
||||
|
|
||||
|
::ng-deep .mat-mdc-paginator-range-label { |
||||
|
white-space: nowrap; |
||||
|
} |
||||
|
|
||||
|
// Nowrap columns |
||||
|
.mat-column-itemType, |
||||
|
.mat-column-createdTime, |
||||
|
.mat-column-updates { |
||||
|
white-space: nowrap; |
||||
|
} |
||||
|
|
||||
|
// Type chip — Design: pill, h-32, rounded-16, px-12 py-6, gap-4, bg at 6% opacity |
||||
|
.tb-installed-type { |
||||
|
display: inline-flex; |
||||
|
align-items: center; |
||||
|
gap: 4px; |
||||
|
height: 32px; |
||||
|
padding: 6px 12px; |
||||
|
border-radius: 16px; |
||||
|
font-size: 14px; |
||||
|
font-weight: 400; |
||||
|
line-height: 20px; |
||||
|
letter-spacing: 0.2px; |
||||
|
} |
||||
|
|
||||
|
.tb-installed-type-icon { |
||||
|
font-size: 18px; |
||||
|
width: 18px; |
||||
|
height: 18px; |
||||
|
color: inherit !important; |
||||
|
--mat-icon-color: currentColor; |
||||
|
} |
||||
|
|
||||
|
.tb-type-widget { color: rgb(32, 115, 61); background: rgba(46, 166, 88, 0.06); } |
||||
|
.tb-type-dashboard { color: rgb(61, 76, 166); background: rgba(61, 76, 166, 0.06); } |
||||
|
.tb-type-solution-template { color: rgb(0, 137, 186); background: rgba(0, 137, 186, 0.06); } |
||||
|
.tb-type-calc-field { color: rgb(40, 120, 148); background: rgba(40, 120, 148, 0.06); } |
||||
|
.tb-type-rule-chain { color: rgb(178, 121, 29); background: rgba(179, 121, 29, 0.06); } |
||||
|
.tb-type-device { color: rgb(24, 146, 110); background: rgba(24, 146, 110, 0.06); } |
||||
|
|
||||
|
// Update row — flex container for version btn + update btn |
||||
|
.tb-installed-update-row { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 12px; |
||||
|
} |
||||
|
|
||||
|
// "vX.X.X Available" button — Design: border rgba(0,0,0,0.12), rounded-4, px-12 py-6, gap-8 |
||||
|
.tb-installed-version-btn { |
||||
|
display: inline-flex; |
||||
|
align-items: center; |
||||
|
gap: 8px; |
||||
|
font-size: 14px; |
||||
|
font-weight: 500; |
||||
|
line-height: 20px; |
||||
|
letter-spacing: 0.25px; |
||||
|
color: rgba(0, 0, 0, 0.76); |
||||
|
background: none; |
||||
|
border: 1px solid rgba(0, 0, 0, 0.12); |
||||
|
border-radius: 4px; |
||||
|
padding: 6px 12px; |
||||
|
cursor: pointer; |
||||
|
font-family: inherit; |
||||
|
|
||||
|
mat-icon { |
||||
|
font-size: 18px; |
||||
|
width: 18px; |
||||
|
height: 18px; |
||||
|
color: inherit !important; |
||||
|
--mat-icon-color: currentColor; |
||||
|
} |
||||
|
|
||||
|
&:hover { |
||||
|
background: rgba(0, 0, 0, 0.04); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// "Update" button — Design: same height as available btn, border matches text color |
||||
|
.tb-installed-update-btn.mat-mdc-outlined-button { |
||||
|
font-size: 14px; |
||||
|
font-weight: 500; |
||||
|
line-height: 20px; |
||||
|
letter-spacing: 0.25px; |
||||
|
padding: 6px 12px; |
||||
|
height: auto; |
||||
|
--mat-button-outlined-outline-color: var(--mat-button-outlined-label-text-color); |
||||
|
} |
||||
|
|
||||
|
// "Up to date" — Design: 14px Medium, #198038, gap-8, py-6 |
||||
|
.tb-installed-up-to-date { |
||||
|
display: inline-flex; |
||||
|
align-items: center; |
||||
|
gap: 8px; |
||||
|
font-size: 14px; |
||||
|
font-weight: 500; |
||||
|
line-height: 20px; |
||||
|
letter-spacing: 0.25px; |
||||
|
color: #198038; |
||||
|
padding: 6px 0; |
||||
|
|
||||
|
mat-icon { |
||||
|
font-size: 18px; |
||||
|
width: 18px; |
||||
|
height: 18px; |
||||
|
color: inherit !important; |
||||
|
--mat-icon-color: currentColor; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Actions column |
||||
|
.tb-installed-actions { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 0; |
||||
|
justify-content: flex-end; |
||||
|
|
||||
|
.mat-mdc-icon-button { |
||||
|
width: 36px; |
||||
|
height: 36px; |
||||
|
padding: 6px; |
||||
|
--mdc-icon-button-state-layer-size: 36px; |
||||
|
--mdc-icon-button-icon-color: rgba(0, 0, 0, 0.54); |
||||
|
color: rgba(0, 0, 0, 0.54); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Loading / Empty |
||||
|
.tb-installed-loading { |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
padding: 48px 0; |
||||
|
} |
||||
|
|
||||
|
.tb-installed-empty { |
||||
|
text-align: center; |
||||
|
padding: 48px 0; |
||||
|
color: rgba(0, 0, 0, 0.54); |
||||
|
font-size: 14px; |
||||
|
} |
||||
|
|
||||
|
:host(:not(.tb-installed-with-version)) { |
||||
|
@media #{$mat-lt-lg} { |
||||
|
.mat-column-actions { |
||||
|
width: 36px; |
||||
|
min-width: 36px; |
||||
|
max-width: 36px; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
:host(.tb-installed-with-version) { |
||||
|
.mat-column-version { |
||||
|
width: 160px; |
||||
|
min-width: 0; |
||||
|
max-width: 160px; |
||||
|
} |
||||
|
|
||||
|
.mat-column-createdTime { |
||||
|
width: 200px; |
||||
|
min-width: 0; |
||||
|
max-width: 200px; |
||||
|
} |
||||
|
|
||||
|
.mat-column-updates { |
||||
|
min-width: 0; |
||||
|
} |
||||
|
|
||||
|
@media #{$mat-lt-md} { |
||||
|
.mat-column-actions { |
||||
|
width: 36px; |
||||
|
min-width: 36px; |
||||
|
max-width: 36px; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,395 @@ |
|||||
|
///
|
||||
|
/// 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 { |
||||
|
AfterViewInit, |
||||
|
ChangeDetectorRef, |
||||
|
Component, |
||||
|
ElementRef, |
||||
|
HostBinding, |
||||
|
Input, |
||||
|
NgZone, |
||||
|
OnChanges, |
||||
|
OnDestroy, |
||||
|
OnInit, |
||||
|
SimpleChanges, |
||||
|
ViewChild |
||||
|
} from '@angular/core'; |
||||
|
import { Router } from '@angular/router'; |
||||
|
import { MatDialog } from '@angular/material/dialog'; |
||||
|
import { MatSort } from '@angular/material/sort'; |
||||
|
import { MatPaginator } from '@angular/material/paginator'; |
||||
|
import { IotHubApiService } from '@core/http/iot-hub-api.service'; |
||||
|
import { TranslateService } from '@ngx-translate/core'; |
||||
|
import { Store } from '@ngrx/store'; |
||||
|
import { AppState } from '@core/core.state'; |
||||
|
import { ActionNotificationShow } from '@core/notification/notification.actions'; |
||||
|
import { switchMap } from 'rxjs/operators'; |
||||
|
import { PageLink } from '@shared/models/page/page-link'; |
||||
|
import { Direction, SortOrder } from '@shared/models/page/sort-order'; |
||||
|
import { |
||||
|
DeviceInstalledItemDescriptor, |
||||
|
IotHubInstalledItem, |
||||
|
ItemPublishedVersionInfo |
||||
|
} from '@shared/models/iot-hub/iot-hub-installed-item.models'; |
||||
|
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; |
||||
|
import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; |
||||
|
import { EntityType } from '@shared/models/entity-type.models'; |
||||
|
import { getEntityDetailsPageURL } from '@core/utils'; |
||||
|
import { |
||||
|
IotHubItemDetailDialogData, |
||||
|
TbIotHubItemDetailDialogComponent |
||||
|
} from '@home/components/iot-hub/iot-hub-item-detail-dialog.component'; |
||||
|
import { |
||||
|
IotHubUpdateDialogData, |
||||
|
TbIotHubUpdateDialogComponent |
||||
|
} from '@home/components/iot-hub/iot-hub-update-dialog.component'; |
||||
|
import { |
||||
|
IotHubDeleteDialogData, |
||||
|
TbIotHubDeleteDialogComponent |
||||
|
} from '@home/components/iot-hub/iot-hub-delete-dialog.component'; |
||||
|
import { |
||||
|
DeviceInstallDialogData, |
||||
|
TbDeviceInstallDialogComponent |
||||
|
} from '@home/components/iot-hub/device-install-dialog/device-install-dialog.component'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'tb-iot-hub-installed-items-table', |
||||
|
standalone: false, |
||||
|
templateUrl: './iot-hub-installed-items-table.component.html', |
||||
|
styleUrls: ['./iot-hub-installed-items-table.component.scss'] |
||||
|
}) |
||||
|
export class TbIotHubInstalledItemsTableComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy { |
||||
|
|
||||
|
@Input() textSearch = ''; |
||||
|
@Input() typeFilters: string[] = []; |
||||
|
@Input() itemVersion: MpItemVersionView; |
||||
|
|
||||
|
@HostBinding('class.tb-installed-with-version') get withVersionClass() { |
||||
|
return !!this.itemVersion; |
||||
|
} |
||||
|
|
||||
|
displayedColumns: string[] = ['itemName', 'itemType', 'version', 'createdTime', 'updates', 'actions']; |
||||
|
dataSource: IotHubInstalledItem[] = []; |
||||
|
totalElements = 0; |
||||
|
pageSize = 10; |
||||
|
pageIndex = 0; |
||||
|
hidePageSize = false; |
||||
|
isLoading = false; |
||||
|
|
||||
|
publishedVersionMap = new Map<string, ItemPublishedVersionInfo>(); |
||||
|
updatesChecked = false; |
||||
|
isCheckingUpdates = false; |
||||
|
|
||||
|
private widgetResize$: ResizeObserver; |
||||
|
|
||||
|
@ViewChild(MatSort, {static: true}) sort: MatSort; |
||||
|
@ViewChild(MatPaginator, {static: true}) paginator: MatPaginator; |
||||
|
|
||||
|
constructor( |
||||
|
private iotHubApiService: IotHubApiService, |
||||
|
private translate: TranslateService, |
||||
|
private store: Store<AppState>, |
||||
|
private router: Router, |
||||
|
private dialog: MatDialog, |
||||
|
private elementRef: ElementRef, |
||||
|
private zone: NgZone, |
||||
|
private cd: ChangeDetectorRef |
||||
|
) { |
||||
|
this.widgetResize$ = new ResizeObserver(() => { |
||||
|
this.zone.run(() => { |
||||
|
const shouldHide = this.elementRef.nativeElement.offsetWidth < 640; |
||||
|
if (shouldHide !== this.hidePageSize) { |
||||
|
this.hidePageSize = shouldHide; |
||||
|
this.cd.markForCheck(); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
this.widgetResize$.observe(this.elementRef.nativeElement); |
||||
|
} |
||||
|
|
||||
|
ngOnInit(): void { |
||||
|
if (this.itemVersion) { |
||||
|
this.displayedColumns = ['version', 'createdTime', 'updates', 'actions']; |
||||
|
this.updatesChecked = true; |
||||
|
this.publishedVersionMap.set(this.itemVersion.itemId, { |
||||
|
itemId: this.itemVersion.itemId, |
||||
|
publishedVersionId: this.itemVersion.id, |
||||
|
publishedVersion: this.itemVersion.version |
||||
|
}); |
||||
|
} |
||||
|
this.loadData(); |
||||
|
} |
||||
|
|
||||
|
ngOnChanges(changes: SimpleChanges): void { |
||||
|
if (changes.textSearch || changes.typeFilters) { |
||||
|
if (!changes.textSearch?.firstChange || !changes.typeFilters?.firstChange) { |
||||
|
this.pageIndex = 0; |
||||
|
if (this.paginator) { |
||||
|
this.paginator.pageIndex = 0; |
||||
|
} |
||||
|
this.loadData(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
ngAfterViewInit(): void { |
||||
|
this.sort.sortChange.subscribe(() => { |
||||
|
this.pageIndex = 0; |
||||
|
this.paginator.pageIndex = 0; |
||||
|
this.loadData(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
ngOnDestroy(): void { |
||||
|
this.widgetResize$?.disconnect(); |
||||
|
} |
||||
|
|
||||
|
onPageChange(event: any): void { |
||||
|
this.pageIndex = event.pageIndex; |
||||
|
this.pageSize = event.pageSize; |
||||
|
this.loadData(); |
||||
|
} |
||||
|
|
||||
|
getItemTypeIcon(itemType: string): string { |
||||
|
switch (itemType) { |
||||
|
case 'WIDGET': return 'widgets'; |
||||
|
case 'DASHBOARD': return 'dashboard'; |
||||
|
case 'SOLUTION_TEMPLATE': return 'integration_instructions'; |
||||
|
case 'CALCULATED_FIELD': return 'functions'; |
||||
|
case 'RULE_CHAIN': return 'settings_ethernet'; |
||||
|
case 'DEVICE': return 'memory'; |
||||
|
default: return 'category'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
deleteItem(item: IotHubInstalledItem): void { |
||||
|
const dialogRef = this.dialog.open(TbIotHubDeleteDialogComponent, { |
||||
|
panelClass: ['tb-dialog'], |
||||
|
autoFocus: false, |
||||
|
data: { itemName: item.itemName, itemType: item.itemType } as IotHubDeleteDialogData |
||||
|
}); |
||||
|
dialogRef.afterClosed().subscribe(confirmed => { |
||||
|
if (confirmed) { |
||||
|
this.iotHubApiService.deleteInstalledItem(item.id.id).subscribe({ |
||||
|
next: () => { |
||||
|
this.store.dispatch(new ActionNotificationShow({ |
||||
|
message: this.translate.instant('iot-hub.installed-item-removed', {name: item.itemName}), |
||||
|
type: 'success', |
||||
|
duration: 3000 |
||||
|
})); |
||||
|
this.loadData(); |
||||
|
}, |
||||
|
error: () => { |
||||
|
this.store.dispatch(new ActionNotificationShow({ |
||||
|
message: this.translate.instant('iot-hub.installed-item-remove-error', {name: item.itemName}), |
||||
|
type: 'error', |
||||
|
duration: 5000 |
||||
|
})); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
getItemTypeLabel(itemType: string): string { |
||||
|
const key = itemTypeTranslations.get(itemType as ItemType); |
||||
|
return key ? this.translate.instant(key) : itemType; |
||||
|
} |
||||
|
|
||||
|
getItemTypeChipClass(itemType: string): string { |
||||
|
switch (itemType) { |
||||
|
case 'WIDGET': return 'tb-type-widget'; |
||||
|
case 'DASHBOARD': return 'tb-type-dashboard'; |
||||
|
case 'CALCULATED_FIELD': return 'tb-type-calc-field'; |
||||
|
case 'RULE_CHAIN': return 'tb-type-rule-chain'; |
||||
|
case 'DEVICE': return 'tb-type-device'; |
||||
|
case 'SOLUTION_TEMPLATE': return 'tb-type-solution-template'; |
||||
|
default: return ''; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
viewItemDetails(item: IotHubInstalledItem): void { |
||||
|
if (item.itemType === 'DEVICE') { |
||||
|
this.openDeviceReviewDialog(item); |
||||
|
return; |
||||
|
} |
||||
|
this.iotHubApiService.getVersionInfo(item.itemVersionId, {ignoreLoading: true}).subscribe(versionView => { |
||||
|
const dialogRef = this.dialog.open(TbIotHubItemDetailDialogComponent, { |
||||
|
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], |
||||
|
autoFocus: false, |
||||
|
data: { |
||||
|
item: versionView, |
||||
|
installedItem: item |
||||
|
} as IotHubItemDetailDialogData |
||||
|
}); |
||||
|
dialogRef.afterClosed().subscribe(result => { |
||||
|
if (result === 'updated' || result === 'deleted') { |
||||
|
this.loadData(); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private openDeviceReviewDialog(item: IotHubInstalledItem): void { |
||||
|
const descriptor = item.descriptor as DeviceInstalledItemDescriptor; |
||||
|
if (!descriptor.installState || !descriptor.selectedInstallMethod) { |
||||
|
this.iotHubApiService.getVersionInfo(item.itemVersionId, {ignoreLoading: true}).subscribe(versionView => { |
||||
|
this.dialog.open(TbIotHubItemDetailDialogComponent, { |
||||
|
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], |
||||
|
autoFocus: false, |
||||
|
data: { item: versionView, installedItem: item } as IotHubItemDetailDialogData |
||||
|
}); |
||||
|
}); |
||||
|
return; |
||||
|
} |
||||
|
this.iotHubApiService.getVersionFileData(item.itemVersionId, { ignoreLoading: true, ignoreErrors: true }).subscribe({ |
||||
|
next: async (blob: Blob) => { |
||||
|
const zipData = await blob.arrayBuffer(); |
||||
|
this.iotHubApiService.getVersionInfo(item.itemVersionId, {ignoreLoading: true, ignoreErrors: true}).subscribe(versionView => { |
||||
|
this.dialog.open(TbDeviceInstallDialogComponent, { |
||||
|
panelClass: ['tb-dialog', 'tb-fullscreen-dialog-lt-md'], |
||||
|
disableClose: false, |
||||
|
autoFocus: false, |
||||
|
data: { |
||||
|
item: versionView, |
||||
|
zipData, |
||||
|
reviewMode: true, |
||||
|
selectedInstallMethod: descriptor.selectedInstallMethod, |
||||
|
installState: descriptor.installState |
||||
|
} as DeviceInstallDialogData |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
getEntityId(item: IotHubInstalledItem): string | null { |
||||
|
const descriptor = item.descriptor; |
||||
|
switch (descriptor.type) { |
||||
|
case 'WIDGET': return descriptor.widgetTypeId?.id; |
||||
|
case 'DASHBOARD': return descriptor.dashboardId?.id; |
||||
|
case 'CALCULATED_FIELD': return descriptor.entityId?.id; |
||||
|
case 'RULE_CHAIN': return descriptor.ruleChainId?.id; |
||||
|
case 'DEVICE': return descriptor.dashboardId?.id ?? null; |
||||
|
case 'SOLUTION_TEMPLATE': return descriptor.dashboardId?.id; |
||||
|
default: return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
getEntityType(item: IotHubInstalledItem): EntityType | null { |
||||
|
const descriptor = item.descriptor; |
||||
|
switch (descriptor.type) { |
||||
|
case 'WIDGET': return EntityType.WIDGET_TYPE; |
||||
|
case 'DASHBOARD': return EntityType.DASHBOARD; |
||||
|
case 'CALCULATED_FIELD': return descriptor.entityId?.entityType as EntityType; |
||||
|
case 'RULE_CHAIN': return EntityType.RULE_CHAIN; |
||||
|
case 'DEVICE': return descriptor.dashboardId ? EntityType.DASHBOARD : null; |
||||
|
case 'SOLUTION_TEMPLATE': return EntityType.DASHBOARD; |
||||
|
default: return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
openEntity(item: IotHubInstalledItem): void { |
||||
|
const entityType = this.getEntityType(item); |
||||
|
const entityId = this.getEntityId(item); |
||||
|
if (entityType && entityId) { |
||||
|
const url = getEntityDetailsPageURL(entityId, entityType); |
||||
|
if (url) { |
||||
|
window.open(this.router.serializeUrl(this.router.parseUrl(url)), '_blank'); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
checkForUpdates(): void { |
||||
|
this.isCheckingUpdates = true; |
||||
|
this.iotHubApiService.getInstalledItemIds({ ignoreLoading: true }).pipe( |
||||
|
switchMap(itemIds => this.iotHubApiService.getItemsPublishedVersions(itemIds, { ignoreLoading: true })) |
||||
|
).subscribe({ |
||||
|
next: (infos) => { |
||||
|
this.publishedVersionMap.clear(); |
||||
|
infos.forEach(info => this.publishedVersionMap.set(info.itemId, info)); |
||||
|
this.updatesChecked = true; |
||||
|
this.isCheckingUpdates = false; |
||||
|
}, |
||||
|
error: () => { |
||||
|
this.isCheckingUpdates = false; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
viewUpdateDetails(publishedInfo: ItemPublishedVersionInfo, installedItem: IotHubInstalledItem): void { |
||||
|
this.iotHubApiService.getVersionInfo(publishedInfo.publishedVersionId, {ignoreLoading: true}).subscribe(versionView => { |
||||
|
const dialogRef = this.dialog.open(TbIotHubItemDetailDialogComponent, { |
||||
|
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], |
||||
|
autoFocus: false, |
||||
|
data: { |
||||
|
item: versionView, |
||||
|
installedItem |
||||
|
} as IotHubItemDetailDialogData |
||||
|
}); |
||||
|
dialogRef.afterClosed().subscribe(result => { |
||||
|
if (result === 'updated' || result === 'deleted') { |
||||
|
this.loadData(); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
updateItem(item: IotHubInstalledItem, publishedInfo: ItemPublishedVersionInfo): void { |
||||
|
const dialogRef = this.dialog.open(TbIotHubUpdateDialogComponent, { |
||||
|
panelClass: ['tb-dialog'], |
||||
|
data: { |
||||
|
installedItemId: item.id.id, |
||||
|
itemName: item.itemName, |
||||
|
itemType: item.itemType as ItemType, |
||||
|
version: publishedInfo.publishedVersion, |
||||
|
versionId: publishedInfo.publishedVersionId, |
||||
|
} as IotHubUpdateDialogData |
||||
|
}); |
||||
|
dialogRef.afterClosed().subscribe(result => { |
||||
|
if (result === 'updated') { |
||||
|
this.loadData(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
getPublishedVersionInfo(item: IotHubInstalledItem): ItemPublishedVersionInfo | undefined { |
||||
|
return this.publishedVersionMap.get(item.itemId); |
||||
|
} |
||||
|
|
||||
|
private loadData(): void { |
||||
|
this.isLoading = true; |
||||
|
const sortOrder: SortOrder = { |
||||
|
property: this.sort?.active || 'createdTime', |
||||
|
direction: this.sort?.direction === 'asc' ? Direction.ASC : Direction.DESC |
||||
|
}; |
||||
|
const pageLink = new PageLink(this.pageSize, this.pageIndex, this.textSearch || null, sortOrder); |
||||
|
const typeFilters = this.typeFilters?.length ? this.typeFilters : null; |
||||
|
this.iotHubApiService.getInstalledItems(pageLink, typeFilters, this.itemVersion?.itemId, {ignoreLoading: true}).subscribe({ |
||||
|
next: (data) => { |
||||
|
this.dataSource = data.data; |
||||
|
this.totalElements = data.totalElements; |
||||
|
this.isLoading = false; |
||||
|
}, |
||||
|
error: () => { |
||||
|
this.dataSource = []; |
||||
|
this.totalElements = 0; |
||||
|
this.isLoading = false; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,174 @@ |
|||||
|
///
|
||||
|
/// 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 { AfterViewInit, Directive, ElementRef, Input, NgZone, OnDestroy, Renderer2 } from '@angular/core'; |
||||
|
|
||||
|
@Directive({ |
||||
|
selector: '[tb-chip-overflow]', |
||||
|
standalone: false |
||||
|
}) |
||||
|
export class ChipOverflowDirective implements AfterViewInit, OnDestroy { |
||||
|
|
||||
|
@Input() chipSelector = '.tb-chip'; |
||||
|
@Input() gap = 8; |
||||
|
@Input() overflowTemplate = '+{n}'; |
||||
|
@Input() overflowClass = 'tb-overflow-chip'; |
||||
|
@Input() minChips = 1; |
||||
|
|
||||
|
private resizeObserver?: ResizeObserver; |
||||
|
private mutationObserver?: MutationObserver; |
||||
|
private overflowEl!: HTMLElement; |
||||
|
private timeoutId?: NodeJS.Timeout; |
||||
|
|
||||
|
constructor( |
||||
|
private host: ElementRef<HTMLElement>, |
||||
|
private renderer: Renderer2, |
||||
|
private zone: NgZone |
||||
|
) {} |
||||
|
|
||||
|
ngAfterViewInit(): void { |
||||
|
this.prepareHost(); |
||||
|
this.createOverflowChip(); |
||||
|
this.zone.runOutsideAngular(() => { |
||||
|
requestAnimationFrame(() => { |
||||
|
this.reflow(); |
||||
|
this.observeResize(); |
||||
|
this.observeMutations(); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
ngOnDestroy(): void { |
||||
|
this.resizeObserver?.disconnect(); |
||||
|
this.mutationObserver?.disconnect(); |
||||
|
if (this.timeoutId) { |
||||
|
clearTimeout(this.timeoutId); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private prepareHost() { |
||||
|
const el = this.host.nativeElement; |
||||
|
const styles: { [key: string]: string } = { |
||||
|
display: 'flex', |
||||
|
flexWrap: 'nowrap', |
||||
|
alignItems: 'center', |
||||
|
overflow: 'hidden', |
||||
|
gap: `${this.gap}px`, |
||||
|
whiteSpace: 'nowrap', |
||||
|
minWidth: '0' |
||||
|
}; |
||||
|
Object.entries(styles).forEach(([prop, value]) => |
||||
|
this.renderer.setStyle(el, prop, value) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
private createOverflowChip() { |
||||
|
this.overflowEl = this.renderer.createElement('div') as HTMLElement; |
||||
|
this.renderer.setProperty(this.overflowEl, 'className', this.overflowClass); |
||||
|
this.renderer.setStyle(this.overflowEl, 'display', 'none'); |
||||
|
this.renderer.setStyle(this.overflowEl, 'userSelect', 'none'); |
||||
|
this.renderer.setStyle(this.overflowEl, 'pointerEvents', 'none'); |
||||
|
this.renderer.appendChild(this.host.nativeElement, this.overflowEl); |
||||
|
} |
||||
|
|
||||
|
private observeResize(): void { |
||||
|
this.resizeObserver = new ResizeObserver(() => { |
||||
|
if (this.timeoutId) { |
||||
|
clearTimeout(this.timeoutId); |
||||
|
} |
||||
|
this.timeoutId = setTimeout(() => this.reflow()); |
||||
|
}); |
||||
|
this.resizeObserver.observe(this.host.nativeElement); |
||||
|
} |
||||
|
|
||||
|
private observeMutations(): void { |
||||
|
this.mutationObserver = new MutationObserver(mutations => { |
||||
|
const relevantMutations = mutations.some( |
||||
|
mutation => |
||||
|
mutation.type === 'childList' && |
||||
|
Array.from(mutation.addedNodes).some(node => node instanceof HTMLElement && node.matches(this.chipSelector)) || |
||||
|
Array.from(mutation.removedNodes).some(node => node instanceof HTMLElement && node.matches(this.chipSelector)) |
||||
|
); |
||||
|
|
||||
|
if (relevantMutations) { |
||||
|
if (this.timeoutId) { |
||||
|
clearTimeout(this.timeoutId); |
||||
|
} |
||||
|
this.timeoutId = setTimeout(() => this.reflow()); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
this.mutationObserver.observe(this.host.nativeElement, { |
||||
|
childList: true, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private reflow() { |
||||
|
const container = this.host.nativeElement; |
||||
|
const chips = Array.from(container.querySelectorAll<HTMLElement>(this.chipSelector)) |
||||
|
.filter(el => el !== this.overflowEl); |
||||
|
|
||||
|
if (!chips.length) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 1. Show all chips to measure their natural widths
|
||||
|
chips.forEach(chip => this.renderer.setStyle(chip, 'display', '')); |
||||
|
this.renderer.setStyle(this.overflowEl, 'display', 'none'); |
||||
|
|
||||
|
const chipWidths = chips.map(chip => Math.ceil(chip.offsetWidth)); |
||||
|
|
||||
|
// 2. Hide everything to measure the true available width
|
||||
|
// without content inflating the table cell
|
||||
|
chips.forEach(chip => this.renderer.setStyle(chip, 'display', 'none')); |
||||
|
|
||||
|
const availableWidth = container.clientWidth; |
||||
|
|
||||
|
if (!availableWidth) { |
||||
|
chips.forEach(chip => this.renderer.setStyle(chip, 'display', '')); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 3. Measure overflow chip width
|
||||
|
this.renderer.setProperty(this.overflowEl, 'textContent', this.overflowTemplate.replace('{n}', '9')); |
||||
|
this.renderer.setStyle(this.overflowEl, 'display', 'inline-flex'); |
||||
|
const minOverflowWidth = this.overflowEl.offsetWidth; |
||||
|
this.renderer.setStyle(this.overflowEl, 'display', 'none'); |
||||
|
|
||||
|
// 4. Determine which chips fit
|
||||
|
let usedWidth = 0; |
||||
|
let hiddenCount = 0; |
||||
|
const minChips = Math.max(1, this.minChips); |
||||
|
|
||||
|
for (let i = 0; i < chips.length; i++) { |
||||
|
const chipWidth = chipWidths[i]; |
||||
|
const nextUsedWidth = usedWidth ? usedWidth + this.gap + chipWidth : chipWidth; |
||||
|
const withOverflow = nextUsedWidth + (nextUsedWidth ? this.gap : 0) + minOverflowWidth; |
||||
|
|
||||
|
if (i < minChips || withOverflow <= availableWidth) { |
||||
|
this.renderer.setStyle(chips[i], 'display', 'inline-flex'); |
||||
|
usedWidth = nextUsedWidth; |
||||
|
} else { |
||||
|
hiddenCount++; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (hiddenCount > 0) { |
||||
|
this.renderer.setProperty(this.overflowEl, 'textContent', this.overflowTemplate.replace('{n}', String(hiddenCount))); |
||||
|
this.renderer.setStyle(this.overflowEl, 'display', 'inline-flex'); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue