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