Browse Source
Nine bite-sized tasks (i18n, utils, API methods, warning dialog, detail dialog badge, resolver component, routing, type-page handoff, smoke test) each verified by build + manual walkthrough. No frontend unit tests because ui-ngx has no test runner configured.pull/15539/head
1 changed files with 936 additions and 0 deletions
@ -0,0 +1,936 @@ |
|||
# 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}` (published) and `/iot-hub/{itemId}/preview` (unpublished-with-warning) deep links that resolve an item, navigate to its 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) |
|||
|
|||
From the design doc, the following IoT Hub (external) backend work is needed to ship the preview flow end-to-end: |
|||
|
|||
1. **New endpoint** `GET /api/items/{itemId}/published` — latest PUBLISHED version as `MpItemVersionView`, 404 if none. Anonymous cross-origin. |
|||
2. **New endpoint** `GET /api/items/{itemId}/latest` — latest version regardless of state, preferring non-published and falling back to published. Anonymous cross-origin. |
|||
3. **`MpItemVersionView`**: for unpublished versions, either keep `publishedTime` falsy (`0`/`null`) or add an explicit `state` field. The frontend's `isPublished()` uses `publishedTime > 0` today. |
|||
4. **By-versionId endpoints** (`GET /api/versions/{versionId}`, `/readme`, `/fileData`, `POST /install`) must serve unpublished versions when queried by ID. |
|||
5. **Install counter policy** for unpublished versions — recommended: skip counting. |
|||
6. **CORS** on the two new endpoints must permit cross-origin GET from any origin. |
|||
|
|||
These live in the IoT Hub repository, not ThingsBoard CE. |
|||
Loading…
Reference in new issue