Browse Source

Merge branch 'feature/iot-hub' of github.com:thingsboard/thingsboard into feature/iot-hub

pull/15508/head
Igor Kulikov 6 months ago
parent
commit
9ec39ab0aa
  1. 939
      docs/superpowers/plans/2026-04-22-iot-hub-item-deep-link.md
  2. 289
      docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md
  3. 7
      ui-ngx/src/app/core/http/iot-hub-api.service.ts
  4. 4
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts
  5. 3
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts
  6. 7
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html
  7. 9
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss
  8. 3
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts
  9. 32
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html
  10. 82
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss
  11. 53
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts
  12. 45
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts
  13. 127
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts
  14. 35
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts
  15. 17
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts
  16. 4
      ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts
  17. 10
      ui-ngx/src/assets/locale/locale.constant-en_US.json

939
docs/superpowers/plans/2026-04-22-iot-hub-item-deep-link.md

@ -0,0 +1,939 @@
# IoT Hub item deep link — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship `/iot-hub/{itemId}` (latest published version of an item) and `/iot-hub/version/{itemVersionId}` (specific version snapshot, warning gate if unpublished) deep links that resolve a version, navigate to the type-specific browse page, and open the existing detail dialog.
**Architecture:** A router-reachable `TbIotHubItemResolverComponent` owns resolution: fetch by itemId, (optionally) gate on a blocking warning dialog, then `router.navigate` to `/iot-hub/{typeSegment(type)}` carrying the version in `history.state`. The target type-page consumes the state once and opens the existing `TbIotHubItemDetailDialogComponent` via `IotHubActionsService`, with a new `preview` flag that adds an "Unpublished preview" badge. Zero ThingsBoard backend changes — install flows reuse existing versionId endpoints.
**Tech Stack:** Angular 20, Angular Material dialogs, NgRx (for toast dispatch), RxJS. TypeScript strict mode.
**Note on testing:** `ui-ngx` has no frontend test runner wired up (no `npm test` script, no karma/jest config). Each task is verified by `npm run lint` + `npm run build:prod` passing and, for flow-level tasks, a manual dev-server smoke test documented in the final task.
**Prerequisite (external):** The two new IoT Hub endpoints (`GET /api/items/{itemId}/published` and `GET /api/items/{itemId}/latest`) must be deployed on the target IoT Hub instance for end-to-end testing. Until they land, local verification can be done against a mocked IoT Hub or against the published endpoint only. See the spec (`docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md`) for the full IoT Hub-side contract.
---
## File structure
**Create:**
- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts` — UUID check, type→route-segment mapping, `isPublished` predicate
- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts` — route-reachable controller
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts` — warning dialog logic
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html` — warning dialog template
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss` — warning dialog styles
**Modify:**
- `ui-ngx/src/app/core/http/iot-hub-api.service.ts` — add `getPublishedVersion`, `getLatestVersion`
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts` — add `preview` field on data + component
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html` — preview badge markup
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss` — preview badge styles
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts` — propagate `preview` in `openItemDetail`
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` — declare + export warning dialog
- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts` — two new child routes (placed last)
- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts` — declare resolver component
- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts` — `maybeOpenDeepLinkedItem` handoff
- `ui-ngx/src/assets/locale/locale.constant-en_US.json` — 9 new i18n keys under the existing `"iot-hub"` block
---
### Task 1: Add i18n keys
Add all new English strings up front so later tasks can reference them freely. Other locales follow the existing project convention (en_US only for new iot-hub keys; translators backfill later).
**Files:**
- Modify: `ui-ngx/src/assets/locale/locale.constant-en_US.json`
- [ ] **Step 1: Add keys inside the existing `"iot-hub"` block**
Open `ui-ngx/src/assets/locale/locale.constant-en_US.json`, find the `"iot-hub"` block (starts at the line matching `"iot-hub": {`), and append the following nine keys to it (place them immediately before the closing `}` of the `"iot-hub"` object, keeping JSON valid — i.e. add a trailing comma to the previous key):
```json
"item-detail": "IoT Hub item",
"item-preview": "IoT Hub item preview",
"unpublished-warning-title": "Unpublished content",
"unpublished-warning-text": "This is a preview of unpublished content. It has not been reviewed by IoT Hub. Installing unverified content can introduce security and stability risks — only continue if you trust the creator.",
"unpublished-warning-confirm": "I understand the risk, continue",
"unpublished-preview": "Unpublished preview",
"deep-link-invalid-id": "Invalid IoT Hub item link.",
"deep-link-not-found": "This IoT Hub item doesn't exist or was removed.",
"deep-link-fetch-failed": "Couldn't load IoT Hub item. Please try again."
```
- [ ] **Step 2: Validate JSON**
Run:
```bash
cd ui-ngx && node -e "JSON.parse(require('fs').readFileSync('src/assets/locale/locale.constant-en_US.json', 'utf8')); console.log('OK')"
```
Expected output: `OK`.
- [ ] **Step 3: Commit**
```bash
git add ui-ngx/src/assets/locale/locale.constant-en_US.json
git commit -m "feat(iot-hub): add i18n keys for item deep-link and unpublished warning"
```
---
### Task 2: Deep-link utility module
A tiny, pure-TS helper file. No Angular deps. Exported functions are used by the resolver and (for `isPublished`) by the detail dialog.
**Files:**
- Create: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts`
- [ ] **Step 1: Create the file**
Create `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts` with:
```ts
///
/// 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 { ItemType } from '@shared/models/iot-hub/iot-hub-item.models';
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function isUUID(s: string | null | undefined): s is string {
return !!s && UUID_RE.test(s);
}
export function typeSegment(t: ItemType): string | undefined {
switch (t) {
case ItemType.WIDGET: return 'widgets';
case ItemType.DASHBOARD: return 'dashboards';
case ItemType.SOLUTION_TEMPLATE: return 'solution-templates';
case ItemType.CALCULATED_FIELD: return 'calculated-fields';
case ItemType.RULE_CHAIN: return 'rule-chains';
case ItemType.DEVICE: return 'devices';
default: return undefined;
}
}
export function isPublished(v: MpItemVersionView): boolean {
return !!v.publishedTime && v.publishedTime > 0;
}
export interface DeepLinkOpenItem {
version: MpItemVersionView;
preview: boolean;
}
```
- [ ] **Step 2: Verify TypeScript compiles**
Run:
```bash
cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -20
```
Expected: build succeeds. If `ng build` is too slow for iteration, use `npx tsc --noEmit -p tsconfig.json 2>&1 | grep iot-hub-deep-link || echo 'no type errors in utils file'`.
- [ ] **Step 3: Commit**
```bash
git add ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts
git commit -m "feat(iot-hub): add deep-link utility helpers (isUUID, typeSegment, isPublished)"
```
---
### Task 3: IoT Hub API service — two new methods
Add `getPublishedVersion` and `getLatestVersion` to `IotHubApiService`. Both hit IoT Hub cross-origin endpoints (same `baseUrl` as existing `/api/versions/published`).
**Files:**
- Modify: `ui-ngx/src/app/core/http/iot-hub-api.service.ts`
- [ ] **Step 1: Add the two methods**
Open `ui-ngx/src/app/core/http/iot-hub-api.service.ts`. Insert the following two methods immediately after `getVersionInfo` (which ends near line 100):
```ts
public getPublishedVersion(itemId: string, config?: IotHubRequestConfig): Observable<MpItemVersionView> {
return this.http.get<MpItemVersionView>(
`${this.baseUrl}/api/items/${itemId}/published`,
{ params: this.buildParams(config) }
);
}
public getLatestVersion(itemId: string, config?: IotHubRequestConfig): Observable<MpItemVersionView> {
return this.http.get<MpItemVersionView>(
`${this.baseUrl}/api/items/${itemId}/latest`,
{ params: this.buildParams(config) }
);
}
```
Use the exact `Edit` tool call: `old_string` should be the line ` public getVersionReadme(versionId: string, config?: IotHubRequestConfig): Observable<string> {` (and enough context around it), and `new_string` should prepend the two new methods above it.
- [ ] **Step 2: Verify build**
Run:
```bash
cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -15
```
Expected: build succeeds.
- [ ] **Step 3: Commit**
```bash
git add ui-ngx/src/app/core/http/iot-hub-api.service.ts
git commit -m "feat(iot-hub): add getPublishedVersion and getLatestVersion API methods"
```
---
### Task 4: Unpublished warning dialog component
New dialog styled after `TbIotHubDeleteDialogComponent`: title, description, item summary, Cancel + danger-accented confirm button. Returns `boolean` from `afterClosed`.
**Files:**
- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts`
- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html`
- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss`
- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts`
- [ ] **Step 1: Create the component .ts**
Create `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts`:
```ts
///
/// 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 IotHubUnpublishedWarningDialogData {
item: MpItemVersionView;
}
@Component({
selector: 'tb-iot-hub-unpublished-warning-dialog',
standalone: false,
templateUrl: './iot-hub-unpublished-warning-dialog.component.html',
styleUrls: ['./iot-hub-unpublished-warning-dialog.component.scss']
})
export class TbIotHubUnpublishedWarningDialogComponent extends DialogComponent<TbIotHubUnpublishedWarningDialogComponent, boolean> {
constructor(
protected store: Store<AppState>,
protected router: Router,
protected dialogRef: MatDialogRef<TbIotHubUnpublishedWarningDialogComponent, boolean>,
@Inject(MAT_DIALOG_DATA) public data: IotHubUnpublishedWarningDialogData
) {
super(store, router, dialogRef);
}
confirm(): void {
this.dialogRef.close(true);
}
cancel(): void {
this.dialogRef.close(false);
}
}
```
- [ ] **Step 2: Create the component .html**
Create `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html`:
```html
<!--
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-warning-content">
<div class="tb-iot-hub-warning-title-row">
<mat-icon class="tb-iot-hub-warning-icon">warning</mat-icon>
<h2 class="tb-iot-hub-warning-title">{{ 'iot-hub.unpublished-warning-title' | translate }}</h2>
</div>
<p class="tb-iot-hub-warning-text">{{ 'iot-hub.unpublished-warning-text' | translate }}</p>
<div class="tb-iot-hub-warning-item">
<span class="tb-iot-hub-warning-item-name">{{ data.item.name }}</span>
<span class="tb-iot-hub-warning-item-version">v {{ data.item.version }}</span>
</div>
</div>
<div class="tb-iot-hub-warning-actions">
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="warn" (click)="confirm()">{{ 'iot-hub.unpublished-warning-confirm' | translate }}</button>
</div>
```
- [ ] **Step 3: Create the component .scss**
Create `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss`:
```scss
/**
* 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.
*/
.tb-iot-hub-warning-content {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
max-width: 480px;
}
.tb-iot-hub-warning-title-row {
display: flex;
align-items: center;
gap: 12px;
}
.tb-iot-hub-warning-icon {
color: #d32f2f;
font-size: 28px;
width: 28px;
height: 28px;
}
.tb-iot-hub-warning-title {
font-size: 20px;
font-weight: 600;
line-height: 24px;
letter-spacing: 0.1px;
color: rgba(0, 0, 0, 0.87);
margin: 0;
}
.tb-iot-hub-warning-text {
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: 0.2px;
color: rgba(0, 0, 0, 0.75);
margin: 0;
}
.tb-iot-hub-warning-item {
display: flex;
align-items: baseline;
gap: 8px;
padding: 12px 16px;
background: rgba(211, 47, 47, 0.08);
border-left: 3px solid #d32f2f;
border-radius: 2px;
.tb-iot-hub-warning-item-name {
font-weight: 600;
color: rgba(0, 0, 0, 0.87);
}
.tb-iot-hub-warning-item-version {
font-size: 13px;
color: rgba(0, 0, 0, 0.54);
}
}
.tb-iot-hub-warning-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 8px;
}
```
- [ ] **Step 4: Register in `IotHubComponentsModule`**
Open `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts`.
Add the import line after the existing `TbIotHubDeleteDialogComponent` import:
```ts
import { TbIotHubUnpublishedWarningDialogComponent } from './iot-hub-unpublished-warning-dialog.component';
```
In the `declarations` array, add `TbIotHubUnpublishedWarningDialogComponent` after `TbIotHubDeleteDialogComponent`.
In the `exports` array, add `TbIotHubUnpublishedWarningDialogComponent` after `TbIotHubDeleteDialogComponent`.
- [ ] **Step 5: Verify build**
Run:
```bash
cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -15
```
Expected: build succeeds.
- [ ] **Step 6: Commit**
```bash
git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts \
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html \
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss \
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts
git commit -m "feat(iot-hub): add unpublished content warning dialog"
```
---
### Task 5: Detail dialog `preview` flag + badge, actions service signature
Add `preview?: boolean` to `IotHubItemDetailDialogData`, store on the component, render an "Unpublished preview" badge in the meta-bar next to the version chip. Extend `IotHubActionsService.openItemDetail` to forward the flag.
**Files:**
- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts`
- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html`
- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss`
- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts`
- [ ] **Step 1: Extend `IotHubItemDetailDialogData` + component field**
In `iot-hub-item-detail-dialog.component.ts`, find the `IotHubItemDetailDialogData` interface (around line 43) and add the optional `preview` field:
```ts
export interface IotHubItemDetailDialogData {
item: MpItemVersionView;
installedItem?: IotHubInstalledItem;
installedItemsCount?: number;
mode?: IotHubItemDetailDialogMode;
showCreator?: boolean;
preview?: boolean;
}
```
In the component class, add a new public field below `showCreator`:
```ts
preview: boolean;
```
In the constructor body (below `this.showCreator = data.showCreator !== false;`), add:
```ts
this.preview = data.preview === true;
```
- [ ] **Step 2: Add badge markup in template**
In `iot-hub-item-detail-dialog.component.html`, find the block starting with `<span class="dlg-subtitle-group">` that contains the version icon (around line 39, the `update` mat-icon). Immediately **after** the closing `</span>` of that version group (just before the `@if (item.publishedTime)` block), insert:
```html
@if (preview) {
<span class="dlg-dot"></span>
<span class="dlg-subtitle-group tb-unpublished-preview-badge">
<mat-icon class="dlg-subtitle-icon">warning</mat-icon>
<span>{{ 'iot-hub.unpublished-preview' | translate }}</span>
</span>
}
```
- [ ] **Step 3: Style the badge**
Append to `iot-hub-item-detail-dialog.component.scss`:
```scss
.dlg-subtitle-group.tb-unpublished-preview-badge {
color: #d32f2f;
font-weight: 600;
.dlg-subtitle-icon {
color: #d32f2f;
}
}
```
- [ ] **Step 4: Extend `IotHubActionsService.openItemDetail`**
In `iot-hub-actions.service.ts`, replace the `openItemDetail` method signature and body with:
```ts
openItemDetail(item: MpItemVersionView, installedItem?: IotHubInstalledItem, installedItemsCount?: number,
mode?: IotHubItemDetailDialogMode, showCreator?: boolean, preview?: boolean): Observable<any> {
return this.dialog.open(TbIotHubItemDetailDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
autoFocus: false,
data: { item, installedItem, installedItemsCount, mode, showCreator, preview } as IotHubItemDetailDialogData
}).afterClosed();
}
```
Existing callers pass 5 or fewer args and remain valid — the new parameter is optional.
- [ ] **Step 5: Verify build**
Run:
```bash
cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -15
```
Expected: build succeeds.
- [ ] **Step 6: Commit**
```bash
git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts \
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html \
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss \
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts
git commit -m "feat(iot-hub): add unpublished preview badge to item detail dialog"
```
---
### Task 6: Resolver component
The heart of the feature. Fetches the version, gates on warning for unpublished preview, redirects to the type-page with router state.
**Files:**
- Create: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts`
- [ ] **Step 1: Create the component**
Create `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts`:
```ts
///
/// 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';
import { MatDialog } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { AppState } from '@core/core.state';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import { IotHubApiService } from '@core/http/iot-hub-api.service';
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models';
import {
DeepLinkOpenItem,
isPublished,
isUUID,
typeSegment
} from './iot-hub-deep-link.utils';
import {
IotHubUnpublishedWarningDialogData,
TbIotHubUnpublishedWarningDialogComponent
} from '@home/components/iot-hub/iot-hub-unpublished-warning-dialog.component';
@Component({
selector: 'tb-iot-hub-item-resolver',
standalone: false,
template: ''
})
export class TbIotHubItemResolverComponent implements OnInit {
constructor(
private route: ActivatedRoute,
private router: Router,
private dialog: MatDialog,
private store: Store<AppState>,
private translate: TranslateService,
private iotHubApi: IotHubApiService
) {}
ngOnInit(): void {
const itemId = this.route.snapshot.paramMap.get('itemId');
const preview = this.route.snapshot.data['preview'] === true;
if (!isUUID(itemId)) {
this.failTo('iot-hub.deep-link-invalid-id');
return;
}
const fetch$ = preview
? this.iotHubApi.getLatestVersion(itemId, { ignoreErrors: true })
: this.iotHubApi.getPublishedVersion(itemId, { ignoreErrors: true });
fetch$.subscribe({
next: v => this.handleResolved(v, preview),
error: err => {
const key = err?.status === 404
? 'iot-hub.deep-link-not-found'
: 'iot-hub.deep-link-fetch-failed';
this.failTo(key);
}
});
}
private handleResolved(version: MpItemVersionView, preview: boolean): void {
const segment = typeSegment(version.type);
if (!segment) {
this.failTo('iot-hub.deep-link-fetch-failed');
return;
}
const showWarning = preview && !isPublished(version);
if (showWarning) {
this.dialog.open<
TbIotHubUnpublishedWarningDialogComponent,
IotHubUnpublishedWarningDialogData,
boolean
>(TbIotHubUnpublishedWarningDialogComponent, {
panelClass: ['tb-dialog'],
disableClose: true,
autoFocus: false,
data: { item: version }
}).afterClosed().subscribe(confirmed => {
if (confirmed) {
this.openOnTypePage(version, segment, true);
} else {
this.router.navigate(['/iot-hub'], { replaceUrl: true });
}
});
} else {
this.openOnTypePage(version, segment, false);
}
}
private openOnTypePage(version: MpItemVersionView, segment: string, preview: boolean): void {
const openItem: DeepLinkOpenItem = { version, preview };
this.router.navigate(['/iot-hub', segment], {
state: { openItem },
replaceUrl: true
});
}
private failTo(messageKey: string): void {
this.store.dispatch(new ActionNotificationShow({
message: this.translate.instant(messageKey),
type: 'error',
duration: 5000
}));
this.router.navigate(['/iot-hub'], { replaceUrl: true });
}
}
```
- [ ] **Step 2: Verify TypeScript compiles (the module wiring comes in Task 7)**
The component is not yet declared in any module, so a full `ng build` will fail. Run a type-check only on this file:
```bash
cd ui-ngx && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "iot-hub-item-resolver|iot-hub-deep-link" || echo "no type errors in new files"
```
Expected: `no type errors in new files`.
- [ ] **Step 3: Commit**
```bash
git add ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts
git commit -m "feat(iot-hub): add item resolver component for deep links"
```
---
### Task 7: Routing + module registration
Register the resolver component in `IotHubModule` and add the two new child routes to `iot-hub-routing.module.ts`. Reserved words must be matched first, so the `:itemId` wildcard routes go **last** in the children array.
**Files:**
- Modify: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts`
- Modify: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts`
- [ ] **Step 1: Declare resolver in `IotHubModule`**
In `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts`, add the import:
```ts
import { TbIotHubItemResolverComponent } from './iot-hub-item-resolver.component';
```
In the `declarations` array, add `TbIotHubItemResolverComponent` after `TbIotHubSearchPageComponent`.
- [ ] **Step 2: Add routes**
In `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts`, add the import:
```ts
import { TbIotHubItemResolverComponent } from './iot-hub-item-resolver.component';
```
In the `children` array of the `/iot-hub` route, **immediately before** the closing `]` (after the existing `creator/:creatorId` entry), insert:
```ts
{
path: ':itemId',
component: TbIotHubItemResolverComponent,
data: {
auth: [Authority.TENANT_ADMIN],
title: 'iot-hub.item-detail'
}
},
{
path: ':itemId/preview',
component: TbIotHubItemResolverComponent,
data: {
auth: [Authority.TENANT_ADMIN],
title: 'iot-hub.item-preview',
preview: true
}
}
```
Make sure there is a comma after the preceding `creator/:creatorId` entry.
- [ ] **Step 3: Verify build**
Run:
```bash
cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -15
```
Expected: build succeeds.
- [ ] **Step 4: Commit**
```bash
git add ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts \
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts
git commit -m "feat(iot-hub): wire resolver component into routing and module"
```
---
### Task 8: Type-page handoff
`TbIotHubItemsPageComponent` consumes `history.state.openItem` on init: resolves installed state, opens the detail dialog with the `preview` flag, then clears the state entry so refresh does not re-open.
**Files:**
- Modify: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts`
- [ ] **Step 1: Extend imports + constructor**
Open `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts`.
Replace the import block (top of file, currently lines 17-20) with:
```ts
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models';
import { IotHubApiService } from '@core/http/iot-hub-api.service';
import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service';
import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models';
import { PageLink } from '@shared/models/page/page-link';
import { DeepLinkOpenItem } from './iot-hub-deep-link.utils';
```
Replace the constructor with:
```ts
constructor(
private route: ActivatedRoute,
private router: Router,
private iotHubApiService: IotHubApiService,
private iotHubActions: IotHubActionsService
) {}
```
- [ ] **Step 2: Extend `ngOnInit` and add handoff method**
Replace the existing `ngOnInit` body with:
```ts
ngOnInit(): void {
const itemType = this.route.snapshot.data['itemType'] as string;
this.config = PAGE_CONFIGS[itemType];
this.loadInstalledCount();
this.maybeOpenDeepLinkedItem();
}
```
Add these two new methods anywhere in the class (e.g. after `loadInstalledCount`):
```ts
private maybeOpenDeepLinkedItem(): void {
const openItem = history.state?.openItem as DeepLinkOpenItem | undefined;
if (!openItem || openItem.version.type !== this.config.type) {
return;
}
history.replaceState({ ...history.state, openItem: undefined }, '');
this.resolveInstalledItem(openItem.version).subscribe(installed => {
this.iotHubActions.openItemDetail(
openItem.version,
installed ?? undefined,
installed ? 1 : 0,
'default',
true,
openItem.preview
).subscribe();
});
}
private resolveInstalledItem(version: MpItemVersionView): Observable<IotHubInstalledItem | null> {
return this.iotHubApiService
.getInstalledItems(new PageLink(1), undefined, version.itemId, { ignoreLoading: true })
.pipe(map(page => page.data[0] ?? null));
}
```
- [ ] **Step 3: Verify build**
Run:
```bash
cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -15
```
Expected: build succeeds.
- [ ] **Step 4: Commit**
```bash
git add ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts
git commit -m "feat(iot-hub): open deep-linked item from history state on type-page init"
```
---
### Task 9: Lint, build, and manual smoke test
Final verification gate. Runs the lint and production build, then walks each user-facing flow in a dev server to confirm behavior.
**Files:** none (verification only)
- [ ] **Step 1: Lint**
Run:
```bash
cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng lint 2>&1 | tail -30
```
Expected: no new errors introduced by files created/modified in tasks 1–8. Warnings in unrelated files are acceptable.
- [ ] **Step 2: Production build**
Run:
```bash
cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -20
```
Expected: build completes without errors.
- [ ] **Step 3: Start dev server**
Run:
```bash
cd ui-ngx && node --max_old_space_size=8048 ./node_modules/@angular/cli/bin/ng serve --configuration development --host 0.0.0.0
```
Dev server should be reachable at `http://localhost:4200`. Log in as a TENANT_ADMIN user. (A running ThingsBoard backend at the default proxy target is required per `proxy.conf.js`.)
- [ ] **Step 4: Manual smoke test — published deep link**
Pick an itemId from `/iot-hub/widgets` (the card's click handler logs it, or inspect the dialog URL via browser dev tools; alternatively, query `iotHubBaseUrl/api/versions/published` and copy an `itemId`).
Navigate to `http://localhost:4200/iot-hub/{that-itemId}`. Verify:
- URL bar ends at `/iot-hub/widgets` (or the correct type page for the item) after resolution
- Detail dialog opens with the item's info, no "Unpublished preview" badge, and Install/Update actions behave normally
- Closing the dialog leaves the user on the type-page
Navigate to `http://localhost:4200/iot-hub/00000000-0000-0000-0000-000000000000` (a valid-shaped but non-existent UUID). Verify:
- URL bar ends at `/iot-hub`
- Red toast: "This IoT Hub item doesn't exist or was removed."
Navigate to `http://localhost:4200/iot-hub/not-a-uuid`. Verify:
- URL bar ends at `/iot-hub`
- Red toast: "Invalid IoT Hub item link."
- [ ] **Step 5: Manual smoke test — preview deep link (requires IoT Hub `/latest` endpoint)**
**If the IoT Hub-side endpoints are not yet deployed**, skip this step and record that end-to-end preview verification is deferred until the IoT Hub PR lands. The per-URL flow is exercised in Step 4; the preview URL hitting a non-existent endpoint will trigger the "fetch failed" toast, which is the correct fallback behavior.
**If the endpoints are available**, navigate to `http://localhost:4200/iot-hub/{unpublished-itemId}/preview`. Verify:
- Warning dialog opens immediately with the item's name/version, red warning icon, and two buttons
- "Cancel" → closes dialog, lands on `/iot-hub` (home)
- Revisit the URL, click "I understand the risk, continue" → URL advances to `/iot-hub/{typePage}`, detail dialog opens with the red "Unpublished preview" badge in the meta bar
- Install button still works end-to-end (calls the TB install endpoint, which proxies to IoT Hub using the unpublished versionId)
Navigate to `http://localhost:4200/iot-hub/{published-only-itemId}/preview`. Verify:
- No warning shown (preview fell back to the published version)
- Detail dialog opens with **no** preview badge
- Behavior matches the regular published URL
- [ ] **Step 6: Commit the final verification (if any lint/build fixes were needed)**
If the smoke test uncovered issues that required fixes, stage and commit them with a message like `fix(iot-hub): address smoke-test findings for deep-link flow`. Otherwise, this step is a no-op — all previous commits already capture the work.
---
## IoT Hub-side changes required (recap)
One new endpoint plus behavior + CORS contracts on the existing by-versionId family:
1. **New endpoint** `GET /api/items/{itemId}/published` — latest PUBLISHED version as `MpItemVersionView`; `404` if none. Anonymous cross-origin. Powers the `/iot-hub/{itemId}` URL.
2. **`GET /api/versions/{versionId}`** must return the requested version regardless of state (PUBLISHED / DRAFT / PENDING_REVIEW / …). Anonymous cross-origin; versionId UUID is the soft-secret gate. Powers the `/iot-hub/version/{itemVersionId}` URL.
3. **`MpItemVersionView`** must allow the frontend to tell published from unpublished. Either `publishedTime` must be falsy (`0`/`null`) for non-published versions, or add an explicit `state` field. Frontend's `isPublished()` uses `publishedTime > 0` today.
4. **Related by-versionId endpoints** must also serve unpublished versions (required for install-from-deep-link):
- `GET /api/versions/{versionId}/readme`
- `GET /api/versions/{versionId}/fileData`
- `POST /api/versions/{versionId}/install`
5. **Install counter policy** for unpublished versions — recommended: skip counting.
6. **CORS** on `/api/items/{itemId}/published` and the `/api/versions/{versionId}/...` family must permit cross-origin GET from any origin.
These live in the IoT Hub repository, not ThingsBoard CE.

289
docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md

@ -0,0 +1,289 @@
# IoT Hub item deep link — design
**Status:** approved
**Date:** 2026-04-22
**Author:** Andrii Shvaika
**Scope:** ThingsBoard CE frontend + IoT Hub backend contract (external)
## Summary
Allow two shareable deep-link URL shapes:
- `http://<tb-host>/iot-hub/{itemId}` — opens the detail view for the latest published version of the item.
- `http://<tb-host>/iot-hub/version/{itemVersionId}` — opens a specific version snapshot. Published versions open directly; unpublished versions are gated behind a security warning.
The first URL is the canonical "share this marketplace item" link. The second targets creator review workflows — stable snapshots of an exact draft.
## Goals
- Two shareable, bookmarkable deep link shapes with distinct semantics:
- `/iot-hub/{itemId}` — always latest published version of the item (404 if none published).
- `/iot-hub/version/{itemVersionId}` — specific version; warning gate when unpublished, then "Unpublished preview" badge in the detail dialog.
- Zero backend changes in ThingsBoard. Install/update flows reuse the existing versionId-based pipeline.
## Non-goals
- Authenticated access to unpublished content. Authorization is "public-by-link": whoever has the UUID can fetch the content.
- Full standalone page for item detail. The detail view stays as an Angular Material dialog; the deep link navigates to the type-specific browse page (`/iot-hub/widgets`, `/iot-hub/dashboards`, etc.) and opens the dialog over it.
- Install-specific semantics for unpublished versions. Install reuses the normal flow; only the IoT Hub-side install counter policy may differ (see IoT Hub-side changes).
- A "latest including drafts" shorthand shape (`/iot-hub/{itemId}/preview`). If creators want to preview a specific draft, they use the version URL with the exact versionId.
## User flows
### Published link: `/iot-hub/{itemId}`
1. User pastes or clicks `/iot-hub/{itemId}`.
2. Angular mounts `TbIotHubItemResolverComponent`.
3. Resolver calls `iotHubApiService.getPublishedVersion(itemId)` → IoT Hub `GET /api/items/{itemId}/published` (new endpoint).
4. On success, resolver navigates to `/iot-hub/{typeSegment(item.type)}` with router state `{ openItem: { version, preview: false } }` and `replaceUrl: true`. Detail dialog opens with no badge.
5. `TbIotHubItemsPageComponent.ngOnInit` consumes `history.state.openItem`, resolves installed state, and calls `IotHubActionsService.openItemDetail(...)`.
6. On 404 (no published version exists) or other error, resolver shows a toast and redirects to `/iot-hub`.
### Version link: `/iot-hub/version/{itemVersionId}`
1. User pastes or clicks `/iot-hub/version/{itemVersionId}`.
2. Angular mounts `TbIotHubItemResolverComponent`.
3. Resolver calls `iotHubApiService.getVersionInfo(itemVersionId)` → IoT Hub `GET /api/versions/{versionId}` (existing endpoint).
4. If the returned version is published → resolver navigates directly to `/iot-hub/{typeSegment(item.type)}` with router state `{ openItem: { version, preview: false } }` and `replaceUrl: true`. Detail dialog opens with no badge.
5. If the returned version is unpublished → resolver opens `TbIotHubUnpublishedWarningDialogComponent` (`disableClose: true`).
- Cancel → `router.navigate(['/iot-hub'])`.
- "I understand the risk, continue" → navigates to `/iot-hub/{typeSegment(item.type)}` with `{ openItem: { version, preview: true } }`; detail dialog opens with the "Unpublished preview" badge.
6. Install / Update / Remove / Open-entity actions behave as usual — all operate on the versionId already fetched, so install-from-version works end-to-end.
7. On 404 or other error, resolver shows a toast and redirects to `/iot-hub`.
## Angular routing
Two routes added to `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts`, placed after the existing named child routes (`widgets`, `dashboards`, `solution-templates`, `calculated-fields`, `rule-chains`, `devices`, `search`, `installed`, `creator/:creatorId`). The literal `version/:itemVersionId` must come **before** the `:itemId` wildcard so the router matches the literal first:
```ts
{ path: 'version/:itemVersionId', component: TbIotHubItemResolverComponent,
data: { auth: [Authority.TENANT_ADMIN], title: 'iot-hub.item-detail' } },
{ path: ':itemId', component: TbIotHubItemResolverComponent,
data: { auth: [Authority.TENANT_ADMIN], title: 'iot-hub.item-detail' } },
```
The resolver branches on which param is present — `paramMap.get('itemVersionId')` signals the version-URL flow; `paramMap.get('itemId')` signals the published-URL flow. A UUID-shape check runs inside the resolver (not as a `UrlMatcher`) so an invalid id produces a friendly toast instead of a generic not-found page.
## Components
### `TbIotHubItemResolverComponent` (new)
Location: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts`.
- Standalone: `false`. Declared in `IotHubModule`.
- Template: empty (`template: ''`). The component renders nothing; it is a router-reachable controller.
- `ngOnInit`:
1. Read `itemVersionId` and `itemId` from route params. Set `byVersion = itemVersionId != null`; pick the relevant id accordingly.
2. Reject non-UUID id → `iot-hub.deep-link-invalid-id` toast + redirect to `/iot-hub`.
3. Dispatch to `getVersionInfo(id)` (byVersion) or `getPublishedVersion(id)` (published), both with `{ ignoreErrors: true }`.
4. On error, map HTTP status to `iot-hub.deep-link-not-found` (404) or `iot-hub.deep-link-fetch-failed` (other) and redirect.
5. On success, call `handleResolved(version, byVersion)`:
- `byVersion` + version unpublished → open warning dialog; confirm routes to type-page with state (`preview: true`); cancel routes to `/iot-hub`.
- Otherwise → route directly to type-page with state (`preview: false`).
- All navigations use `replaceUrl: true` so the resolver URL does not pollute browser history.
- The published URL (`/iot-hub/{itemId}`) cannot surface unpublished content — `getPublishedVersion` only returns PUBLISHED versions — so the warning branch is unreachable for that flow. The `byVersion` gate in `handleResolved` makes this explicit.
### `iot-hub-deep-link.utils.ts` (new)
Shared helpers:
```ts
export const isUUID = (s: string | null): s is string => !!s && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
export function typeSegment(t: ItemType): string | undefined {
switch (t) {
case ItemType.WIDGET: return 'widgets';
case ItemType.DASHBOARD: return 'dashboards';
case ItemType.SOLUTION_TEMPLATE: return 'solution-templates';
case ItemType.CALCULATED_FIELD: return 'calculated-fields';
case ItemType.RULE_CHAIN: return 'rule-chains';
case ItemType.DEVICE: return 'devices';
default: return undefined;
}
}
export function isPublished(v: MpItemVersionView): boolean {
return !!v.publishedTime && v.publishedTime > 0;
}
```
A `typeSegment` returning `undefined` (future `ItemType` values) surfaces as `iot-hub.deep-link-fetch-failed` in the resolver.
### `TbIotHubUnpublishedWarningDialogComponent` (new)
Location: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.{ts,html,scss}`. Declared in `IotHubComponentsModule`.
- Title: translated `iot-hub.unpublished-warning-title` ("Unpublished content") with a red `warning` Material icon.
- Body: `iot-hub.unpublished-warning-text` paragraph ("This is a preview of unpublished content. It has not been reviewed by IoT Hub. Installing unverified content can introduce security and stability risks — only continue if you trust the creator.").
- Secondary line: `{item.name} • v {item.version}` so the user sees what they are acknowledging.
- Buttons:
- `Cancel` — returns `false`.
- `iot-hub.unpublished-warning-confirm` ("I understand the risk, continue") — returns `true`, styled with the project's danger accent.
- Dialog config: `panelClass: ['tb-dialog']`, `disableClose: true`, `autoFocus: false`.
- `MAT_DIALOG_DATA` payload: `{ item: MpItemVersionView }`.
Structural sibling of `TbIotHubDeleteDialogComponent`.
### `TbIotHubItemDetailDialogComponent` (modified)
- `IotHubItemDetailDialogData` gains optional `preview?: boolean`.
- Component stores `this.preview = data.preview === true` and exposes it to the template.
- Template adds a preview badge next to the existing version chip in the sticky meta bar:
```html
<div class="tb-unpublished-preview-badge" *ngIf="preview">
<mat-icon>warning</mat-icon>
<span>{{ 'iot-hub.unpublished-preview' | translate }}</span>
</div>
```
- No other template or behavior changes. Install / Update / Remove / Open-entity actions are untouched.
- SCSS adds `.tb-unpublished-preview-badge` (red-on-light-red, matching the warning dialog accent).
### `IotHubActionsService` (modified)
```ts
openItemDetail(
item: MpItemVersionView,
installedItem?: IotHubInstalledItem,
installedItemsCount?: number,
mode?: IotHubItemDetailDialogMode,
showCreator?: boolean,
preview?: boolean
): Observable<any>
```
`preview` is forwarded into `IotHubItemDetailDialogData`. All existing callers ignore the new parameter (undefined → non-preview).
### `TbIotHubItemsPageComponent` (modified)
`ngOnInit` is extended to consume `history.state.openItem` exactly once per navigation:
```ts
private maybeOpenDeepLinkedItem(): void {
const openItem = history.state?.openItem as
{ version: MpItemVersionView; preview?: boolean } | undefined;
if (!openItem || openItem.version.type !== this.config.type) return;
history.replaceState({ ...history.state, openItem: undefined }, '');
this.resolveInstalledItem(openItem.version).subscribe(installed => {
this.iotHubActions.openItemDetail(
openItem.version,
installed,
installed ? 1 : 0,
'default',
true,
openItem.preview
).subscribe(result => this.handleDetailResult(result));
});
}
private resolveInstalledItem(v: MpItemVersionView): Observable<IotHubInstalledItem | null> {
return this.iotHubApiService
.getInstalledItems(new PageLink(1), undefined, v.itemId)
.pipe(map(page => page.data[0] ?? null));
}
```
`handleDetailResult` delegates to the same installed-count refresh logic already used by card clicks. The `history.replaceState` call clears `openItem` so a page refresh does not re-open the dialog from stale state.
## API contract
### `IotHubApiService` — one new method
```ts
public getPublishedVersion(itemId: string, config?: IotHubRequestConfig): Observable<MpItemVersionView> {
return this.http.get<MpItemVersionView>(
`${this.baseUrl}/api/items/${itemId}/published`,
{ params: this.buildParams(config) }
);
}
```
The version URL reuses the existing `getVersionInfo(versionId, config)` which calls `GET /api/versions/{versionId}`. Both accept `{ ignoreErrors: true }` so the resolver can handle failures inline rather than surfacing the global interceptor toast.
### ThingsBoard backend
Unchanged. `IotHubController.installVersion` and `IotHubController.updateInstalledItem` already operate on versionIds; both deep-link flows funnel into them without modification.
## i18n
Add to `ui-ngx/src/assets/locale/locale.constant-en_US.json` (and mirror into other locales):
- `iot-hub.item-detail` — "IoT Hub item"
- `iot-hub.unpublished-warning-title` — "Unpublished content"
- `iot-hub.unpublished-warning-text` — "This is a preview of unpublished content. It has not been reviewed by IoT Hub. Installing unverified content can introduce security and stability risks — only continue if you trust the creator."
- `iot-hub.unpublished-warning-confirm` — "I understand the risk, continue"
- `iot-hub.unpublished-preview` — "Unpublished preview"
- `iot-hub.deep-link-invalid-id` — "Invalid IoT Hub item link."
- `iot-hub.deep-link-not-found` — "This IoT Hub item doesn't exist or was removed."
- `iot-hub.deep-link-fetch-failed` — "Couldn't load IoT Hub item. Please try again."
## Edge cases
- **Invalid UUID shape** for either `itemId` or `itemVersionId` → `iot-hub.deep-link-invalid-id` toast + redirect to `/iot-hub`.
- **404 from IoT Hub**:
- Published URL: item has no published version, or item doesn't exist. Toast `iot-hub.deep-link-not-found` + redirect.
- Version URL: version doesn't exist or was removed. Same toast + redirect.
- **Network / 5xx error** → `iot-hub.deep-link-fetch-failed` toast + redirect.
- **Version URL resolves to a published version** → no warning, no badge. Serves as a stable snapshot link to that version.
- **Unsupported `ItemType`** (future value not in `typeSegment`) → treated as `iot-hub.deep-link-fetch-failed`.
- **User hits Browser Back from the warning dialog** → dialog destroys with the resolver component; no zombie dialog.
- **User lacks `TENANT_ADMIN`** → the `/iot-hub` parent route guard blocks; no additional guard needed.
- **Deep link for an already-installed item** → detail dialog shows its usual "Installed / Update / Open entity" actions against the resolved versionId. Both URL shapes produce identical behavior once the dialog is open.
- **Refresh after deep link has been resolved** → URL is now `/iot-hub/{typePage}`; `history.state.openItem` is cleared; user sees the type-page with no dialog (expected).
## Testing
- `TbIotHubItemResolverComponent` unit tests with mocked `IotHubApiService` and `Router`:
- published URL happy path (no warning, no badge)
- version URL happy path, version is published (no warning, no badge)
- version URL happy path, version is unpublished (warning → confirm → dialog with badge)
- version URL, warning cancel → redirect to `/iot-hub`
- invalid UUID (either param)
- 404 (both URL shapes)
- 5xx / network error
- unsupported `ItemType`
- `isPublished()` and `typeSegment()` unit tests.
- `TbIotHubUnpublishedWarningDialogComponent` component tests: renders item name/version; Cancel returns `false`; Confirm returns `true`.
- `TbIotHubItemsPageComponent.maybeOpenDeepLinkedItem` integration test with seeded `history.state`: asserts dialog opens, state is cleared, type mismatch is ignored.
- No ThingsBoard backend tests (no backend changes).
## Files touched
New:
- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts`
- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts`
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts`
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html`
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss`
Modified:
- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts`
- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts`
- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts`
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts`
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts`
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html`
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss`
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts`
- `ui-ngx/src/app/core/http/iot-hub-api.service.ts`
- `ui-ngx/src/assets/locale/locale.constant-*.json`
## IoT Hub-side changes required
These live in the IoT Hub repository, not ThingsBoard CE. One new endpoint plus behavior/CORS contracts on the existing by-versionId family.
1. **New endpoint** `GET /api/items/{itemId}/published`
- Returns `MpItemVersionView` for the latest version of the item that is in the PUBLISHED state.
- `404` when the item has no published version or doesn't exist.
- Anonymous cross-origin access (same CORS policy as `/api/versions/published`).
- Powers the `/iot-hub/{itemId}` deep link.
2. **Behavior contract on existing `GET /api/versions/{versionId}`**: must return the requested version regardless of its state (PUBLISHED, DRAFT, PENDING_REVIEW, …). This powers the `/iot-hub/version/{itemVersionId}` deep link. Anonymous cross-origin; the versionId UUID itself is the soft-secret gate.
3. **`MpItemVersionView` response for unpublished versions** must allow the frontend to tell published from unpublished. Either `publishedTime` must be falsy (`null` / `0`) for non-published versions, or an explicit `state` field must be added. Pick one; the frontend uses `isPublished(v)` based on `publishedTime` today.
4. **Related by-versionId endpoints must also serve unpublished versions** (required by the install flow proxied through TB):
- `GET /api/versions/{versionId}/readme`
- `GET /api/versions/{versionId}/fileData`
- `POST /api/versions/{versionId}/install`
5. **Install counter policy**: decide whether `POST /api/versions/{versionId}/install` against an unpublished version increments counters. Recommended: skip, to avoid inflating published install metrics with creator self-tests.
6. **CORS**: ensure `/api/items/{itemId}/published` and the full `/api/versions/{versionId}/...` family permit cross-origin GET from any origin.

7
ui-ngx/src/app/core/http/iot-hub-api.service.ts

@ -114,6 +114,13 @@ export class IotHubApiService {
);
}
public getPublishedVersion(itemId: string, config?: IotHubRequestConfig): Observable<MpItemVersionView> {
return this.http.get<MpItemVersionView>(
`${this.baseUrl}/api/items/${itemId}/published`,
{ params: this.buildParams(config) }
);
}
public getVersionReadme(versionId: string, config?: IotHubRequestConfig): Observable<string> {
return this.http.get(`${this.baseUrl}/api/versions/${versionId}/readme`, {
params: this.buildParams(config),

4
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts

@ -40,12 +40,12 @@ export class IotHubActionsService {
) {}
openItemDetail(item: MpItemVersionView, installedItem?: IotHubInstalledItem, installedItemsCount?: number,
mode?: IotHubItemDetailDialogMode, showCreator?: boolean): Observable<any> {
mode?: IotHubItemDetailDialogMode, showCreator?: boolean, preview?: boolean): Observable<any> {
return this.dialog.open(TbIotHubItemDetailDialogComponent, {
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
disableClose: true,
autoFocus: false,
data: { item, installedItem, installedItemsCount, mode, showCreator } as IotHubItemDetailDialogData
data: { item, installedItem, installedItemsCount, mode, showCreator, preview } as IotHubItemDetailDialogData
}).afterClosed();
}

3
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts

@ -24,6 +24,7 @@ import { TbIotHubAddItemDialogComponent } from './iot-hub-add-item-dialog.compon
import { TbIotHubInstallDialogComponent } from './iot-hub-install-dialog.component';
import { TbIotHubUpdateDialogComponent } from './iot-hub-update-dialog.component';
import { TbIotHubDeleteDialogComponent } from './iot-hub-delete-dialog.component';
import { TbIotHubUnpublishedWarningDialogComponent } from './iot-hub-unpublished-warning-dialog.component';
import { TbDeviceInstallDialogComponent } from './device-install-dialog/device-install-dialog.component';
import { TbIotHubSearchComponent } from './iot-hub-search.component';
import { TbIotHubInstalledItemsTableComponent } from './iot-hub-installed-items-table.component';
@ -39,6 +40,7 @@ import { IotHubActionsService } from './iot-hub-actions.service';
TbIotHubInstallDialogComponent,
TbIotHubUpdateDialogComponent,
TbIotHubDeleteDialogComponent,
TbIotHubUnpublishedWarningDialogComponent,
TbDeviceInstallDialogComponent,
TbIotHubSearchComponent,
TbIotHubInstalledItemsTableComponent,
@ -59,6 +61,7 @@ import { IotHubActionsService } from './iot-hub-actions.service';
TbIotHubInstallDialogComponent,
TbIotHubUpdateDialogComponent,
TbIotHubDeleteDialogComponent,
TbIotHubUnpublishedWarningDialogComponent,
TbDeviceInstallDialogComponent,
TbIotHubSearchComponent,
TbIotHubInstalledItemsTableComponent,

7
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html

@ -40,6 +40,13 @@
<mat-icon class="dlg-subtitle-icon">update</mat-icon>
<span>v {{ item.version }}</span>
</span>
@if (preview) {
<span class="dlg-dot"></span>
<span class="dlg-subtitle-group tb-unpublished-preview-badge">
<mat-icon class="dlg-subtitle-icon">warning</mat-icon>
<span>{{ 'iot-hub.unpublished-preview' | translate }}</span>
</span>
}
@if (item.publishedTime) {
<span class="dlg-dot"></span>
<span class="dlg-subtitle-group">

9
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss

@ -767,3 +767,12 @@
padding-right: 48px;
}
}
.dlg-subtitle-group.tb-unpublished-preview-badge {
color: #d32f2f;
font-weight: 600;
.dlg-subtitle-icon {
color: #d32f2f;
}
}

3
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts

@ -39,6 +39,7 @@ export interface IotHubItemDetailDialogData {
installedItemsCount?: number;
mode?: IotHubItemDetailDialogMode;
showCreator?: boolean;
preview?: boolean;
}
@Component({
@ -53,6 +54,7 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
item: MpItemVersionView;
mode: IotHubItemDetailDialogMode;
showCreator: boolean;
preview: boolean;
typeTranslations = itemTypeTranslations;
readmeContent: string = '';
installedItem?: IotHubInstalledItem;
@ -74,6 +76,7 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
this.item = data.item;
this.mode = data.mode || 'default';
this.showCreator = data.showCreator !== false;
this.preview = data.preview === true;
this.installedItem = data.installedItem;
this.installedItemsCount = data.installedItemsCount || 0;
this.buildCarouselImages();

32
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html

@ -0,0 +1,32 @@
<!--
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-warning-content">
<div class="tb-iot-hub-warning-title-row">
<mat-icon class="tb-iot-hub-warning-icon">warning</mat-icon>
<h2 class="tb-iot-hub-warning-title">{{ 'iot-hub.unpublished-warning-title' | translate }}</h2>
</div>
<p class="tb-iot-hub-warning-text">{{ 'iot-hub.unpublished-warning-text' | translate }}</p>
<div class="tb-iot-hub-warning-item">
<span class="tb-iot-hub-warning-item-name">{{ data.item.name }}</span>
<span class="tb-iot-hub-warning-item-version">v {{ data.item.version }}</span>
</div>
</div>
<div class="tb-iot-hub-warning-actions">
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="warn" (click)="confirm()">{{ 'iot-hub.unpublished-warning-confirm' | translate }}</button>
</div>

82
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss

@ -0,0 +1,82 @@
/**
* 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.
*/
.tb-iot-hub-warning-content {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
max-width: 480px;
}
.tb-iot-hub-warning-title-row {
display: flex;
align-items: center;
gap: 12px;
}
.tb-iot-hub-warning-icon {
color: #d32f2f;
font-size: 28px;
width: 28px;
height: 28px;
}
.tb-iot-hub-warning-title {
font-size: 20px;
font-weight: 600;
line-height: 24px;
letter-spacing: 0.1px;
color: rgba(0, 0, 0, 0.87);
margin: 0;
}
.tb-iot-hub-warning-text {
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: 0.2px;
color: rgba(0, 0, 0, 0.75);
margin: 0;
}
.tb-iot-hub-warning-item {
display: flex;
align-items: baseline;
gap: 8px;
padding: 12px 16px;
background: rgba(211, 47, 47, 0.08);
border-left: 3px solid #d32f2f;
border-radius: 2px;
.tb-iot-hub-warning-item-name {
font-weight: 600;
color: rgba(0, 0, 0, 0.87);
}
.tb-iot-hub-warning-item-version {
font-size: 13px;
color: rgba(0, 0, 0, 0.54);
}
}
.tb-iot-hub-warning-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 8px;
}

53
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts

@ -0,0 +1,53 @@
///
/// 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 IotHubUnpublishedWarningDialogData {
item: MpItemVersionView;
}
@Component({
selector: 'tb-iot-hub-unpublished-warning-dialog',
standalone: false,
templateUrl: './iot-hub-unpublished-warning-dialog.component.html',
styleUrls: ['./iot-hub-unpublished-warning-dialog.component.scss']
})
export class TbIotHubUnpublishedWarningDialogComponent extends DialogComponent<TbIotHubUnpublishedWarningDialogComponent, boolean> {
constructor(
protected store: Store<AppState>,
protected router: Router,
protected dialogRef: MatDialogRef<TbIotHubUnpublishedWarningDialogComponent, boolean>,
@Inject(MAT_DIALOG_DATA) public data: IotHubUnpublishedWarningDialogData
) {
super(store, router, dialogRef);
}
confirm(): void {
this.dialogRef.close(true);
}
cancel(): void {
this.dialogRef.close(false);
}
}

45
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts

@ -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 { ItemType } from '@shared/models/iot-hub/iot-hub-item.models';
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function isUUID(s: string | null | undefined): s is string {
return !!s && UUID_RE.test(s);
}
export function typeSegment(t: ItemType): string | undefined {
switch (t) {
case ItemType.WIDGET: return 'widgets';
case ItemType.DASHBOARD: return 'dashboards';
case ItemType.SOLUTION_TEMPLATE: return 'solution-templates';
case ItemType.CALCULATED_FIELD: return 'calculated-fields';
case ItemType.RULE_CHAIN: return 'rule-chains';
case ItemType.DEVICE: return 'devices';
default: return undefined;
}
}
export function isPublished(v: MpItemVersionView): boolean {
return !!v.publishedTime && v.publishedTime > 0;
}
export interface DeepLinkOpenItem {
version: MpItemVersionView;
preview: boolean;
}

127
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts

@ -0,0 +1,127 @@
///
/// 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';
import { MatDialog } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { AppState } from '@core/core.state';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import { IotHubApiService } from '@core/http/iot-hub-api.service';
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models';
import {
DeepLinkOpenItem,
isPublished,
isUUID,
typeSegment
} from './iot-hub-deep-link.utils';
import {
IotHubUnpublishedWarningDialogData,
TbIotHubUnpublishedWarningDialogComponent
} from '@home/components/iot-hub/iot-hub-unpublished-warning-dialog.component';
@Component({
selector: 'tb-iot-hub-item-resolver',
standalone: false,
template: ''
})
export class TbIotHubItemResolverComponent implements OnInit {
constructor(
private route: ActivatedRoute,
private router: Router,
private dialog: MatDialog,
private store: Store<AppState>,
private translate: TranslateService,
private iotHubApi: IotHubApiService
) {}
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
const itemVersionId = params.get('itemVersionId');
const itemId = params.get('itemId');
const byVersion = itemVersionId != null;
const id = byVersion ? itemVersionId : itemId;
if (!isUUID(id)) {
this.failTo('iot-hub.deep-link-invalid-id');
return;
}
const fetch$ = byVersion
? this.iotHubApi.getVersionInfo(id, { ignoreErrors: true })
: this.iotHubApi.getPublishedVersion(id, { ignoreErrors: true });
fetch$.subscribe({
next: v => this.handleResolved(v, byVersion),
error: err => {
const key = err?.status === 404
? 'iot-hub.deep-link-not-found'
: 'iot-hub.deep-link-fetch-failed';
this.failTo(key);
}
});
}
private handleResolved(version: MpItemVersionView, byVersion: boolean): void {
const segment = typeSegment(version.type);
if (!segment) {
this.failTo('iot-hub.deep-link-fetch-failed');
return;
}
const unpublished = byVersion && !isPublished(version);
if (unpublished) {
this.dialog.open<
TbIotHubUnpublishedWarningDialogComponent,
IotHubUnpublishedWarningDialogData,
boolean
>(TbIotHubUnpublishedWarningDialogComponent, {
panelClass: ['tb-dialog'],
disableClose: true,
autoFocus: false,
data: { item: version }
}).afterClosed().subscribe(confirmed => {
if (confirmed) {
this.openOnTypePage(version, segment, true);
} else {
this.router.navigate(['/iot-hub'], { replaceUrl: true });
}
});
} else {
this.openOnTypePage(version, segment, false);
}
}
private openOnTypePage(version: MpItemVersionView, segment: string, preview: boolean): void {
const openItem: DeepLinkOpenItem = { version, preview };
this.router.navigate(['/iot-hub', segment], {
state: { openItem },
replaceUrl: true
});
}
private failTo(messageKey: string): void {
this.store.dispatch(new ActionNotificationShow({
message: this.translate.instant(messageKey),
type: 'error',
duration: 5000
}));
this.router.navigate(['/iot-hub'], { replaceUrl: true });
}
}

35
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts

@ -16,8 +16,15 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models';
import { IotHubApiService } from '@core/http/iot-hub-api.service';
import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service';
import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models';
import { PageLink } from '@shared/models/page/page-link';
import { DeepLinkOpenItem } from './iot-hub-deep-link.utils';
interface ItemTypePageConfig {
type: ItemType;
@ -86,13 +93,15 @@ export class TbIotHubItemsPageComponent implements OnInit {
constructor(
private route: ActivatedRoute,
private router: Router,
private iotHubApiService: IotHubApiService
private iotHubApiService: IotHubApiService,
private iotHubActions: IotHubActionsService
) {}
ngOnInit(): void {
const itemType = this.route.snapshot.data['itemType'] as string;
this.config = PAGE_CONFIGS[itemType];
this.loadInstalledCount();
this.maybeOpenDeepLinkedItem();
}
goBack(): void {
@ -112,4 +121,28 @@ export class TbIotHubItemsPageComponent implements OnInit {
this.installedItemsCount = count;
});
}
private maybeOpenDeepLinkedItem(): void {
const openItem = history.state?.openItem as DeepLinkOpenItem | undefined;
if (!openItem || openItem.version.type !== this.config.type) {
return;
}
history.replaceState({ ...history.state, openItem: undefined }, '');
this.resolveInstalledItem(openItem.version).subscribe(installed => {
this.iotHubActions.openItemDetail(
openItem.version,
installed ?? undefined,
installed ? 1 : 0,
'default',
true,
openItem.preview
).subscribe();
});
}
private resolveInstalledItem(version: MpItemVersionView): Observable<IotHubInstalledItem | null> {
return this.iotHubApiService
.getInstalledItems(new PageLink(1), undefined, version.itemId, { ignoreLoading: true })
.pipe(map(page => page.data[0] ?? null));
}
}

17
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts

@ -23,6 +23,7 @@ import { TbIotHubItemsPageComponent } from './iot-hub-items-page.component';
import { TbIotHubCreatorProfileComponent } from './iot-hub-creator-profile.component';
import { TbIotHubInstalledItemsComponent } from './iot-hub-installed-items.component';
import { TbIotHubSearchPageComponent } from './iot-hub-search-page.component';
import { TbIotHubItemResolverComponent } from './iot-hub-item-resolver.component';
const routes: Routes = [
{
@ -138,6 +139,22 @@ const routes: Routes = [
icon: 'person'
}
}
},
{
path: 'version/:itemVersionId',
component: TbIotHubItemResolverComponent,
data: {
auth: [Authority.TENANT_ADMIN],
title: 'iot-hub.item-detail'
}
},
{
path: ':itemId',
component: TbIotHubItemResolverComponent,
data: {
auth: [Authority.TENANT_ADMIN],
title: 'iot-hub.item-detail'
}
}
]
}

4
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts

@ -24,6 +24,7 @@ import { TbIotHubItemsPageComponent } from './iot-hub-items-page.component';
import { TbIotHubCreatorProfileComponent } from './iot-hub-creator-profile.component';
import { TbIotHubInstalledItemsComponent } from './iot-hub-installed-items.component';
import { TbIotHubSearchPageComponent } from './iot-hub-search-page.component';
import { TbIotHubItemResolverComponent } from './iot-hub-item-resolver.component';
@NgModule({
declarations: [
@ -31,7 +32,8 @@ import { TbIotHubSearchPageComponent } from './iot-hub-search-page.component';
TbIotHubItemsPageComponent,
TbIotHubCreatorProfileComponent,
TbIotHubInstalledItemsComponent,
TbIotHubSearchPageComponent
TbIotHubSearchPageComponent,
TbIotHubItemResolverComponent
],
imports: [
CommonModule,

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

@ -3880,7 +3880,15 @@
"device-install-title": "Connect \"{{name}}\"",
"device-installing-title": "Installing \"{{name}}\"",
"done": "Done",
"in-progress": "In progress"
"in-progress": "In progress",
"item-detail": "IoT Hub item",
"unpublished-warning-title": "Unpublished content",
"unpublished-warning-text": "This is a preview of unpublished content. It has not been reviewed by IoT Hub. Installing unverified content can introduce security and stability risks — only continue if you trust the creator.",
"unpublished-warning-confirm": "I understand the risk, continue",
"unpublished-preview": "Unpublished preview",
"deep-link-invalid-id": "Invalid IoT Hub item link.",
"deep-link-not-found": "This IoT Hub item doesn't exist or was removed.",
"deep-link-fetch-failed": "Couldn't load IoT Hub item. Please try again."
},
"item-data": {
"widget-type-timeseries": "Timeseries",

Loading…
Cancel
Save