17 changed files with 1661 additions and 5 deletions
@ -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. |
|||
@ -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. |
|||
@ -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> |
|||
@ -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; |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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 }); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue