diff --git a/docs/superpowers/plans/2026-04-22-iot-hub-item-deep-link.md b/docs/superpowers/plans/2026-04-22-iot-hub-item-deep-link.md new file mode 100644 index 0000000000..63eba437bb --- /dev/null +++ b/docs/superpowers/plans/2026-04-22-iot-hub-item-deep-link.md @@ -0,0 +1,939 @@ +# IoT Hub item deep link — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `/iot-hub/{itemId}` (latest published version of an item) and `/iot-hub/version/{itemVersionId}` (specific version snapshot, warning gate if unpublished) deep links that resolve a version, navigate to the type-specific browse page, and open the existing detail dialog. + +**Architecture:** A router-reachable `TbIotHubItemResolverComponent` owns resolution: fetch by itemId, (optionally) gate on a blocking warning dialog, then `router.navigate` to `/iot-hub/{typeSegment(type)}` carrying the version in `history.state`. The target type-page consumes the state once and opens the existing `TbIotHubItemDetailDialogComponent` via `IotHubActionsService`, with a new `preview` flag that adds an "Unpublished preview" badge. Zero ThingsBoard backend changes — install flows reuse existing versionId endpoints. + +**Tech Stack:** Angular 20, Angular Material dialogs, NgRx (for toast dispatch), RxJS. TypeScript strict mode. + +**Note on testing:** `ui-ngx` has no frontend test runner wired up (no `npm test` script, no karma/jest config). Each task is verified by `npm run lint` + `npm run build:prod` passing and, for flow-level tasks, a manual dev-server smoke test documented in the final task. + +**Prerequisite (external):** The two new IoT Hub endpoints (`GET /api/items/{itemId}/published` and `GET /api/items/{itemId}/latest`) must be deployed on the target IoT Hub instance for end-to-end testing. Until they land, local verification can be done against a mocked IoT Hub or against the published endpoint only. See the spec (`docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md`) for the full IoT Hub-side contract. + +--- + +## File structure + +**Create:** +- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts` — UUID check, type→route-segment mapping, `isPublished` predicate +- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts` — route-reachable controller +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts` — warning dialog logic +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html` — warning dialog template +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss` — warning dialog styles + +**Modify:** +- `ui-ngx/src/app/core/http/iot-hub-api.service.ts` — add `getPublishedVersion`, `getLatestVersion` +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts` — add `preview` field on data + component +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html` — preview badge markup +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss` — preview badge styles +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts` — propagate `preview` in `openItemDetail` +- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` — declare + export warning dialog +- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts` — two new child routes (placed last) +- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts` — declare resolver component +- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts` — `maybeOpenDeepLinkedItem` handoff +- `ui-ngx/src/assets/locale/locale.constant-en_US.json` — 9 new i18n keys under the existing `"iot-hub"` block + +--- + +### Task 1: Add i18n keys + +Add all new English strings up front so later tasks can reference them freely. Other locales follow the existing project convention (en_US only for new iot-hub keys; translators backfill later). + +**Files:** +- Modify: `ui-ngx/src/assets/locale/locale.constant-en_US.json` + +- [ ] **Step 1: Add keys inside the existing `"iot-hub"` block** + +Open `ui-ngx/src/assets/locale/locale.constant-en_US.json`, find the `"iot-hub"` block (starts at the line matching `"iot-hub": {`), and append the following nine keys to it (place them immediately before the closing `}` of the `"iot-hub"` object, keeping JSON valid — i.e. add a trailing comma to the previous key): + +```json + "item-detail": "IoT Hub item", + "item-preview": "IoT Hub item preview", + "unpublished-warning-title": "Unpublished content", + "unpublished-warning-text": "This is a preview of unpublished content. It has not been reviewed by IoT Hub. Installing unverified content can introduce security and stability risks — only continue if you trust the creator.", + "unpublished-warning-confirm": "I understand the risk, continue", + "unpublished-preview": "Unpublished preview", + "deep-link-invalid-id": "Invalid IoT Hub item link.", + "deep-link-not-found": "This IoT Hub item doesn't exist or was removed.", + "deep-link-fetch-failed": "Couldn't load IoT Hub item. Please try again." +``` + +- [ ] **Step 2: Validate JSON** + +Run: +```bash +cd ui-ngx && node -e "JSON.parse(require('fs').readFileSync('src/assets/locale/locale.constant-en_US.json', 'utf8')); console.log('OK')" +``` +Expected output: `OK`. + +- [ ] **Step 3: Commit** + +```bash +git add ui-ngx/src/assets/locale/locale.constant-en_US.json +git commit -m "feat(iot-hub): add i18n keys for item deep-link and unpublished warning" +``` + +--- + +### Task 2: Deep-link utility module + +A tiny, pure-TS helper file. No Angular deps. Exported functions are used by the resolver and (for `isPublished`) by the detail dialog. + +**Files:** +- Create: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts` + +- [ ] **Step 1: Create the file** + +Create `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts` with: + +```ts +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function isUUID(s: string | null | undefined): s is string { + return !!s && UUID_RE.test(s); +} + +export function typeSegment(t: ItemType): string | undefined { + switch (t) { + case ItemType.WIDGET: return 'widgets'; + case ItemType.DASHBOARD: return 'dashboards'; + case ItemType.SOLUTION_TEMPLATE: return 'solution-templates'; + case ItemType.CALCULATED_FIELD: return 'calculated-fields'; + case ItemType.RULE_CHAIN: return 'rule-chains'; + case ItemType.DEVICE: return 'devices'; + default: return undefined; + } +} + +export function isPublished(v: MpItemVersionView): boolean { + return !!v.publishedTime && v.publishedTime > 0; +} + +export interface DeepLinkOpenItem { + version: MpItemVersionView; + preview: boolean; +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: +```bash +cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -20 +``` +Expected: build succeeds. If `ng build` is too slow for iteration, use `npx tsc --noEmit -p tsconfig.json 2>&1 | grep iot-hub-deep-link || echo 'no type errors in utils file'`. + +- [ ] **Step 3: Commit** + +```bash +git add ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts +git commit -m "feat(iot-hub): add deep-link utility helpers (isUUID, typeSegment, isPublished)" +``` + +--- + +### Task 3: IoT Hub API service — two new methods + +Add `getPublishedVersion` and `getLatestVersion` to `IotHubApiService`. Both hit IoT Hub cross-origin endpoints (same `baseUrl` as existing `/api/versions/published`). + +**Files:** +- Modify: `ui-ngx/src/app/core/http/iot-hub-api.service.ts` + +- [ ] **Step 1: Add the two methods** + +Open `ui-ngx/src/app/core/http/iot-hub-api.service.ts`. Insert the following two methods immediately after `getVersionInfo` (which ends near line 100): + +```ts + public getPublishedVersion(itemId: string, config?: IotHubRequestConfig): Observable { + return this.http.get( + `${this.baseUrl}/api/items/${itemId}/published`, + { params: this.buildParams(config) } + ); + } + + public getLatestVersion(itemId: string, config?: IotHubRequestConfig): Observable { + return this.http.get( + `${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 {` (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 { + + constructor( + protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @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 + +
+
+ warning +

{{ 'iot-hub.unpublished-warning-title' | translate }}

+
+

{{ 'iot-hub.unpublished-warning-text' | translate }}

+
+ {{ data.item.name }} + v {{ data.item.version }} +
+
+
+ + +
+``` + +- [ ] **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 `` that contains the version icon (around line 39, the `update` mat-icon). Immediately **after** the closing `` of that version group (just before the `@if (item.publishedTime)` block), insert: + +```html + @if (preview) { + + + warning + {{ 'iot-hub.unpublished-preview' | translate }} + + } +``` + +- [ ] **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 { + 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, + 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 { + 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. diff --git a/docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md b/docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md new file mode 100644 index 0000000000..fda5f3c451 --- /dev/null +++ b/docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md @@ -0,0 +1,289 @@ +# IoT Hub item deep link — design + +**Status:** approved +**Date:** 2026-04-22 +**Author:** Andrii Shvaika +**Scope:** ThingsBoard CE frontend + IoT Hub backend contract (external) + +## Summary + +Allow two shareable deep-link URL shapes: +- `http:///iot-hub/{itemId}` — opens the detail view for the latest published version of the item. +- `http:///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 +
+ warning + {{ 'iot-hub.unpublished-preview' | translate }} +
+ ``` + +- 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 +``` + +`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 { + 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 { + return this.http.get( + `${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. diff --git a/ui-ngx/src/app/core/http/iot-hub-api.service.ts b/ui-ngx/src/app/core/http/iot-hub-api.service.ts index 15be07599a..12878ac0fc 100644 --- a/ui-ngx/src/app/core/http/iot-hub-api.service.ts +++ b/ui-ngx/src/app/core/http/iot-hub-api.service.ts @@ -114,6 +114,13 @@ export class IotHubApiService { ); } + public getPublishedVersion(itemId: string, config?: IotHubRequestConfig): Observable { + return this.http.get( + `${this.baseUrl}/api/items/${itemId}/published`, + { params: this.buildParams(config) } + ); + } + public getVersionReadme(versionId: string, config?: IotHubRequestConfig): Observable { return this.http.get(`${this.baseUrl}/api/versions/${versionId}/readme`, { params: this.buildParams(config), diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts index 54fdc751d9..ee32245eb1 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts @@ -40,12 +40,12 @@ export class IotHubActionsService { ) {} openItemDetail(item: MpItemVersionView, installedItem?: IotHubInstalledItem, installedItemsCount?: number, - mode?: IotHubItemDetailDialogMode, showCreator?: boolean): Observable { + mode?: IotHubItemDetailDialogMode, showCreator?: boolean, preview?: boolean): Observable { return this.dialog.open(TbIotHubItemDetailDialogComponent, { panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], disableClose: true, autoFocus: false, - data: { item, installedItem, installedItemsCount, mode, showCreator } as IotHubItemDetailDialogData + data: { item, installedItem, installedItemsCount, mode, showCreator, preview } as IotHubItemDetailDialogData }).afterClosed(); } diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts index 64a90d5d24..44bc1ad4c8 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts @@ -24,6 +24,7 @@ import { TbIotHubAddItemDialogComponent } from './iot-hub-add-item-dialog.compon import { TbIotHubInstallDialogComponent } from './iot-hub-install-dialog.component'; import { TbIotHubUpdateDialogComponent } from './iot-hub-update-dialog.component'; import { TbIotHubDeleteDialogComponent } from './iot-hub-delete-dialog.component'; +import { TbIotHubUnpublishedWarningDialogComponent } from './iot-hub-unpublished-warning-dialog.component'; import { TbDeviceInstallDialogComponent } from './device-install-dialog/device-install-dialog.component'; import { TbIotHubSearchComponent } from './iot-hub-search.component'; import { TbIotHubInstalledItemsTableComponent } from './iot-hub-installed-items-table.component'; @@ -39,6 +40,7 @@ import { IotHubActionsService } from './iot-hub-actions.service'; TbIotHubInstallDialogComponent, TbIotHubUpdateDialogComponent, TbIotHubDeleteDialogComponent, + TbIotHubUnpublishedWarningDialogComponent, TbDeviceInstallDialogComponent, TbIotHubSearchComponent, TbIotHubInstalledItemsTableComponent, @@ -59,6 +61,7 @@ import { IotHubActionsService } from './iot-hub-actions.service'; TbIotHubInstallDialogComponent, TbIotHubUpdateDialogComponent, TbIotHubDeleteDialogComponent, + TbIotHubUnpublishedWarningDialogComponent, TbDeviceInstallDialogComponent, TbIotHubSearchComponent, TbIotHubInstalledItemsTableComponent, diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html index 054761448c..a8c943ffa1 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html @@ -40,6 +40,13 @@ update v {{ item.version }} + @if (preview) { + + + warning + {{ 'iot-hub.unpublished-preview' | translate }} + + } @if (item.publishedTime) { diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss index eb6d5ab3c4..c0f20fb85e 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss @@ -767,3 +767,12 @@ padding-right: 48px; } } + +.dlg-subtitle-group.tb-unpublished-preview-badge { + color: #d32f2f; + font-weight: 600; + + .dlg-subtitle-icon { + color: #d32f2f; + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts index d2d54727a8..63962c97c6 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts @@ -39,6 +39,7 @@ export interface IotHubItemDetailDialogData { installedItemsCount?: number; mode?: IotHubItemDetailDialogMode; showCreator?: boolean; + preview?: boolean; } @Component({ @@ -53,6 +54,7 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent +
+
+ warning +

{{ 'iot-hub.unpublished-warning-title' | translate }}

+
+

{{ 'iot-hub.unpublished-warning-text' | translate }}

+
+ {{ data.item.name }} + v {{ data.item.version }} +
+
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss new file mode 100644 index 0000000000..184924cbcc --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss @@ -0,0 +1,82 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.tb-iot-hub-warning-content { + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px; + max-width: 480px; +} + +.tb-iot-hub-warning-title-row { + display: flex; + align-items: center; + gap: 12px; +} + +.tb-iot-hub-warning-icon { + color: #d32f2f; + font-size: 28px; + width: 28px; + height: 28px; +} + +.tb-iot-hub-warning-title { + font-size: 20px; + font-weight: 600; + line-height: 24px; + letter-spacing: 0.1px; + color: rgba(0, 0, 0, 0.87); + margin: 0; +} + +.tb-iot-hub-warning-text { + font-size: 14px; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.75); + margin: 0; +} + +.tb-iot-hub-warning-item { + display: flex; + align-items: baseline; + gap: 8px; + padding: 12px 16px; + background: rgba(211, 47, 47, 0.08); + border-left: 3px solid #d32f2f; + border-radius: 2px; + + .tb-iot-hub-warning-item-name { + font-weight: 600; + color: rgba(0, 0, 0, 0.87); + } + + .tb-iot-hub-warning-item-version { + font-size: 13px; + color: rgba(0, 0, 0, 0.54); + } +} + +.tb-iot-hub-warning-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 8px; +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts new file mode 100644 index 0000000000..3d55dc598a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts @@ -0,0 +1,53 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; + +export interface IotHubUnpublishedWarningDialogData { + item: MpItemVersionView; +} + +@Component({ + selector: 'tb-iot-hub-unpublished-warning-dialog', + standalone: false, + templateUrl: './iot-hub-unpublished-warning-dialog.component.html', + styleUrls: ['./iot-hub-unpublished-warning-dialog.component.scss'] +}) +export class TbIotHubUnpublishedWarningDialogComponent extends DialogComponent { + + constructor( + protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: IotHubUnpublishedWarningDialogData + ) { + super(store, router, dialogRef); + } + + confirm(): void { + this.dialogRef.close(true); + } + + cancel(): void { + this.dialogRef.close(false); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts new file mode 100644 index 0000000000..fcf9f9bb2b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts @@ -0,0 +1,45 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function isUUID(s: string | null | undefined): s is string { + return !!s && UUID_RE.test(s); +} + +export function typeSegment(t: ItemType): string | undefined { + switch (t) { + case ItemType.WIDGET: return 'widgets'; + case ItemType.DASHBOARD: return 'dashboards'; + case ItemType.SOLUTION_TEMPLATE: return 'solution-templates'; + case ItemType.CALCULATED_FIELD: return 'calculated-fields'; + case ItemType.RULE_CHAIN: return 'rule-chains'; + case ItemType.DEVICE: return 'devices'; + default: return undefined; + } +} + +export function isPublished(v: MpItemVersionView): boolean { + return !!v.publishedTime && v.publishedTime > 0; +} + +export interface DeepLinkOpenItem { + version: MpItemVersionView; + preview: boolean; +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts new file mode 100644 index 0000000000..0239966f10 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts @@ -0,0 +1,127 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { AppState } from '@core/core.state'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { + DeepLinkOpenItem, + isPublished, + isUUID, + typeSegment +} from './iot-hub-deep-link.utils'; +import { + IotHubUnpublishedWarningDialogData, + TbIotHubUnpublishedWarningDialogComponent +} from '@home/components/iot-hub/iot-hub-unpublished-warning-dialog.component'; + +@Component({ + selector: 'tb-iot-hub-item-resolver', + standalone: false, + template: '' +}) +export class TbIotHubItemResolverComponent implements OnInit { + + constructor( + private route: ActivatedRoute, + private router: Router, + private dialog: MatDialog, + private store: Store, + 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 }); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts index 88b18c86be..0e8c731ce6 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts @@ -16,8 +16,15 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { map } from 'rxjs/operators'; +import { Observable } from 'rxjs'; import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service'; +import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { PageLink } from '@shared/models/page/page-link'; +import { DeepLinkOpenItem } from './iot-hub-deep-link.utils'; interface ItemTypePageConfig { type: ItemType; @@ -86,13 +93,15 @@ export class TbIotHubItemsPageComponent implements OnInit { constructor( private route: ActivatedRoute, private router: Router, - private iotHubApiService: IotHubApiService + private iotHubApiService: IotHubApiService, + private iotHubActions: IotHubActionsService ) {} ngOnInit(): void { const itemType = this.route.snapshot.data['itemType'] as string; this.config = PAGE_CONFIGS[itemType]; this.loadInstalledCount(); + this.maybeOpenDeepLinkedItem(); } goBack(): void { @@ -112,4 +121,28 @@ export class TbIotHubItemsPageComponent implements OnInit { this.installedItemsCount = count; }); } + + private maybeOpenDeepLinkedItem(): void { + const openItem = history.state?.openItem as DeepLinkOpenItem | undefined; + if (!openItem || openItem.version.type !== this.config.type) { + return; + } + history.replaceState({ ...history.state, openItem: undefined }, ''); + this.resolveInstalledItem(openItem.version).subscribe(installed => { + this.iotHubActions.openItemDetail( + openItem.version, + installed ?? undefined, + installed ? 1 : 0, + 'default', + true, + openItem.preview + ).subscribe(); + }); + } + + private resolveInstalledItem(version: MpItemVersionView): Observable { + return this.iotHubApiService + .getInstalledItems(new PageLink(1), undefined, version.itemId, { ignoreLoading: true }) + .pipe(map(page => page.data[0] ?? null)); + } } diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts index b917f51f6e..118256edff 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts @@ -23,6 +23,7 @@ import { TbIotHubItemsPageComponent } from './iot-hub-items-page.component'; import { TbIotHubCreatorProfileComponent } from './iot-hub-creator-profile.component'; import { TbIotHubInstalledItemsComponent } from './iot-hub-installed-items.component'; import { TbIotHubSearchPageComponent } from './iot-hub-search-page.component'; +import { TbIotHubItemResolverComponent } from './iot-hub-item-resolver.component'; const routes: Routes = [ { @@ -138,6 +139,22 @@ const routes: Routes = [ icon: 'person' } } + }, + { + path: 'version/:itemVersionId', + component: TbIotHubItemResolverComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'iot-hub.item-detail' + } + }, + { + path: ':itemId', + component: TbIotHubItemResolverComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'iot-hub.item-detail' + } } ] } diff --git a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts index fb69490e45..b586fc613c 100644 --- a/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts +++ b/ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts @@ -24,6 +24,7 @@ import { TbIotHubItemsPageComponent } from './iot-hub-items-page.component'; import { TbIotHubCreatorProfileComponent } from './iot-hub-creator-profile.component'; import { TbIotHubInstalledItemsComponent } from './iot-hub-installed-items.component'; import { TbIotHubSearchPageComponent } from './iot-hub-search-page.component'; +import { TbIotHubItemResolverComponent } from './iot-hub-item-resolver.component'; @NgModule({ declarations: [ @@ -31,7 +32,8 @@ import { TbIotHubSearchPageComponent } from './iot-hub-search-page.component'; TbIotHubItemsPageComponent, TbIotHubCreatorProfileComponent, TbIotHubInstalledItemsComponent, - TbIotHubSearchPageComponent + TbIotHubSearchPageComponent, + TbIotHubItemResolverComponent ], imports: [ CommonModule, diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 3d161fb2fd..6980c36ac5 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3880,7 +3880,15 @@ "device-install-title": "Connect \"{{name}}\"", "device-installing-title": "Installing \"{{name}}\"", "done": "Done", - "in-progress": "In progress" + "in-progress": "In progress", + "item-detail": "IoT Hub item", + "unpublished-warning-title": "Unpublished content", + "unpublished-warning-text": "This is a preview of unpublished content. It has not been reviewed by IoT Hub. Installing unverified content can introduce security and stability risks — only continue if you trust the creator.", + "unpublished-warning-confirm": "I understand the risk, continue", + "unpublished-preview": "Unpublished preview", + "deep-link-invalid-id": "Invalid IoT Hub item link.", + "deep-link-not-found": "This IoT Hub item doesn't exist or was removed.", + "deep-link-fetch-failed": "Couldn't load IoT Hub item. Please try again." }, "item-data": { "widget-type-timeseries": "Timeseries",