48 changed files with 471 additions and 3698 deletions
@ -1,939 +0,0 @@ |
|||
# IoT Hub item deep link — Implementation Plan |
|||
|
|||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
|||
|
|||
**Goal:** Ship `/iot-hub/{itemId}` (latest published version of an item) and `/iot-hub/version/{itemVersionId}` (specific version snapshot, warning gate if unpublished) deep links that resolve a version, navigate to the type-specific browse page, and open the existing detail dialog. |
|||
|
|||
**Architecture:** A router-reachable `TbIotHubItemResolverComponent` owns resolution: fetch by itemId, (optionally) gate on a blocking warning dialog, then `router.navigate` to `/iot-hub/{typeSegment(type)}` carrying the version in `history.state`. The target type-page consumes the state once and opens the existing `TbIotHubItemDetailDialogComponent` via `IotHubActionsService`, with a new `preview` flag that adds an "Unpublished preview" badge. Zero ThingsBoard backend changes — install flows reuse existing versionId endpoints. |
|||
|
|||
**Tech Stack:** Angular 20, Angular Material dialogs, NgRx (for toast dispatch), RxJS. TypeScript strict mode. |
|||
|
|||
**Note on testing:** `ui-ngx` has no frontend test runner wired up (no `npm test` script, no karma/jest config). Each task is verified by `npm run lint` + `npm run build:prod` passing and, for flow-level tasks, a manual dev-server smoke test documented in the final task. |
|||
|
|||
**Prerequisite (external):** The two new IoT Hub endpoints (`GET /api/items/{itemId}/published` and `GET /api/items/{itemId}/latest`) must be deployed on the target IoT Hub instance for end-to-end testing. Until they land, local verification can be done against a mocked IoT Hub or against the published endpoint only. See the spec (`docs/superpowers/specs/2026-04-22-iot-hub-item-deep-link-design.md`) for the full IoT Hub-side contract. |
|||
|
|||
--- |
|||
|
|||
## File structure |
|||
|
|||
**Create:** |
|||
- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts` — UUID check, type→route-segment mapping, `isPublished` predicate |
|||
- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts` — route-reachable controller |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts` — warning dialog logic |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html` — warning dialog template |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss` — warning dialog styles |
|||
|
|||
**Modify:** |
|||
- `ui-ngx/src/app/core/http/iot-hub-api.service.ts` — add `getPublishedVersion`, `getLatestVersion` |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts` — add `preview` field on data + component |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html` — preview badge markup |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss` — preview badge styles |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts` — propagate `preview` in `openItemDetail` |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` — declare + export warning dialog |
|||
- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts` — two new child routes (placed last) |
|||
- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts` — declare resolver component |
|||
- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts` — `maybeOpenDeepLinkedItem` handoff |
|||
- `ui-ngx/src/assets/locale/locale.constant-en_US.json` — 9 new i18n keys under the existing `"iot-hub"` block |
|||
|
|||
--- |
|||
|
|||
### Task 1: Add i18n keys |
|||
|
|||
Add all new English strings up front so later tasks can reference them freely. Other locales follow the existing project convention (en_US only for new iot-hub keys; translators backfill later). |
|||
|
|||
**Files:** |
|||
- Modify: `ui-ngx/src/assets/locale/locale.constant-en_US.json` |
|||
|
|||
- [ ] **Step 1: Add keys inside the existing `"iot-hub"` block** |
|||
|
|||
Open `ui-ngx/src/assets/locale/locale.constant-en_US.json`, find the `"iot-hub"` block (starts at the line matching `"iot-hub": {`), and append the following nine keys to it (place them immediately before the closing `}` of the `"iot-hub"` object, keeping JSON valid — i.e. add a trailing comma to the previous key): |
|||
|
|||
```json |
|||
"item-detail": "IoT Hub item", |
|||
"item-preview": "IoT Hub item preview", |
|||
"unpublished-warning-title": "Unpublished content", |
|||
"unpublished-warning-text": "This is a preview of unpublished content. It has not been reviewed by IoT Hub. Installing unverified content can introduce security and stability risks — only continue if you trust the creator.", |
|||
"unpublished-warning-confirm": "I understand the risk, continue", |
|||
"unpublished-preview": "Unpublished preview", |
|||
"deep-link-invalid-id": "Invalid IoT Hub item link.", |
|||
"deep-link-not-found": "This IoT Hub item doesn't exist or was removed.", |
|||
"deep-link-fetch-failed": "Couldn't load IoT Hub item. Please try again." |
|||
``` |
|||
|
|||
- [ ] **Step 2: Validate JSON** |
|||
|
|||
Run: |
|||
```bash |
|||
cd ui-ngx && node -e "JSON.parse(require('fs').readFileSync('src/assets/locale/locale.constant-en_US.json', 'utf8')); console.log('OK')" |
|||
``` |
|||
Expected output: `OK`. |
|||
|
|||
- [ ] **Step 3: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/assets/locale/locale.constant-en_US.json |
|||
git commit -m "feat(iot-hub): add i18n keys for item deep-link and unpublished warning" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 2: Deep-link utility module |
|||
|
|||
A tiny, pure-TS helper file. No Angular deps. Exported functions are used by the resolver and (for `isPublished`) by the detail dialog. |
|||
|
|||
**Files:** |
|||
- Create: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts` |
|||
|
|||
- [ ] **Step 1: Create the file** |
|||
|
|||
Create `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts` with: |
|||
|
|||
```ts |
|||
/// |
|||
/// Copyright © 2016-2026 The Thingsboard Authors |
|||
/// |
|||
/// Licensed under the Apache License, Version 2.0 (the "License"); |
|||
/// you may not use this file except in compliance with the License. |
|||
/// You may obtain a copy of the License at |
|||
/// |
|||
/// http://www.apache.org/licenses/LICENSE-2.0 |
|||
/// |
|||
/// Unless required by applicable law or agreed to in writing, software |
|||
/// distributed under the License is distributed on an "AS IS" BASIS, |
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
/// See the License for the specific language governing permissions and |
|||
/// limitations under the License. |
|||
/// |
|||
|
|||
import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; |
|||
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; |
|||
|
|||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; |
|||
|
|||
export function isUUID(s: string | null | undefined): s is string { |
|||
return !!s && UUID_RE.test(s); |
|||
} |
|||
|
|||
export function typeSegment(t: ItemType): string | undefined { |
|||
switch (t) { |
|||
case ItemType.WIDGET: return 'widgets'; |
|||
case ItemType.DASHBOARD: return 'dashboards'; |
|||
case ItemType.SOLUTION_TEMPLATE: return 'solution-templates'; |
|||
case ItemType.CALCULATED_FIELD: return 'calculated-fields'; |
|||
case ItemType.RULE_CHAIN: return 'rule-chains'; |
|||
case ItemType.DEVICE: return 'devices'; |
|||
default: return undefined; |
|||
} |
|||
} |
|||
|
|||
export function isPublished(v: MpItemVersionView): boolean { |
|||
return !!v.publishedTime && v.publishedTime > 0; |
|||
} |
|||
|
|||
export interface DeepLinkOpenItem { |
|||
version: MpItemVersionView; |
|||
preview: boolean; |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 2: Verify TypeScript compiles** |
|||
|
|||
Run: |
|||
```bash |
|||
cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -20 |
|||
``` |
|||
Expected: build succeeds. If `ng build` is too slow for iteration, use `npx tsc --noEmit -p tsconfig.json 2>&1 | grep iot-hub-deep-link || echo 'no type errors in utils file'`. |
|||
|
|||
- [ ] **Step 3: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts |
|||
git commit -m "feat(iot-hub): add deep-link utility helpers (isUUID, typeSegment, isPublished)" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 3: IoT Hub API service — two new methods |
|||
|
|||
Add `getPublishedVersion` and `getLatestVersion` to `IotHubApiService`. Both hit IoT Hub cross-origin endpoints (same `baseUrl` as existing `/api/versions/published`). |
|||
|
|||
**Files:** |
|||
- Modify: `ui-ngx/src/app/core/http/iot-hub-api.service.ts` |
|||
|
|||
- [ ] **Step 1: Add the two methods** |
|||
|
|||
Open `ui-ngx/src/app/core/http/iot-hub-api.service.ts`. Insert the following two methods immediately after `getVersionInfo` (which ends near line 100): |
|||
|
|||
```ts |
|||
public getPublishedVersion(itemId: string, config?: IotHubRequestConfig): Observable<MpItemVersionView> { |
|||
return this.http.get<MpItemVersionView>( |
|||
`${this.baseUrl}/api/items/${itemId}/published`, |
|||
{ params: this.buildParams(config) } |
|||
); |
|||
} |
|||
|
|||
public getLatestVersion(itemId: string, config?: IotHubRequestConfig): Observable<MpItemVersionView> { |
|||
return this.http.get<MpItemVersionView>( |
|||
`${this.baseUrl}/api/items/${itemId}/latest`, |
|||
{ params: this.buildParams(config) } |
|||
); |
|||
} |
|||
``` |
|||
|
|||
Use the exact `Edit` tool call: `old_string` should be the line ` public getVersionReadme(versionId: string, config?: IotHubRequestConfig): Observable<string> {` (and enough context around it), and `new_string` should prepend the two new methods above it. |
|||
|
|||
- [ ] **Step 2: Verify build** |
|||
|
|||
Run: |
|||
```bash |
|||
cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -15 |
|||
``` |
|||
Expected: build succeeds. |
|||
|
|||
- [ ] **Step 3: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/core/http/iot-hub-api.service.ts |
|||
git commit -m "feat(iot-hub): add getPublishedVersion and getLatestVersion API methods" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 4: Unpublished warning dialog component |
|||
|
|||
New dialog styled after `TbIotHubDeleteDialogComponent`: title, description, item summary, Cancel + danger-accented confirm button. Returns `boolean` from `afterClosed`. |
|||
|
|||
**Files:** |
|||
- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts` |
|||
- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html` |
|||
- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss` |
|||
- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` |
|||
|
|||
- [ ] **Step 1: Create the component .ts** |
|||
|
|||
Create `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts`: |
|||
|
|||
```ts |
|||
/// |
|||
/// Copyright © 2016-2026 The Thingsboard Authors |
|||
/// |
|||
/// Licensed under the Apache License, Version 2.0 (the "License"); |
|||
/// you may not use this file except in compliance with the License. |
|||
/// You may obtain a copy of the License at |
|||
/// |
|||
/// http://www.apache.org/licenses/LICENSE-2.0 |
|||
/// |
|||
/// Unless required by applicable law or agreed to in writing, software |
|||
/// distributed under the License is distributed on an "AS IS" BASIS, |
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
/// See the License for the specific language governing permissions and |
|||
/// limitations under the License. |
|||
/// |
|||
|
|||
import { Component, Inject } from '@angular/core'; |
|||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; |
|||
import { Router } from '@angular/router'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { DialogComponent } from '@shared/components/dialog.component'; |
|||
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; |
|||
|
|||
export interface IotHubUnpublishedWarningDialogData { |
|||
item: MpItemVersionView; |
|||
} |
|||
|
|||
@Component({ |
|||
selector: 'tb-iot-hub-unpublished-warning-dialog', |
|||
standalone: false, |
|||
templateUrl: './iot-hub-unpublished-warning-dialog.component.html', |
|||
styleUrls: ['./iot-hub-unpublished-warning-dialog.component.scss'] |
|||
}) |
|||
export class TbIotHubUnpublishedWarningDialogComponent extends DialogComponent<TbIotHubUnpublishedWarningDialogComponent, boolean> { |
|||
|
|||
constructor( |
|||
protected store: Store<AppState>, |
|||
protected router: Router, |
|||
protected dialogRef: MatDialogRef<TbIotHubUnpublishedWarningDialogComponent, boolean>, |
|||
@Inject(MAT_DIALOG_DATA) public data: IotHubUnpublishedWarningDialogData |
|||
) { |
|||
super(store, router, dialogRef); |
|||
} |
|||
|
|||
confirm(): void { |
|||
this.dialogRef.close(true); |
|||
} |
|||
|
|||
cancel(): void { |
|||
this.dialogRef.close(false); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 2: Create the component .html** |
|||
|
|||
Create `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html`: |
|||
|
|||
```html |
|||
<!-- |
|||
|
|||
Copyright © 2016-2026 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
<div class="tb-iot-hub-warning-content"> |
|||
<div class="tb-iot-hub-warning-title-row"> |
|||
<mat-icon class="tb-iot-hub-warning-icon">warning</mat-icon> |
|||
<h2 class="tb-iot-hub-warning-title">{{ 'iot-hub.unpublished-warning-title' | translate }}</h2> |
|||
</div> |
|||
<p class="tb-iot-hub-warning-text">{{ 'iot-hub.unpublished-warning-text' | translate }}</p> |
|||
<div class="tb-iot-hub-warning-item"> |
|||
<span class="tb-iot-hub-warning-item-name">{{ data.item.name }}</span> |
|||
<span class="tb-iot-hub-warning-item-version">v {{ data.item.version }}</span> |
|||
</div> |
|||
</div> |
|||
<div class="tb-iot-hub-warning-actions"> |
|||
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button> |
|||
<button mat-flat-button color="warn" (click)="confirm()">{{ 'iot-hub.unpublished-warning-confirm' | translate }}</button> |
|||
</div> |
|||
``` |
|||
|
|||
- [ ] **Step 3: Create the component .scss** |
|||
|
|||
Create `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss`: |
|||
|
|||
```scss |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0 |
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
|
|||
.tb-iot-hub-warning-content { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 16px; |
|||
padding: 24px; |
|||
max-width: 480px; |
|||
} |
|||
|
|||
.tb-iot-hub-warning-title-row { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 12px; |
|||
} |
|||
|
|||
.tb-iot-hub-warning-icon { |
|||
color: #d32f2f; |
|||
font-size: 28px; |
|||
width: 28px; |
|||
height: 28px; |
|||
} |
|||
|
|||
.tb-iot-hub-warning-title { |
|||
font-size: 20px; |
|||
font-weight: 600; |
|||
line-height: 24px; |
|||
letter-spacing: 0.1px; |
|||
color: rgba(0, 0, 0, 0.87); |
|||
margin: 0; |
|||
} |
|||
|
|||
.tb-iot-hub-warning-text { |
|||
font-size: 14px; |
|||
font-weight: 400; |
|||
line-height: 20px; |
|||
letter-spacing: 0.2px; |
|||
color: rgba(0, 0, 0, 0.75); |
|||
margin: 0; |
|||
} |
|||
|
|||
.tb-iot-hub-warning-item { |
|||
display: flex; |
|||
align-items: baseline; |
|||
gap: 8px; |
|||
padding: 12px 16px; |
|||
background: rgba(211, 47, 47, 0.08); |
|||
border-left: 3px solid #d32f2f; |
|||
border-radius: 2px; |
|||
|
|||
.tb-iot-hub-warning-item-name { |
|||
font-weight: 600; |
|||
color: rgba(0, 0, 0, 0.87); |
|||
} |
|||
|
|||
.tb-iot-hub-warning-item-version { |
|||
font-size: 13px; |
|||
color: rgba(0, 0, 0, 0.54); |
|||
} |
|||
} |
|||
|
|||
.tb-iot-hub-warning-actions { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: flex-end; |
|||
gap: 8px; |
|||
padding: 8px; |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 4: Register in `IotHubComponentsModule`** |
|||
|
|||
Open `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts`. |
|||
|
|||
Add the import line after the existing `TbIotHubDeleteDialogComponent` import: |
|||
|
|||
```ts |
|||
import { TbIotHubUnpublishedWarningDialogComponent } from './iot-hub-unpublished-warning-dialog.component'; |
|||
``` |
|||
|
|||
In the `declarations` array, add `TbIotHubUnpublishedWarningDialogComponent` after `TbIotHubDeleteDialogComponent`. |
|||
|
|||
In the `exports` array, add `TbIotHubUnpublishedWarningDialogComponent` after `TbIotHubDeleteDialogComponent`. |
|||
|
|||
- [ ] **Step 5: Verify build** |
|||
|
|||
Run: |
|||
```bash |
|||
cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -15 |
|||
``` |
|||
Expected: build succeeds. |
|||
|
|||
- [ ] **Step 6: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts \ |
|||
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html \ |
|||
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss \ |
|||
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts |
|||
git commit -m "feat(iot-hub): add unpublished content warning dialog" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 5: Detail dialog `preview` flag + badge, actions service signature |
|||
|
|||
Add `preview?: boolean` to `IotHubItemDetailDialogData`, store on the component, render an "Unpublished preview" badge in the meta-bar next to the version chip. Extend `IotHubActionsService.openItemDetail` to forward the flag. |
|||
|
|||
**Files:** |
|||
- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts` |
|||
- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html` |
|||
- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss` |
|||
- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts` |
|||
|
|||
- [ ] **Step 1: Extend `IotHubItemDetailDialogData` + component field** |
|||
|
|||
In `iot-hub-item-detail-dialog.component.ts`, find the `IotHubItemDetailDialogData` interface (around line 43) and add the optional `preview` field: |
|||
|
|||
```ts |
|||
export interface IotHubItemDetailDialogData { |
|||
item: MpItemVersionView; |
|||
installedItem?: IotHubInstalledItem; |
|||
installedItemsCount?: number; |
|||
mode?: IotHubItemDetailDialogMode; |
|||
showCreator?: boolean; |
|||
preview?: boolean; |
|||
} |
|||
``` |
|||
|
|||
In the component class, add a new public field below `showCreator`: |
|||
|
|||
```ts |
|||
preview: boolean; |
|||
``` |
|||
|
|||
In the constructor body (below `this.showCreator = data.showCreator !== false;`), add: |
|||
|
|||
```ts |
|||
this.preview = data.preview === true; |
|||
``` |
|||
|
|||
- [ ] **Step 2: Add badge markup in template** |
|||
|
|||
In `iot-hub-item-detail-dialog.component.html`, find the block starting with `<span class="dlg-subtitle-group">` that contains the version icon (around line 39, the `update` mat-icon). Immediately **after** the closing `</span>` of that version group (just before the `@if (item.publishedTime)` block), insert: |
|||
|
|||
```html |
|||
@if (preview) { |
|||
<span class="dlg-dot"></span> |
|||
<span class="dlg-subtitle-group tb-unpublished-preview-badge"> |
|||
<mat-icon class="dlg-subtitle-icon">warning</mat-icon> |
|||
<span>{{ 'iot-hub.unpublished-preview' | translate }}</span> |
|||
</span> |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 3: Style the badge** |
|||
|
|||
Append to `iot-hub-item-detail-dialog.component.scss`: |
|||
|
|||
```scss |
|||
.dlg-subtitle-group.tb-unpublished-preview-badge { |
|||
color: #d32f2f; |
|||
font-weight: 600; |
|||
|
|||
.dlg-subtitle-icon { |
|||
color: #d32f2f; |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 4: Extend `IotHubActionsService.openItemDetail`** |
|||
|
|||
In `iot-hub-actions.service.ts`, replace the `openItemDetail` method signature and body with: |
|||
|
|||
```ts |
|||
openItemDetail(item: MpItemVersionView, installedItem?: IotHubInstalledItem, installedItemsCount?: number, |
|||
mode?: IotHubItemDetailDialogMode, showCreator?: boolean, preview?: boolean): Observable<any> { |
|||
return this.dialog.open(TbIotHubItemDetailDialogComponent, { |
|||
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], |
|||
autoFocus: false, |
|||
data: { item, installedItem, installedItemsCount, mode, showCreator, preview } as IotHubItemDetailDialogData |
|||
}).afterClosed(); |
|||
} |
|||
``` |
|||
|
|||
Existing callers pass 5 or fewer args and remain valid — the new parameter is optional. |
|||
|
|||
- [ ] **Step 5: Verify build** |
|||
|
|||
Run: |
|||
```bash |
|||
cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -15 |
|||
``` |
|||
Expected: build succeeds. |
|||
|
|||
- [ ] **Step 6: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts \ |
|||
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html \ |
|||
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss \ |
|||
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts |
|||
git commit -m "feat(iot-hub): add unpublished preview badge to item detail dialog" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 6: Resolver component |
|||
|
|||
The heart of the feature. Fetches the version, gates on warning for unpublished preview, redirects to the type-page with router state. |
|||
|
|||
**Files:** |
|||
- Create: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts` |
|||
|
|||
- [ ] **Step 1: Create the component** |
|||
|
|||
Create `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts`: |
|||
|
|||
```ts |
|||
/// |
|||
/// Copyright © 2016-2026 The Thingsboard Authors |
|||
/// |
|||
/// Licensed under the Apache License, Version 2.0 (the "License"); |
|||
/// you may not use this file except in compliance with the License. |
|||
/// You may obtain a copy of the License at |
|||
/// |
|||
/// http://www.apache.org/licenses/LICENSE-2.0 |
|||
/// |
|||
/// Unless required by applicable law or agreed to in writing, software |
|||
/// distributed under the License is distributed on an "AS IS" BASIS, |
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
/// See the License for the specific language governing permissions and |
|||
/// limitations under the License. |
|||
/// |
|||
|
|||
import { Component, OnInit } from '@angular/core'; |
|||
import { ActivatedRoute, Router } from '@angular/router'; |
|||
import { MatDialog } from '@angular/material/dialog'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { TranslateService } from '@ngx-translate/core'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { ActionNotificationShow } from '@core/notification/notification.actions'; |
|||
import { IotHubApiService } from '@core/http/iot-hub-api.service'; |
|||
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; |
|||
import { |
|||
DeepLinkOpenItem, |
|||
isPublished, |
|||
isUUID, |
|||
typeSegment |
|||
} from './iot-hub-deep-link.utils'; |
|||
import { |
|||
IotHubUnpublishedWarningDialogData, |
|||
TbIotHubUnpublishedWarningDialogComponent |
|||
} from '@home/components/iot-hub/iot-hub-unpublished-warning-dialog.component'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-iot-hub-item-resolver', |
|||
standalone: false, |
|||
template: '' |
|||
}) |
|||
export class TbIotHubItemResolverComponent implements OnInit { |
|||
|
|||
constructor( |
|||
private route: ActivatedRoute, |
|||
private router: Router, |
|||
private dialog: MatDialog, |
|||
private store: Store<AppState>, |
|||
private translate: TranslateService, |
|||
private iotHubApi: IotHubApiService |
|||
) {} |
|||
|
|||
ngOnInit(): void { |
|||
const itemId = this.route.snapshot.paramMap.get('itemId'); |
|||
const preview = this.route.snapshot.data['preview'] === true; |
|||
|
|||
if (!isUUID(itemId)) { |
|||
this.failTo('iot-hub.deep-link-invalid-id'); |
|||
return; |
|||
} |
|||
|
|||
const fetch$ = preview |
|||
? this.iotHubApi.getLatestVersion(itemId, { ignoreErrors: true }) |
|||
: this.iotHubApi.getPublishedVersion(itemId, { ignoreErrors: true }); |
|||
|
|||
fetch$.subscribe({ |
|||
next: v => this.handleResolved(v, preview), |
|||
error: err => { |
|||
const key = err?.status === 404 |
|||
? 'iot-hub.deep-link-not-found' |
|||
: 'iot-hub.deep-link-fetch-failed'; |
|||
this.failTo(key); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
private handleResolved(version: MpItemVersionView, preview: boolean): void { |
|||
const segment = typeSegment(version.type); |
|||
if (!segment) { |
|||
this.failTo('iot-hub.deep-link-fetch-failed'); |
|||
return; |
|||
} |
|||
|
|||
const showWarning = preview && !isPublished(version); |
|||
|
|||
if (showWarning) { |
|||
this.dialog.open< |
|||
TbIotHubUnpublishedWarningDialogComponent, |
|||
IotHubUnpublishedWarningDialogData, |
|||
boolean |
|||
>(TbIotHubUnpublishedWarningDialogComponent, { |
|||
panelClass: ['tb-dialog'], |
|||
disableClose: true, |
|||
autoFocus: false, |
|||
data: { item: version } |
|||
}).afterClosed().subscribe(confirmed => { |
|||
if (confirmed) { |
|||
this.openOnTypePage(version, segment, true); |
|||
} else { |
|||
this.router.navigate(['/iot-hub'], { replaceUrl: true }); |
|||
} |
|||
}); |
|||
} else { |
|||
this.openOnTypePage(version, segment, false); |
|||
} |
|||
} |
|||
|
|||
private openOnTypePage(version: MpItemVersionView, segment: string, preview: boolean): void { |
|||
const openItem: DeepLinkOpenItem = { version, preview }; |
|||
this.router.navigate(['/iot-hub', segment], { |
|||
state: { openItem }, |
|||
replaceUrl: true |
|||
}); |
|||
} |
|||
|
|||
private failTo(messageKey: string): void { |
|||
this.store.dispatch(new ActionNotificationShow({ |
|||
message: this.translate.instant(messageKey), |
|||
type: 'error', |
|||
duration: 5000 |
|||
})); |
|||
this.router.navigate(['/iot-hub'], { replaceUrl: true }); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 2: Verify TypeScript compiles (the module wiring comes in Task 7)** |
|||
|
|||
The component is not yet declared in any module, so a full `ng build` will fail. Run a type-check only on this file: |
|||
|
|||
```bash |
|||
cd ui-ngx && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "iot-hub-item-resolver|iot-hub-deep-link" || echo "no type errors in new files" |
|||
``` |
|||
Expected: `no type errors in new files`. |
|||
|
|||
- [ ] **Step 3: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts |
|||
git commit -m "feat(iot-hub): add item resolver component for deep links" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 7: Routing + module registration |
|||
|
|||
Register the resolver component in `IotHubModule` and add the two new child routes to `iot-hub-routing.module.ts`. Reserved words must be matched first, so the `:itemId` wildcard routes go **last** in the children array. |
|||
|
|||
**Files:** |
|||
- Modify: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts` |
|||
- Modify: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts` |
|||
|
|||
- [ ] **Step 1: Declare resolver in `IotHubModule`** |
|||
|
|||
In `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts`, add the import: |
|||
|
|||
```ts |
|||
import { TbIotHubItemResolverComponent } from './iot-hub-item-resolver.component'; |
|||
``` |
|||
|
|||
In the `declarations` array, add `TbIotHubItemResolverComponent` after `TbIotHubSearchPageComponent`. |
|||
|
|||
- [ ] **Step 2: Add routes** |
|||
|
|||
In `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts`, add the import: |
|||
|
|||
```ts |
|||
import { TbIotHubItemResolverComponent } from './iot-hub-item-resolver.component'; |
|||
``` |
|||
|
|||
In the `children` array of the `/iot-hub` route, **immediately before** the closing `]` (after the existing `creator/:creatorId` entry), insert: |
|||
|
|||
```ts |
|||
{ |
|||
path: ':itemId', |
|||
component: TbIotHubItemResolverComponent, |
|||
data: { |
|||
auth: [Authority.TENANT_ADMIN], |
|||
title: 'iot-hub.item-detail' |
|||
} |
|||
}, |
|||
{ |
|||
path: ':itemId/preview', |
|||
component: TbIotHubItemResolverComponent, |
|||
data: { |
|||
auth: [Authority.TENANT_ADMIN], |
|||
title: 'iot-hub.item-preview', |
|||
preview: true |
|||
} |
|||
} |
|||
``` |
|||
|
|||
Make sure there is a comma after the preceding `creator/:creatorId` entry. |
|||
|
|||
- [ ] **Step 3: Verify build** |
|||
|
|||
Run: |
|||
```bash |
|||
cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -15 |
|||
``` |
|||
Expected: build succeeds. |
|||
|
|||
- [ ] **Step 4: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts \ |
|||
ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts |
|||
git commit -m "feat(iot-hub): wire resolver component into routing and module" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 8: Type-page handoff |
|||
|
|||
`TbIotHubItemsPageComponent` consumes `history.state.openItem` on init: resolves installed state, opens the detail dialog with the `preview` flag, then clears the state entry so refresh does not re-open. |
|||
|
|||
**Files:** |
|||
- Modify: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts` |
|||
|
|||
- [ ] **Step 1: Extend imports + constructor** |
|||
|
|||
Open `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts`. |
|||
|
|||
Replace the import block (top of file, currently lines 17-20) with: |
|||
|
|||
```ts |
|||
import { Component, OnInit } from '@angular/core'; |
|||
import { ActivatedRoute, Router } from '@angular/router'; |
|||
import { map } from 'rxjs/operators'; |
|||
import { Observable } from 'rxjs'; |
|||
import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; |
|||
import { IotHubApiService } from '@core/http/iot-hub-api.service'; |
|||
import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service'; |
|||
import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models'; |
|||
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; |
|||
import { PageLink } from '@shared/models/page/page-link'; |
|||
import { DeepLinkOpenItem } from './iot-hub-deep-link.utils'; |
|||
``` |
|||
|
|||
Replace the constructor with: |
|||
|
|||
```ts |
|||
constructor( |
|||
private route: ActivatedRoute, |
|||
private router: Router, |
|||
private iotHubApiService: IotHubApiService, |
|||
private iotHubActions: IotHubActionsService |
|||
) {} |
|||
``` |
|||
|
|||
- [ ] **Step 2: Extend `ngOnInit` and add handoff method** |
|||
|
|||
Replace the existing `ngOnInit` body with: |
|||
|
|||
```ts |
|||
ngOnInit(): void { |
|||
const itemType = this.route.snapshot.data['itemType'] as string; |
|||
this.config = PAGE_CONFIGS[itemType]; |
|||
this.loadInstalledCount(); |
|||
this.maybeOpenDeepLinkedItem(); |
|||
} |
|||
``` |
|||
|
|||
Add these two new methods anywhere in the class (e.g. after `loadInstalledCount`): |
|||
|
|||
```ts |
|||
private maybeOpenDeepLinkedItem(): void { |
|||
const openItem = history.state?.openItem as DeepLinkOpenItem | undefined; |
|||
if (!openItem || openItem.version.type !== this.config.type) { |
|||
return; |
|||
} |
|||
history.replaceState({ ...history.state, openItem: undefined }, ''); |
|||
this.resolveInstalledItem(openItem.version).subscribe(installed => { |
|||
this.iotHubActions.openItemDetail( |
|||
openItem.version, |
|||
installed ?? undefined, |
|||
installed ? 1 : 0, |
|||
'default', |
|||
true, |
|||
openItem.preview |
|||
).subscribe(); |
|||
}); |
|||
} |
|||
|
|||
private resolveInstalledItem(version: MpItemVersionView): Observable<IotHubInstalledItem | null> { |
|||
return this.iotHubApiService |
|||
.getInstalledItems(new PageLink(1), undefined, version.itemId, { ignoreLoading: true }) |
|||
.pipe(map(page => page.data[0] ?? null)); |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 3: Verify build** |
|||
|
|||
Run: |
|||
```bash |
|||
cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -15 |
|||
``` |
|||
Expected: build succeeds. |
|||
|
|||
- [ ] **Step 4: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts |
|||
git commit -m "feat(iot-hub): open deep-linked item from history state on type-page init" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 9: Lint, build, and manual smoke test |
|||
|
|||
Final verification gate. Runs the lint and production build, then walks each user-facing flow in a dev server to confirm behavior. |
|||
|
|||
**Files:** none (verification only) |
|||
|
|||
- [ ] **Step 1: Lint** |
|||
|
|||
Run: |
|||
```bash |
|||
cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng lint 2>&1 | tail -30 |
|||
``` |
|||
Expected: no new errors introduced by files created/modified in tasks 1–8. Warnings in unrelated files are acceptable. |
|||
|
|||
- [ ] **Step 2: Production build** |
|||
|
|||
Run: |
|||
```bash |
|||
cd ui-ngx && node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration production 2>&1 | tail -20 |
|||
``` |
|||
Expected: build completes without errors. |
|||
|
|||
- [ ] **Step 3: Start dev server** |
|||
|
|||
Run: |
|||
```bash |
|||
cd ui-ngx && node --max_old_space_size=8048 ./node_modules/@angular/cli/bin/ng serve --configuration development --host 0.0.0.0 |
|||
``` |
|||
Dev server should be reachable at `http://localhost:4200`. Log in as a TENANT_ADMIN user. (A running ThingsBoard backend at the default proxy target is required per `proxy.conf.js`.) |
|||
|
|||
- [ ] **Step 4: Manual smoke test — published deep link** |
|||
|
|||
Pick an itemId from `/iot-hub/widgets` (the card's click handler logs it, or inspect the dialog URL via browser dev tools; alternatively, query `iotHubBaseUrl/api/versions/published` and copy an `itemId`). |
|||
|
|||
Navigate to `http://localhost:4200/iot-hub/{that-itemId}`. Verify: |
|||
- URL bar ends at `/iot-hub/widgets` (or the correct type page for the item) after resolution |
|||
- Detail dialog opens with the item's info, no "Unpublished preview" badge, and Install/Update actions behave normally |
|||
- Closing the dialog leaves the user on the type-page |
|||
|
|||
Navigate to `http://localhost:4200/iot-hub/00000000-0000-0000-0000-000000000000` (a valid-shaped but non-existent UUID). Verify: |
|||
- URL bar ends at `/iot-hub` |
|||
- Red toast: "This IoT Hub item doesn't exist or was removed." |
|||
|
|||
Navigate to `http://localhost:4200/iot-hub/not-a-uuid`. Verify: |
|||
- URL bar ends at `/iot-hub` |
|||
- Red toast: "Invalid IoT Hub item link." |
|||
|
|||
- [ ] **Step 5: Manual smoke test — preview deep link (requires IoT Hub `/latest` endpoint)** |
|||
|
|||
**If the IoT Hub-side endpoints are not yet deployed**, skip this step and record that end-to-end preview verification is deferred until the IoT Hub PR lands. The per-URL flow is exercised in Step 4; the preview URL hitting a non-existent endpoint will trigger the "fetch failed" toast, which is the correct fallback behavior. |
|||
|
|||
**If the endpoints are available**, navigate to `http://localhost:4200/iot-hub/{unpublished-itemId}/preview`. Verify: |
|||
- Warning dialog opens immediately with the item's name/version, red warning icon, and two buttons |
|||
- "Cancel" → closes dialog, lands on `/iot-hub` (home) |
|||
- Revisit the URL, click "I understand the risk, continue" → URL advances to `/iot-hub/{typePage}`, detail dialog opens with the red "Unpublished preview" badge in the meta bar |
|||
- Install button still works end-to-end (calls the TB install endpoint, which proxies to IoT Hub using the unpublished versionId) |
|||
|
|||
Navigate to `http://localhost:4200/iot-hub/{published-only-itemId}/preview`. Verify: |
|||
- No warning shown (preview fell back to the published version) |
|||
- Detail dialog opens with **no** preview badge |
|||
- Behavior matches the regular published URL |
|||
|
|||
- [ ] **Step 6: Commit the final verification (if any lint/build fixes were needed)** |
|||
|
|||
If the smoke test uncovered issues that required fixes, stage and commit them with a message like `fix(iot-hub): address smoke-test findings for deep-link flow`. Otherwise, this step is a no-op — all previous commits already capture the work. |
|||
|
|||
--- |
|||
|
|||
## IoT Hub-side changes required (recap) |
|||
|
|||
One new endpoint plus behavior + CORS contracts on the existing by-versionId family: |
|||
|
|||
1. **New endpoint** `GET /api/items/{itemId}/published` — latest PUBLISHED version as `MpItemVersionView`; `404` if none. Anonymous cross-origin. Powers the `/iot-hub/{itemId}` URL. |
|||
2. **`GET /api/versions/{versionId}`** must return the requested version regardless of state (PUBLISHED / DRAFT / PENDING_REVIEW / …). Anonymous cross-origin; versionId UUID is the soft-secret gate. Powers the `/iot-hub/version/{itemVersionId}` URL. |
|||
3. **`MpItemVersionView`** must allow the frontend to tell published from unpublished. Either `publishedTime` must be falsy (`0`/`null`) for non-published versions, or add an explicit `state` field. Frontend's `isPublished()` uses `publishedTime > 0` today. |
|||
4. **Related by-versionId endpoints** must also serve unpublished versions (required for install-from-deep-link): |
|||
- `GET /api/versions/{versionId}/readme` |
|||
- `GET /api/versions/{versionId}/fileData` |
|||
- `POST /api/versions/{versionId}/install` |
|||
5. **Install counter policy** for unpublished versions — recommended: skip counting. |
|||
6. **CORS** on `/api/items/{itemId}/published` and the `/api/versions/{versionId}/...` family must permit cross-origin GET from any origin. |
|||
|
|||
These live in the IoT Hub repository, not ThingsBoard CE. |
|||
@ -1,685 +0,0 @@ |
|||
# Install-Method Sync with IoT Hub Marketplace — 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:** Sync CE's `InstallMethod` enum + labels with the IoT Hub marketplace's expanded 50-value allow-list, and add a "PE only" gate in the device install dialog so users selecting a PE-only integration get a clear message instead of a silently-broken wizard. |
|||
|
|||
**Architecture:** Frontend-only. CE has no backend install-method allow-list (`DeviceInstalledItemDescriptor.selectedInstallMethod` is a free-form `String`), so all work lives in `ui-ngx`. The `GATEWAY_CONNECTOR` step type already handles connector config generically (line 761 of `device-install-dialog.component.ts`), so the 12 new `GATEWAY_*` install methods need no new install logic — only enum + label entries. The 26 new `INTEGRATION_*` methods are PE-only on CE: today CE silently skips `CONVERTER`/`INTEGRATION` steps (line 553), running an empty-progress wizard — this plan replaces that silent skip with an explicit "PE only — upgrade required" panel shown right after connectivity selection. |
|||
|
|||
**Tech Stack:** Angular 20, TypeScript, Material Design (existing `TbDeviceInstallDialogComponent`). |
|||
|
|||
--- |
|||
|
|||
## Source of Truth |
|||
|
|||
The marketplace allow-list lives at: |
|||
`/home/ashvayka/git/iot-hub/dao/src/main/java/org/thingsboard/iothub/dao/service/impl/ItemDataServiceImpl.java`, constant `VALID_INSTALL_METHODS`. |
|||
|
|||
CE's enum must be a strict subset of the marketplace's allow-list, including every entry. Diverging causes "marketplace accepts the package, CE wizard rejects it / shows raw constant" failures. |
|||
|
|||
PE/CE applicability — extracted from `/home/ashvayka/git/iot-hub/device-library-contribution.md`: |
|||
|
|||
| Group | Constants | CE-supported install behavior | |
|||
|-------|-----------|-------------------------------| |
|||
| Direct (5) | `DIRECT_HTTP`, `DIRECT_MQTT`, `DIRECT_COAP`, `DIRECT_LWM2M`, `DIRECT_SNMP` | Yes — runs through `DEVICE` + standard steps | |
|||
| Gateway (15) | `GATEWAY_MQTT`, `GATEWAY_MODBUS`, `GATEWAY_OPCUA`, `GATEWAY_BACNET`, `GATEWAY_BLE`, `GATEWAY_CAN`, `GATEWAY_FTP`, `GATEWAY_KNX`, `GATEWAY_OCPP`, `GATEWAY_ODBC`, `GATEWAY_REQUEST`, `GATEWAY_REST`, `GATEWAY_SNMP`, `GATEWAY_SOCKET`, `GATEWAY_XMPP` | Yes — generic `GATEWAY_CONNECTOR` step writes connector config to gateway shared attributes regardless of connector type | |
|||
| ChirpStack CE (1) | `CHIRPSTACK` | Yes | |
|||
| Integrations (29) | `INTEGRATION_*` (all values listed in step 4 of `VALID_INSTALL_METHODS`) | **No — PE only**. Show "PE only" panel, do not start wizard | |
|||
|
|||
--- |
|||
|
|||
## File Structure |
|||
|
|||
| Action | File | Responsibility | |
|||
|--------|------|----------------| |
|||
| Modify | `ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts` | Extend `InstallMethod` enum to 50 values; extend `installMethodLabels` to 50 labels; add new `peOnlyInstallMethods` set | |
|||
| Modify | `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts` | Add `peOnlySelected` getter; route `confirmConnectivity()` to a new "PE only" view-state instead of `startWizard()` when user picks a PE-only method | |
|||
| Modify | `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html` | Add a new conditional panel that renders when `peOnlySelected` is true: title, body copy, "Learn about ThingsBoard PE" link, Close button | |
|||
| Modify | `ui-ngx/src/assets/locale/locale.constant-en_US.json` | Add three translation keys for the PE-only panel: `iot-hub.device-install-pe-only-title`, `iot-hub.device-install-pe-only-message`, `iot-hub.device-install-pe-only-learn-more` | |
|||
|
|||
--- |
|||
|
|||
## Task 1: Sync `InstallMethod` enum to all 50 values |
|||
|
|||
**Files:** |
|||
- Modify: `ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts` |
|||
|
|||
- [ ] **Step 1: Replace the `InstallMethod` enum block** |
|||
|
|||
Find (lines 17–30): |
|||
```typescript |
|||
export enum InstallMethod { |
|||
DIRECT_HTTP = 'DIRECT_HTTP', |
|||
DIRECT_MQTT = 'DIRECT_MQTT', |
|||
DIRECT_COAP = 'DIRECT_COAP', |
|||
DIRECT_LWM2M = 'DIRECT_LWM2M', |
|||
DIRECT_SNMP = 'DIRECT_SNMP', |
|||
GATEWAY_MQTT = 'GATEWAY_MQTT', |
|||
GATEWAY_MODBUS = 'GATEWAY_MODBUS', |
|||
GATEWAY_OPCUA = 'GATEWAY_OPCUA', |
|||
CHIRPSTACK = 'CHIRPSTACK', |
|||
INTEGRATION_CHIRPSTACK = 'INTEGRATION_CHIRPSTACK', |
|||
INTEGRATION_TTN = 'INTEGRATION_TTN', |
|||
INTEGRATION_LORIOT = 'INTEGRATION_LORIOT' |
|||
} |
|||
``` |
|||
|
|||
Replace with (preserve grouping comments — they mirror the marketplace validator's grouping and make drift visible at review time): |
|||
```typescript |
|||
export enum InstallMethod { |
|||
// Direct device-to-platform transports |
|||
DIRECT_HTTP = 'DIRECT_HTTP', |
|||
DIRECT_MQTT = 'DIRECT_MQTT', |
|||
DIRECT_COAP = 'DIRECT_COAP', |
|||
DIRECT_LWM2M = 'DIRECT_LWM2M', |
|||
DIRECT_SNMP = 'DIRECT_SNMP', |
|||
// ThingsBoard IoT Gateway connectors |
|||
GATEWAY_MQTT = 'GATEWAY_MQTT', |
|||
GATEWAY_MODBUS = 'GATEWAY_MODBUS', |
|||
GATEWAY_OPCUA = 'GATEWAY_OPCUA', |
|||
GATEWAY_BACNET = 'GATEWAY_BACNET', |
|||
GATEWAY_BLE = 'GATEWAY_BLE', |
|||
GATEWAY_CAN = 'GATEWAY_CAN', |
|||
GATEWAY_FTP = 'GATEWAY_FTP', |
|||
GATEWAY_KNX = 'GATEWAY_KNX', |
|||
GATEWAY_OCPP = 'GATEWAY_OCPP', |
|||
GATEWAY_ODBC = 'GATEWAY_ODBC', |
|||
GATEWAY_REQUEST = 'GATEWAY_REQUEST', |
|||
GATEWAY_REST = 'GATEWAY_REST', |
|||
GATEWAY_SNMP = 'GATEWAY_SNMP', |
|||
GATEWAY_SOCKET = 'GATEWAY_SOCKET', |
|||
GATEWAY_XMPP = 'GATEWAY_XMPP', |
|||
// ChirpStack (CE-compatible LoRaWAN integration) |
|||
CHIRPSTACK = 'CHIRPSTACK', |
|||
// ThingsBoard PE integrations (CE shows "PE only" gate) |
|||
INTEGRATION_APACHE_PULSAR = 'INTEGRATION_APACHE_PULSAR', |
|||
INTEGRATION_AWS_IOT = 'INTEGRATION_AWS_IOT', |
|||
INTEGRATION_AWS_KINESIS = 'INTEGRATION_AWS_KINESIS', |
|||
INTEGRATION_AWS_SQS = 'INTEGRATION_AWS_SQS', |
|||
INTEGRATION_AZURE_EVENT_HUB = 'INTEGRATION_AZURE_EVENT_HUB', |
|||
INTEGRATION_AZURE_IOT_HUB = 'INTEGRATION_AZURE_IOT_HUB', |
|||
INTEGRATION_AZURE_SERVICE_BUS = 'INTEGRATION_AZURE_SERVICE_BUS', |
|||
INTEGRATION_CHIRPSTACK = 'INTEGRATION_CHIRPSTACK', |
|||
INTEGRATION_COAP = 'INTEGRATION_COAP', |
|||
INTEGRATION_CUSTOM = 'INTEGRATION_CUSTOM', |
|||
INTEGRATION_HTTP = 'INTEGRATION_HTTP', |
|||
INTEGRATION_IOT_CREATORS = 'INTEGRATION_IOT_CREATORS', |
|||
INTEGRATION_KAFKA = 'INTEGRATION_KAFKA', |
|||
INTEGRATION_KPN_THINGS = 'INTEGRATION_KPN_THINGS', |
|||
INTEGRATION_LORIOT = 'INTEGRATION_LORIOT', |
|||
INTEGRATION_MQTT = 'INTEGRATION_MQTT', |
|||
INTEGRATION_OPC_UA = 'INTEGRATION_OPC_UA', |
|||
INTEGRATION_PARTICLE = 'INTEGRATION_PARTICLE', |
|||
INTEGRATION_PUB_SUB = 'INTEGRATION_PUB_SUB', |
|||
INTEGRATION_RABBITMQ = 'INTEGRATION_RABBITMQ', |
|||
INTEGRATION_REMOTE = 'INTEGRATION_REMOTE', |
|||
INTEGRATION_SIGFOX = 'INTEGRATION_SIGFOX', |
|||
INTEGRATION_TCP = 'INTEGRATION_TCP', |
|||
INTEGRATION_THINGPARK = 'INTEGRATION_THINGPARK', |
|||
INTEGRATION_THINGPARK_ENTERPRISE = 'INTEGRATION_THINGPARK_ENTERPRISE', |
|||
INTEGRATION_TTI = 'INTEGRATION_TTI', |
|||
INTEGRATION_TTN = 'INTEGRATION_TTN', |
|||
INTEGRATION_TUYA = 'INTEGRATION_TUYA', |
|||
INTEGRATION_UDP = 'INTEGRATION_UDP' |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 2: Verify enum count is 50** |
|||
|
|||
Run: |
|||
```bash |
|||
grep -c '^ [A-Z][A-Z_]* = ' /home/ashvayka/git/ce/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts |
|||
``` |
|||
Expected: `50` |
|||
|
|||
- [ ] **Step 3: Verify CE values are a subset of marketplace `VALID_INSTALL_METHODS`** |
|||
|
|||
Run: |
|||
```bash |
|||
diff <(grep -oP "(?<=^ )[A-Z][A-Z_]+(?= = )" /home/ashvayka/git/ce/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts | sort -u) \ |
|||
<(grep -oP '"[A-Z][A-Z_]+"' /home/ashvayka/git/iot-hub/dao/src/main/java/org/thingsboard/iothub/dao/service/impl/ItemDataServiceImpl.java | sed 's/^"//; s/"$//' | head -50 | sort -u) |
|||
``` |
|||
Expected: no output (sets identical). |
|||
|
|||
- [ ] **Step 4: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts |
|||
git commit -m "feat(iot-hub): expand InstallMethod enum to marketplace's 50-value allow-list" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Task 2: Sync `installMethodLabels` map |
|||
|
|||
**Files:** |
|||
- Modify: `ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts` |
|||
|
|||
- [ ] **Step 1: Replace the `installMethodLabels` map** |
|||
|
|||
Find (lines 32–47, after Task 1 the line numbers shift — locate by the `export const installMethodLabels = new Map<string, string>(` declaration): |
|||
```typescript |
|||
export const installMethodLabels = new Map<string, string>( |
|||
[ |
|||
[InstallMethod.DIRECT_HTTP, 'HTTP'], |
|||
[InstallMethod.DIRECT_MQTT, 'MQTT'], |
|||
[InstallMethod.DIRECT_COAP, 'CoAP'], |
|||
[InstallMethod.DIRECT_LWM2M, 'LwM2M'], |
|||
[InstallMethod.DIRECT_SNMP, 'SNMP'], |
|||
[InstallMethod.GATEWAY_MQTT, 'MQTT Gateway'], |
|||
[InstallMethod.GATEWAY_MODBUS, 'Modbus Gateway'], |
|||
[InstallMethod.GATEWAY_OPCUA, 'OPC-UA Gateway'], |
|||
[InstallMethod.CHIRPSTACK, 'ChirpStack'], |
|||
[InstallMethod.INTEGRATION_CHIRPSTACK, 'ChirpStack (PE)'], |
|||
[InstallMethod.INTEGRATION_TTN, 'The Things Stack'], |
|||
[InstallMethod.INTEGRATION_LORIOT, 'LORIOT'] |
|||
] |
|||
); |
|||
``` |
|||
|
|||
Replace with (labels mirror the brief's table and the "Description" column of the marketplace contributor doc; gateway labels keep the existing "X Gateway" pattern; integration labels keep the brand name without a "(PE)" suffix because the dialog's PE-only panel already conveys that): |
|||
```typescript |
|||
export const installMethodLabels = new Map<string, string>( |
|||
[ |
|||
// Direct |
|||
[InstallMethod.DIRECT_HTTP, 'HTTP'], |
|||
[InstallMethod.DIRECT_MQTT, 'MQTT'], |
|||
[InstallMethod.DIRECT_COAP, 'CoAP'], |
|||
[InstallMethod.DIRECT_LWM2M, 'LwM2M'], |
|||
[InstallMethod.DIRECT_SNMP, 'SNMP'], |
|||
// Gateway connectors |
|||
[InstallMethod.GATEWAY_MQTT, 'MQTT Gateway'], |
|||
[InstallMethod.GATEWAY_MODBUS, 'Modbus Gateway'], |
|||
[InstallMethod.GATEWAY_OPCUA, 'OPC-UA Gateway'], |
|||
[InstallMethod.GATEWAY_BACNET, 'BACnet Gateway'], |
|||
[InstallMethod.GATEWAY_BLE, 'BLE Gateway'], |
|||
[InstallMethod.GATEWAY_CAN, 'CAN Gateway'], |
|||
[InstallMethod.GATEWAY_FTP, 'FTP Gateway'], |
|||
[InstallMethod.GATEWAY_KNX, 'KNX Gateway'], |
|||
[InstallMethod.GATEWAY_OCPP, 'OCPP Gateway'], |
|||
[InstallMethod.GATEWAY_ODBC, 'ODBC Gateway'], |
|||
[InstallMethod.GATEWAY_REQUEST, 'Request Gateway'], |
|||
[InstallMethod.GATEWAY_REST, 'REST Gateway'], |
|||
[InstallMethod.GATEWAY_SNMP, 'SNMP Gateway'], |
|||
[InstallMethod.GATEWAY_SOCKET, 'Socket Gateway'], |
|||
[InstallMethod.GATEWAY_XMPP, 'XMPP Gateway'], |
|||
// ChirpStack |
|||
[InstallMethod.CHIRPSTACK, 'ChirpStack'], |
|||
// PE integrations |
|||
[InstallMethod.INTEGRATION_APACHE_PULSAR, 'Apache Pulsar'], |
|||
[InstallMethod.INTEGRATION_AWS_IOT, 'AWS IoT'], |
|||
[InstallMethod.INTEGRATION_AWS_KINESIS, 'AWS Kinesis'], |
|||
[InstallMethod.INTEGRATION_AWS_SQS, 'AWS SQS'], |
|||
[InstallMethod.INTEGRATION_AZURE_EVENT_HUB, 'Azure Event Hub'], |
|||
[InstallMethod.INTEGRATION_AZURE_IOT_HUB, 'Azure IoT Hub'], |
|||
[InstallMethod.INTEGRATION_AZURE_SERVICE_BUS, 'Azure Service Bus'], |
|||
[InstallMethod.INTEGRATION_CHIRPSTACK, 'ChirpStack (Integration)'], |
|||
[InstallMethod.INTEGRATION_COAP, 'CoAP Integration'], |
|||
[InstallMethod.INTEGRATION_CUSTOM, 'Custom Integration'], |
|||
[InstallMethod.INTEGRATION_HTTP, 'HTTP Integration'], |
|||
[InstallMethod.INTEGRATION_IOT_CREATORS, 'IoT Creators'], |
|||
[InstallMethod.INTEGRATION_KAFKA, 'Apache Kafka'], |
|||
[InstallMethod.INTEGRATION_KPN_THINGS, 'KPN Things'], |
|||
[InstallMethod.INTEGRATION_LORIOT, 'LORIOT'], |
|||
[InstallMethod.INTEGRATION_MQTT, 'MQTT Integration'], |
|||
[InstallMethod.INTEGRATION_OPC_UA, 'OPC-UA Integration'], |
|||
[InstallMethod.INTEGRATION_PARTICLE, 'Particle'], |
|||
[InstallMethod.INTEGRATION_PUB_SUB, 'Google Pub/Sub'], |
|||
[InstallMethod.INTEGRATION_RABBITMQ, 'RabbitMQ'], |
|||
[InstallMethod.INTEGRATION_REMOTE, 'Remote Integration'], |
|||
[InstallMethod.INTEGRATION_SIGFOX, 'Sigfox'], |
|||
[InstallMethod.INTEGRATION_TCP, 'TCP Integration'], |
|||
[InstallMethod.INTEGRATION_THINGPARK, 'ThingPark Wireless'], |
|||
[InstallMethod.INTEGRATION_THINGPARK_ENTERPRISE, 'ThingPark Enterprise'], |
|||
[InstallMethod.INTEGRATION_TTI, 'The Things Industries'], |
|||
[InstallMethod.INTEGRATION_TTN, 'The Things Stack'], |
|||
[InstallMethod.INTEGRATION_TUYA, 'Tuya'], |
|||
[InstallMethod.INTEGRATION_UDP, 'UDP Integration'] |
|||
] |
|||
); |
|||
``` |
|||
|
|||
- [ ] **Step 2: Verify label count matches enum count** |
|||
|
|||
Run: |
|||
```bash |
|||
node -e " |
|||
const src = require('fs').readFileSync('/home/ashvayka/git/ce/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts', 'utf8'); |
|||
const enumMatches = (src.match(/^ [A-Z][A-Z_]* = '/gm) || []).length; |
|||
const labelMatches = (src.match(/\[InstallMethod\./g) || []).length; |
|||
console.log('enum:', enumMatches, 'labels:', labelMatches); |
|||
process.exit(enumMatches === 50 && labelMatches === 50 ? 0 : 1); |
|||
" |
|||
``` |
|||
Expected: `enum: 50 labels: 50` and exit code 0. |
|||
|
|||
- [ ] **Step 3: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts |
|||
git commit -m "feat(iot-hub): add human-readable labels for all 50 install methods" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Task 3: Add `peOnlyInstallMethods` set |
|||
|
|||
**Files:** |
|||
- Modify: `ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts` |
|||
|
|||
- [ ] **Step 1: Append the PE-only set after `installMethodLabels`** |
|||
|
|||
After the `installMethodLabels` declaration (and the closing `);`), insert a new exported set. Use the actual enum members so a typo causes a compile error: |
|||
|
|||
```typescript |
|||
|
|||
export const peOnlyInstallMethods: ReadonlySet<string> = new Set<string>([ |
|||
InstallMethod.INTEGRATION_APACHE_PULSAR, |
|||
InstallMethod.INTEGRATION_AWS_IOT, |
|||
InstallMethod.INTEGRATION_AWS_KINESIS, |
|||
InstallMethod.INTEGRATION_AWS_SQS, |
|||
InstallMethod.INTEGRATION_AZURE_EVENT_HUB, |
|||
InstallMethod.INTEGRATION_AZURE_IOT_HUB, |
|||
InstallMethod.INTEGRATION_AZURE_SERVICE_BUS, |
|||
InstallMethod.INTEGRATION_CHIRPSTACK, |
|||
InstallMethod.INTEGRATION_COAP, |
|||
InstallMethod.INTEGRATION_CUSTOM, |
|||
InstallMethod.INTEGRATION_HTTP, |
|||
InstallMethod.INTEGRATION_IOT_CREATORS, |
|||
InstallMethod.INTEGRATION_KAFKA, |
|||
InstallMethod.INTEGRATION_KPN_THINGS, |
|||
InstallMethod.INTEGRATION_LORIOT, |
|||
InstallMethod.INTEGRATION_MQTT, |
|||
InstallMethod.INTEGRATION_OPC_UA, |
|||
InstallMethod.INTEGRATION_PARTICLE, |
|||
InstallMethod.INTEGRATION_PUB_SUB, |
|||
InstallMethod.INTEGRATION_RABBITMQ, |
|||
InstallMethod.INTEGRATION_REMOTE, |
|||
InstallMethod.INTEGRATION_SIGFOX, |
|||
InstallMethod.INTEGRATION_TCP, |
|||
InstallMethod.INTEGRATION_THINGPARK, |
|||
InstallMethod.INTEGRATION_THINGPARK_ENTERPRISE, |
|||
InstallMethod.INTEGRATION_TTI, |
|||
InstallMethod.INTEGRATION_TTN, |
|||
InstallMethod.INTEGRATION_TUYA, |
|||
InstallMethod.INTEGRATION_UDP |
|||
]); |
|||
``` |
|||
|
|||
Note: this set has 29 entries (every `INTEGRATION_*`). `CHIRPSTACK` (no `INTEGRATION_` prefix) is NOT in this set — it's the CE-compatible LoRaWAN install method. |
|||
|
|||
- [ ] **Step 2: Verify TypeScript compiles** |
|||
|
|||
Run: |
|||
```bash |
|||
cd /home/ashvayka/git/ce/ui-ngx && npx tsc --noEmit -p tsconfig.app.json 2>&1 | tail -10 |
|||
``` |
|||
Expected: no errors mentioning `device-package.models.ts`. |
|||
|
|||
- [ ] **Step 3: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts |
|||
git commit -m "feat(iot-hub): add peOnlyInstallMethods set covering 29 PE-only integrations" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Task 4: Add `peOnlySelected` state to install dialog component |
|||
|
|||
**Files:** |
|||
- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts` |
|||
|
|||
- [ ] **Step 1: Import `peOnlyInstallMethods`** |
|||
|
|||
Locate the existing import block (lines 36–47) that destructures from `@shared/models/iot-hub/device-package.models`: |
|||
|
|||
```typescript |
|||
import { |
|||
installMethodLabels as INSTALL_METHOD_LABELS, |
|||
DeviceInstallStep, |
|||
DevicePackageInfo, |
|||
ENTITY_STEP_TYPES, |
|||
EntityStepOutput, |
|||
EntityStepProgress, |
|||
FormFieldDefinition, |
|||
FormFieldType, |
|||
InstallStepType, |
|||
stepTypeAliasMap |
|||
} from '@shared/models/iot-hub/device-package.models'; |
|||
``` |
|||
|
|||
Replace the destructure list to add `peOnlyInstallMethods`: |
|||
|
|||
```typescript |
|||
import { |
|||
installMethodLabels as INSTALL_METHOD_LABELS, |
|||
peOnlyInstallMethods, |
|||
DeviceInstallStep, |
|||
DevicePackageInfo, |
|||
ENTITY_STEP_TYPES, |
|||
EntityStepOutput, |
|||
EntityStepProgress, |
|||
FormFieldDefinition, |
|||
FormFieldType, |
|||
InstallStepType, |
|||
stepTypeAliasMap |
|||
} from '@shared/models/iot-hub/device-package.models'; |
|||
``` |
|||
|
|||
- [ ] **Step 2: Add `peOnlySelected` view-state flag and getter** |
|||
|
|||
Locate the connectivity-state block in the class (around lines 92–96): |
|||
|
|||
```typescript |
|||
// Connectivity |
|||
showConnectivitySelector = false; |
|||
availableInstallMethods: string[] = []; |
|||
selectedInstallMethod: string | null = null; |
|||
installMethodLabels = INSTALL_METHOD_LABELS; |
|||
``` |
|||
|
|||
Replace with: |
|||
|
|||
```typescript |
|||
// Connectivity |
|||
showConnectivitySelector = false; |
|||
showPeOnlyPanel = false; |
|||
availableInstallMethods: string[] = []; |
|||
selectedInstallMethod: string | null = null; |
|||
installMethodLabels = INSTALL_METHOD_LABELS; |
|||
|
|||
get isSelectedPeOnly(): boolean { |
|||
return this.selectedInstallMethod !== null && peOnlyInstallMethods.has(this.selectedInstallMethod); |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 3: Route auto-selected single install method to PE-only panel when applicable** |
|||
|
|||
Locate the `ngOnInit` branching block (around lines 172–178): |
|||
|
|||
```typescript |
|||
} else if (this.availableInstallMethods.length === 1) { |
|||
this.selectedInstallMethod = this.availableInstallMethods[0]; |
|||
this.showConnectivitySelector = false; |
|||
this.startWizard(); |
|||
} else { |
|||
this.showConnectivitySelector = true; |
|||
} |
|||
``` |
|||
|
|||
Replace with: |
|||
|
|||
```typescript |
|||
} else if (this.availableInstallMethods.length === 1) { |
|||
this.selectedInstallMethod = this.availableInstallMethods[0]; |
|||
this.showConnectivitySelector = false; |
|||
if (this.isSelectedPeOnly) { |
|||
this.showPeOnlyPanel = true; |
|||
} else { |
|||
this.startWizard(); |
|||
} |
|||
} else { |
|||
this.showConnectivitySelector = true; |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 4: Route `confirmConnectivity()` to PE-only panel when user picks a PE-only method** |
|||
|
|||
Locate `confirmConnectivity()` at line 200: |
|||
|
|||
```typescript |
|||
confirmConnectivity(): void { |
|||
if (!this.selectedInstallMethod) { |
|||
return; |
|||
} |
|||
this.startWizard(); |
|||
} |
|||
``` |
|||
|
|||
Replace with: |
|||
|
|||
```typescript |
|||
confirmConnectivity(): void { |
|||
if (!this.selectedInstallMethod) { |
|||
return; |
|||
} |
|||
if (this.isSelectedPeOnly) { |
|||
this.showPeOnlyPanel = true; |
|||
this.cdr.detectChanges(); |
|||
return; |
|||
} |
|||
this.startWizard(); |
|||
} |
|||
``` |
|||
|
|||
The connectivity selector branch is already hidden once `showPeOnlyPanel` flips true — see the template change in Task 5 — so no separate `showConnectivitySelector` flip is needed. |
|||
|
|||
- [ ] **Step 5: Verify build** |
|||
|
|||
Run: |
|||
```bash |
|||
cd /home/ashvayka/git/ce/ui-ngx && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E 'device-install-dialog|device-package' | head -10 |
|||
``` |
|||
Expected: no output (file compiles). |
|||
|
|||
- [ ] **Step 6: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts |
|||
git commit -m "feat(iot-hub): gate PE-only install methods with dedicated panel state" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Task 5: Render the PE-only panel in the install dialog template |
|||
|
|||
**Files:** |
|||
- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html` |
|||
|
|||
- [ ] **Step 1: Insert the PE-only panel after the connectivity selector block** |
|||
|
|||
Locate the connectivity selector block (lines 213–238 — starts with `@if (!wizardStarted) {` and contains `<!-- Connectivity selector -->`). The structure is: |
|||
|
|||
```html |
|||
@if (!wizardStarted) { |
|||
<!-- Connectivity selector --> |
|||
<div mat-dialog-content> |
|||
... |
|||
</div> |
|||
<mat-dialog-actions align="end"> |
|||
... |
|||
</mat-dialog-actions> |
|||
|
|||
} @else if (reviewMode) { |
|||
``` |
|||
|
|||
The `@if (!wizardStarted)` branch must be tightened so the connectivity selector only renders when `!showPeOnlyPanel`, and a new branch must render the PE-only panel. |
|||
|
|||
Replace the `@if (!wizardStarted) { ... }` outer block (the entire connectivity-selector branch including the `<!-- Connectivity selector -->` content and the closing `</mat-dialog-actions>`) with: |
|||
|
|||
```html |
|||
@if (!wizardStarted && !showPeOnlyPanel) { |
|||
<!-- Connectivity selector --> |
|||
<div mat-dialog-content> |
|||
<div class="tb-device-install-connectivity flex flex-col gap-3"> |
|||
<p>{{ 'iot-hub.device-install-select-connectivity' | translate }}</p> |
|||
<div class="flex flex-wrap gap-2"> |
|||
@for (ct of availableInstallMethods; track ct) { |
|||
<button mat-stroked-button |
|||
class="tb-connectivity-button" |
|||
[class.selected]="selectedInstallMethod === ct" |
|||
(click)="selectConnectivity(ct)"> |
|||
{{ installMethodLabels.get(ct) || ct }} |
|||
</button> |
|||
} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<mat-dialog-actions align="end"> |
|||
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button> |
|||
<button mat-stroked-button color="primary" |
|||
[disabled]="!selectedInstallMethod" |
|||
(click)="confirmConnectivity()"> |
|||
{{ 'action.next' | translate }} |
|||
</button> |
|||
</mat-dialog-actions> |
|||
|
|||
} @else if (showPeOnlyPanel) { |
|||
<!-- PE-only gate --> |
|||
<div mat-dialog-content> |
|||
<div class="tb-device-install-pe-only flex flex-col items-center gap-3 py-6 text-center"> |
|||
<mat-icon class="tb-device-install-pe-only-icon">workspace_premium</mat-icon> |
|||
<h3 class="tb-device-install-pe-only-title"> |
|||
{{ 'iot-hub.device-install-pe-only-title' | translate }} |
|||
</h3> |
|||
<p class="tb-device-install-pe-only-message"> |
|||
{{ 'iot-hub.device-install-pe-only-message' | translate:{ method: installMethodLabels.get(selectedInstallMethod) || selectedInstallMethod } }} |
|||
</p> |
|||
<a mat-stroked-button color="primary" |
|||
href="https://thingsboard.io/products/thingsboard-pe/" |
|||
target="_blank" rel="noopener"> |
|||
{{ 'iot-hub.device-install-pe-only-learn-more' | translate }} |
|||
</a> |
|||
</div> |
|||
</div> |
|||
<mat-dialog-actions align="end"> |
|||
<button mat-button (click)="cancel()">{{ 'action.close' | translate }}</button> |
|||
</mat-dialog-actions> |
|||
|
|||
} @else if (reviewMode) { |
|||
``` |
|||
|
|||
(The `@else if (reviewMode) {` line was already there — just leave it as the start of the next branch.) |
|||
|
|||
- [ ] **Step 2: Verify Angular template compiles** |
|||
|
|||
Run: |
|||
```bash |
|||
cd /home/ashvayka/git/ce/ui-ngx && npx ng build --configuration=production 2>&1 | tail -8 |
|||
``` |
|||
Expected: ends with `Application bundle generation complete.` and no template errors. |
|||
|
|||
- [ ] **Step 3: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html |
|||
git commit -m "feat(iot-hub): render PE-only panel when user selects a PE-only install method" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Task 6: Add translation keys for the PE-only panel |
|||
|
|||
**Files:** |
|||
- Modify: `ui-ngx/src/assets/locale/locale.constant-en_US.json` |
|||
|
|||
- [ ] **Step 1: Locate the `iot-hub` section's `device-install-*` keys** |
|||
|
|||
Run: |
|||
```bash |
|||
grep -n 'device-install-select-connectivity\|device-install-title' /home/ashvayka/git/ce/ui-ngx/src/assets/locale/locale.constant-en_US.json |
|||
``` |
|||
This identifies the lines where the existing `device-install-*` keys live so the new keys can be added alongside them. |
|||
|
|||
- [ ] **Step 2: Add the three new keys** |
|||
|
|||
Add these three key/value pairs immediately after the existing `device-install-select-connectivity` key, preserving the surrounding JSON structure (commas, indentation): |
|||
|
|||
```json |
|||
"device-install-pe-only-title": "ThingsBoard PE required", |
|||
"device-install-pe-only-message": "The {{method}} install method requires ThingsBoard Professional Edition. Upgrade your installation to use this device package.", |
|||
"device-install-pe-only-learn-more": "Learn about ThingsBoard PE", |
|||
``` |
|||
|
|||
`{{method}}` is the MessageFormat parameter passed from the template (`installMethodLabels.get(selectedInstallMethod)`). |
|||
|
|||
- [ ] **Step 3: Verify JSON is valid** |
|||
|
|||
Run: |
|||
```bash |
|||
node -e "JSON.parse(require('fs').readFileSync('/home/ashvayka/git/ce/ui-ngx/src/assets/locale/locale.constant-en_US.json','utf8')); console.log('OK');" |
|||
``` |
|||
Expected: `OK`. |
|||
|
|||
- [ ] **Step 4: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/assets/locale/locale.constant-en_US.json |
|||
git commit -m "feat(iot-hub): add translation keys for PE-only install panel" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Task 7: Manual smoke verification |
|||
|
|||
**Files:** none (verification only) |
|||
|
|||
- [ ] **Step 1: Production build sanity check** |
|||
|
|||
Run: |
|||
```bash |
|||
cd /home/ashvayka/git/ce/ui-ngx && npx ng build --configuration=production 2>&1 | tail -3 |
|||
``` |
|||
Expected: ends with `Application bundle generation complete.` |
|||
|
|||
- [ ] **Step 2: Verify CE enum is a strict superset of the previous 12-value enum** |
|||
|
|||
Run: |
|||
```bash |
|||
for c in DIRECT_HTTP DIRECT_MQTT DIRECT_COAP DIRECT_LWM2M DIRECT_SNMP \ |
|||
GATEWAY_MQTT GATEWAY_MODBUS GATEWAY_OPCUA \ |
|||
CHIRPSTACK INTEGRATION_CHIRPSTACK INTEGRATION_TTN INTEGRATION_LORIOT; do |
|||
grep -q "${c} = '${c}'" /home/ashvayka/git/ce/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts \ |
|||
&& echo "OK: $c" || echo "MISSING: $c" |
|||
done |
|||
``` |
|||
Expected: 12 lines starting with `OK:` and zero `MISSING:` lines. |
|||
|
|||
- [ ] **Step 3: Verify all 50 marketplace constants are present** |
|||
|
|||
Run: |
|||
```bash |
|||
for c in DIRECT_HTTP DIRECT_MQTT DIRECT_COAP DIRECT_LWM2M DIRECT_SNMP \ |
|||
GATEWAY_MQTT GATEWAY_MODBUS GATEWAY_OPCUA \ |
|||
GATEWAY_BACNET GATEWAY_BLE GATEWAY_CAN GATEWAY_FTP GATEWAY_KNX \ |
|||
GATEWAY_OCPP GATEWAY_ODBC GATEWAY_REQUEST GATEWAY_REST \ |
|||
GATEWAY_SNMP GATEWAY_SOCKET GATEWAY_XMPP \ |
|||
CHIRPSTACK \ |
|||
INTEGRATION_APACHE_PULSAR INTEGRATION_AWS_IOT INTEGRATION_AWS_KINESIS \ |
|||
INTEGRATION_AWS_SQS INTEGRATION_AZURE_EVENT_HUB INTEGRATION_AZURE_IOT_HUB \ |
|||
INTEGRATION_AZURE_SERVICE_BUS INTEGRATION_CHIRPSTACK INTEGRATION_COAP \ |
|||
INTEGRATION_CUSTOM INTEGRATION_HTTP INTEGRATION_IOT_CREATORS \ |
|||
INTEGRATION_KAFKA INTEGRATION_KPN_THINGS INTEGRATION_LORIOT \ |
|||
INTEGRATION_MQTT INTEGRATION_OPC_UA INTEGRATION_PARTICLE \ |
|||
INTEGRATION_PUB_SUB INTEGRATION_RABBITMQ INTEGRATION_REMOTE \ |
|||
INTEGRATION_SIGFOX INTEGRATION_TCP INTEGRATION_THINGPARK \ |
|||
INTEGRATION_THINGPARK_ENTERPRISE INTEGRATION_TTI INTEGRATION_TTN \ |
|||
INTEGRATION_TUYA INTEGRATION_UDP; do |
|||
grep -q "${c} = '${c}'" /home/ashvayka/git/ce/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts \ |
|||
&& grep -q "InstallMethod.${c}, '" /home/ashvayka/git/ce/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts \ |
|||
|| echo "INCOMPLETE: $c" |
|||
done |
|||
``` |
|||
Expected: no output (every constant has both an enum entry and a label). |
|||
|
|||
- [ ] **Step 4: Browser smoke test (manual)** |
|||
|
|||
1. Start dev server: `cd /home/ashvayka/git/ce/ui-ngx && npm start` |
|||
2. Log in as a TENANT_ADMIN. Open IoT Hub → Browse. |
|||
3. Pick any device package that lists multiple install methods. Open install dialog. |
|||
4. **Direct/Gateway/CHIRPSTACK methods:** label renders correctly (no raw constant), Next button starts the wizard normally — no regression. |
|||
5. **PE-only method (e.g., `INTEGRATION_AWS_IOT` if the package supports it; otherwise temporarily edit any local test package's `device-info.json` to add a PE-only method):** Next button shows the PE-only panel, the title says "ThingsBoard PE required", the body interpolates the method label (e.g., "The AWS IoT install method requires…"), the "Learn about ThingsBoard PE" link opens `thingsboard.io/products/thingsboard-pe/` in a new tab, the Close button dismisses the dialog. |
|||
6. **Auto-selected single PE-only method:** if a package lists only one method and it's PE-only, the dialog should open directly on the PE-only panel (no connectivity selector). |
|||
|
|||
If any check fails, mark this task incomplete and address before merging. |
|||
|
|||
--- |
|||
|
|||
## Out of scope |
|||
|
|||
- **No backend allow-list to update.** CE's `DeviceInstalledItemDescriptor.selectedInstallMethod` is a free-form `String` field; the marketplace validator at `ItemDataServiceImpl.VALID_INSTALL_METHODS` is the single source of truth. |
|||
- **No new install behavior for the 12 new gateway connectors.** The existing `GATEWAY_CONNECTOR` step (`device-install-dialog.component.ts:761`) writes any connector config object to the gateway's `active_connectors` shared attribute regardless of connector type. Connector-specific schemas (BACnet, BLE, KNX, etc.) are creator concerns, not wizard concerns — the creator's connector JSON config is opaque to the wizard. |
|||
- **No connector-specific `${gateway.*}` variable additions.** Existing gateway outputs (`${gateway.id}`, `${gateway.name}`, `${gateway.token}`, `${gateway.dockerComposeUrl}`) are sufficient. Per-connector values like ports or hostnames are already supplied via `SHOW_FORM` fields and resolved through the standard variable mechanism. |
|||
- **No CE PE→install conversion.** PE integrations stay PE-only; CE shows the gate. |
|||
- **No automated unit test parity.** `ui-ngx` has no spec runner configured (no `*.spec.ts` files outside `node_modules`). The bash verifications in Task 7 act as the parity check. |
|||
@ -1,974 +0,0 @@ |
|||
# IoT Hub `${item-link:uuid}` Markdown Component — 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:** Add a `${item-link:<uuid>}` markdown placeholder that renders an IoT Hub marketplace item card (thumbnail + name + creator) inline in readmes and install instructions, opening the linked item in a new tab. |
|||
|
|||
**Architecture:** A pure-string utility rewrites `${item-link:<uuid>}` to a `<tb-iot-hub-item-link-card itemId="...">` Angular component tag before the markdown reaches `tb-markdown`. The component is declared in a small `IotHubItemLinkModule` that each `tb-markdown` instance receives as `additionalCompileModules`. The component owns its own fetch (via `IotHubApiService.getPublishedVersion`), state (`loading` / `loaded` / `unavailable`), and click target (`/iot-hub/{itemId}` with `target="_blank"`). |
|||
|
|||
**Tech Stack:** Angular 20 (TypeScript, Material), `tb-markdown` (NgModule-based dynamic compilation), `IotHubApiService` (existing), Tailwind for utility classes, component-scoped SCSS. |
|||
|
|||
**Spec:** `docs/superpowers/specs/2026-05-06-iot-hub-item-link.md` |
|||
|
|||
**File structure:** |
|||
|
|||
New files: |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts` — `replaceItemLinkPlaceholders` + regex |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.ts` |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.html` |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.scss` |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module.ts` |
|||
|
|||
Modified files: |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` — import `IotHubItemLinkModule` |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts` — call utility in `loadReadme()`, expose compile modules |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html` — bind `[additionalCompileModules]` on readme `<tb-markdown>` |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts` — add `^item-link:(uuid)$` branch in `resolveVariables()`, expose compile modules |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html` — bind `[additionalCompileModules]` |
|||
- `ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts` — pre-process `details`, expose compile modules |
|||
- `ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html` — bind `[additionalCompileModules]` |
|||
- `ui-ngx/src/assets/locale/locale.constant-en_US.json` — add `iot-hub.item-link-unavailable` |
|||
|
|||
--- |
|||
|
|||
## Task 1: Create placeholder utility |
|||
|
|||
**Files:** |
|||
- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts` |
|||
|
|||
- [ ] **Step 1: Create the utility file** |
|||
|
|||
Create `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts`: |
|||
|
|||
```typescript |
|||
/// |
|||
/// 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. |
|||
/// |
|||
|
|||
export const ITEM_LINK_PLACEHOLDER_REGEX = |
|||
/\$\{item-link:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\}/g; |
|||
|
|||
export const ITEM_LINK_KEY_REGEX = |
|||
/^item-link:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/; |
|||
|
|||
export function itemLinkCardTag(itemId: string): string { |
|||
return `<tb-iot-hub-item-link-card itemId="${itemId}"></tb-iot-hub-item-link-card>`; |
|||
} |
|||
|
|||
export function replaceItemLinkPlaceholders(markdown: string): string { |
|||
if (!markdown) { |
|||
return markdown; |
|||
} |
|||
return markdown.replace(ITEM_LINK_PLACEHOLDER_REGEX, (_match, uuid) => itemLinkCardTag(uuid)); |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 2: Sanity-check the regex** |
|||
|
|||
Write a temporary script `/tmp/check-item-link-regex.js` with this exact content: |
|||
|
|||
```javascript |
|||
const re = /\$\{item-link:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\}/g; |
|||
const cases = [ |
|||
['${item-link:11111111-2222-3333-4444-555555555555}', 'valid'], |
|||
['${item-link:not-a-uuid}', 'invalid'], |
|||
['Two: ${item-link:11111111-2222-3333-4444-555555555555} and ${item-link:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee}', 'two valid'], |
|||
['```\n${item-link:11111111-2222-3333-4444-555555555555}\n```', 'inside code fence (still replaced — documented behavior)'] |
|||
]; |
|||
for (const [input, label] of cases) { |
|||
console.log(label + ':', input.replace(re, (_m, u) => '<TAG itemId=' + u + '>')); |
|||
} |
|||
``` |
|||
|
|||
Run it: |
|||
```bash |
|||
node /tmp/check-item-link-regex.js |
|||
``` |
|||
|
|||
Expected output: |
|||
``` |
|||
valid: <TAG itemId=11111111-2222-3333-4444-555555555555> |
|||
invalid: ${item-link:not-a-uuid} |
|||
two valid: Two: <TAG itemId=11111111-2222-3333-4444-555555555555> and <TAG itemId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee> |
|||
inside code fence (still replaced — documented behavior): ``` |
|||
<TAG itemId=11111111-2222-3333-4444-555555555555> |
|||
``` |
|||
``` |
|||
|
|||
Then delete the script: `rm /tmp/check-item-link-regex.js`. |
|||
|
|||
- [ ] **Step 3: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts |
|||
git commit -m "feat(iot-hub): add item-link placeholder utility for markdown" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Task 2: Create item-link card component (skeleton + loaded states, image-thumbnail items) |
|||
|
|||
**Files:** |
|||
- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.ts` |
|||
- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.html` |
|||
- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.scss` |
|||
|
|||
- [ ] **Step 1: Create the directory** |
|||
|
|||
```bash |
|||
mkdir -p ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card |
|||
``` |
|||
|
|||
- [ ] **Step 2: Create `iot-hub-item-link-card.component.ts`** |
|||
|
|||
```typescript |
|||
/// |
|||
/// 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, Input, OnInit } from '@angular/core'; |
|||
import { IotHubApiService } from '@core/http/iot-hub-api.service'; |
|||
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; |
|||
import { ItemType } from '@shared/models/iot-hub/iot-hub-item.models'; |
|||
|
|||
type CardState = 'loading' | 'loaded' | 'unavailable'; |
|||
|
|||
const COMPACT_TYPES: ReadonlySet<ItemType> = new Set([ |
|||
ItemType.CALCULATED_FIELD, |
|||
ItemType.ALARM_RULE, |
|||
ItemType.RULE_CHAIN |
|||
]); |
|||
|
|||
@Component({ |
|||
selector: 'tb-iot-hub-item-link-card', |
|||
standalone: false, |
|||
templateUrl: './iot-hub-item-link-card.component.html', |
|||
styleUrls: ['./iot-hub-item-link-card.component.scss'] |
|||
}) |
|||
export class TbIotHubItemLinkCardComponent implements OnInit { |
|||
|
|||
@Input() itemId!: string; |
|||
|
|||
state: CardState = 'loading'; |
|||
item: MpItemVersionView | null = null; |
|||
|
|||
constructor(private iotHubApiService: IotHubApiService) {} |
|||
|
|||
ngOnInit(): void { |
|||
if (!this.itemId) { |
|||
this.state = 'unavailable'; |
|||
return; |
|||
} |
|||
this.iotHubApiService |
|||
.getPublishedVersion(this.itemId, { ignoreErrors: true, ignoreLoading: true }) |
|||
.subscribe({ |
|||
next: item => { |
|||
this.item = item; |
|||
this.state = item ? 'loaded' : 'unavailable'; |
|||
}, |
|||
error: () => { |
|||
this.state = 'unavailable'; |
|||
} |
|||
}); |
|||
} |
|||
|
|||
isCompact(): boolean { |
|||
return !!this.item && COMPACT_TYPES.has(this.item.type); |
|||
} |
|||
|
|||
getImageUrl(): string | null { |
|||
return this.item?.image |
|||
? this.iotHubApiService.resolveResourceUrl(this.item.image) |
|||
: null; |
|||
} |
|||
|
|||
getCompactIcon(): string { |
|||
if (!this.item) { |
|||
return 'category'; |
|||
} |
|||
if (this.item.icon) { |
|||
return this.item.icon; |
|||
} |
|||
switch (this.item.type) { |
|||
case ItemType.CALCULATED_FIELD: return 'functions'; |
|||
case ItemType.ALARM_RULE: return 'notification_important'; |
|||
case ItemType.RULE_CHAIN: return 'account_tree'; |
|||
default: return 'category'; |
|||
} |
|||
} |
|||
|
|||
getTypeIcon(): string { |
|||
if (!this.item) { |
|||
return 'category'; |
|||
} |
|||
switch (this.item.type) { |
|||
case ItemType.WIDGET: return 'widgets'; |
|||
case ItemType.DASHBOARD: return 'dashboard'; |
|||
case ItemType.SOLUTION_TEMPLATE: return 'integration_instructions'; |
|||
case ItemType.CALCULATED_FIELD: return 'functions'; |
|||
case ItemType.ALARM_RULE: return 'notification_important'; |
|||
case ItemType.RULE_CHAIN: return 'account_tree'; |
|||
case ItemType.DEVICE: return 'memory'; |
|||
default: return 'category'; |
|||
} |
|||
} |
|||
|
|||
getCompactColor(): string { |
|||
return this.item?.color || '#048ad3'; |
|||
} |
|||
|
|||
getHref(): string { |
|||
return `/iot-hub/${this.itemId}`; |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 3: Create `iot-hub-item-link-card.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. |
|||
|
|||
--> |
|||
@switch (state) { |
|||
@case ('loading') { |
|||
<div class="tb-iot-hub-item-link-card tb-iot-hub-item-link-card--skeleton" aria-busy="true"> |
|||
<div class="tb-iot-hub-item-link-thumb tb-iot-hub-item-link-skeleton-block"></div> |
|||
<div class="tb-iot-hub-item-link-text"> |
|||
<span class="tb-iot-hub-item-link-skeleton-line"></span> |
|||
<span class="tb-iot-hub-item-link-skeleton-line tb-iot-hub-item-link-skeleton-line--short"></span> |
|||
</div> |
|||
</div> |
|||
} |
|||
@case ('loaded') { |
|||
<a [href]="getHref()" |
|||
target="_blank" |
|||
rel="noopener noreferrer" |
|||
class="tb-iot-hub-item-link-card"> |
|||
@if (isCompact()) { |
|||
<div class="tb-iot-hub-item-link-thumb tb-iot-hub-item-link-thumb--compact" |
|||
[style.background]="getCompactColor()"> |
|||
<tb-icon class="tb-iot-hub-item-link-thumb-icon">{{ getCompactIcon() }}</tb-icon> |
|||
</div> |
|||
} @else { |
|||
<div class="tb-iot-hub-item-link-thumb"> |
|||
@if (getImageUrl(); as imgUrl) { |
|||
<img [src]="imgUrl" alt=""> |
|||
} @else { |
|||
<mat-icon class="tb-iot-hub-item-link-thumb-fallback">{{ getTypeIcon() }}</mat-icon> |
|||
} |
|||
</div> |
|||
} |
|||
<div class="tb-iot-hub-item-link-text"> |
|||
<span class="tb-iot-hub-item-link-name">{{ item?.name }}</span> |
|||
<span class="tb-iot-hub-item-link-author"> |
|||
<mat-icon>person</mat-icon> |
|||
{{ item?.creatorDisplayName }} |
|||
</span> |
|||
</div> |
|||
</a> |
|||
} |
|||
@case ('unavailable') { |
|||
<div class="tb-iot-hub-item-link-card tb-iot-hub-item-link-card--unavailable"> |
|||
<div class="tb-iot-hub-item-link-thumb tb-iot-hub-item-link-thumb--unavailable"> |
|||
<mat-icon>link_off</mat-icon> |
|||
</div> |
|||
<div class="tb-iot-hub-item-link-text"> |
|||
<span class="tb-iot-hub-item-link-name">{{ 'iot-hub.item-link-unavailable' | translate }}</span> |
|||
</div> |
|||
</div> |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 4: Create `iot-hub-item-link-card.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. |
|||
*/ |
|||
|
|||
:host { |
|||
display: block; |
|||
margin: 12px 0; |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-card { |
|||
display: inline-flex; |
|||
align-items: center; |
|||
gap: 12px; |
|||
width: 320px; |
|||
max-width: 100%; |
|||
padding: 8px 12px; |
|||
border: 1px solid rgba(0, 0, 0, 0.08); |
|||
border-radius: 8px; |
|||
background: #fff; |
|||
text-decoration: none; |
|||
color: inherit; |
|||
transition: background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease; |
|||
|
|||
&:hover:not(.tb-iot-hub-item-link-card--unavailable):not(.tb-iot-hub-item-link-card--skeleton) { |
|||
background: rgba(4, 138, 211, 0.04); |
|||
border-color: rgba(4, 138, 211, 0.32); |
|||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); |
|||
} |
|||
|
|||
&--unavailable { |
|||
opacity: 0.6; |
|||
cursor: default; |
|||
} |
|||
|
|||
&--skeleton { |
|||
cursor: default; |
|||
} |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-thumb { |
|||
flex: 0 0 48px; |
|||
width: 48px; |
|||
height: 48px; |
|||
border-radius: 6px; |
|||
background: #f4f6f8; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
overflow: hidden; |
|||
|
|||
img { |
|||
width: 100%; |
|||
height: 100%; |
|||
object-fit: cover; |
|||
display: block; |
|||
} |
|||
|
|||
&--compact { |
|||
color: #fff; |
|||
} |
|||
|
|||
&--unavailable { |
|||
color: rgba(0, 0, 0, 0.45); |
|||
} |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-thumb-icon { |
|||
font-size: 26px; |
|||
width: 26px; |
|||
height: 26px; |
|||
line-height: 26px; |
|||
color: #fff; |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-thumb-fallback { |
|||
font-size: 26px; |
|||
width: 26px; |
|||
height: 26px; |
|||
color: rgba(0, 0, 0, 0.45); |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-text { |
|||
display: flex; |
|||
flex-direction: column; |
|||
min-width: 0; |
|||
gap: 2px; |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-name { |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
line-height: 1.3; |
|||
white-space: nowrap; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-author { |
|||
display: inline-flex; |
|||
align-items: center; |
|||
gap: 4px; |
|||
font-size: 12px; |
|||
color: rgba(0, 0, 0, 0.6); |
|||
line-height: 1.3; |
|||
|
|||
mat-icon { |
|||
font-size: 14px; |
|||
width: 14px; |
|||
height: 14px; |
|||
} |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-skeleton-block, |
|||
.tb-iot-hub-item-link-skeleton-line { |
|||
background: linear-gradient( |
|||
90deg, |
|||
rgba(0, 0, 0, 0.06) 0%, |
|||
rgba(0, 0, 0, 0.12) 50%, |
|||
rgba(0, 0, 0, 0.06) 100% |
|||
); |
|||
background-size: 200% 100%; |
|||
animation: tb-iot-hub-item-link-shimmer 1.4s ease-in-out infinite; |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
.tb-iot-hub-item-link-skeleton-line { |
|||
display: block; |
|||
height: 12px; |
|||
width: 180px; |
|||
margin: 4px 0; |
|||
|
|||
&--short { |
|||
width: 96px; |
|||
} |
|||
} |
|||
|
|||
@keyframes tb-iot-hub-item-link-shimmer { |
|||
0% { background-position: 200% 0; } |
|||
100% { background-position: -200% 0; } |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 5: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card |
|||
git commit -m "feat(iot-hub): add TbIotHubItemLinkCardComponent for markdown item links" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Task 3: Create the wrapper module and register it |
|||
|
|||
**Files:** |
|||
- Create: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module.ts` |
|||
- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` |
|||
|
|||
- [ ] **Step 1: Create `iot-hub-item-link.module.ts`** |
|||
|
|||
```typescript |
|||
/// |
|||
/// 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 { NgModule } from '@angular/core'; |
|||
import { CommonModule } from '@angular/common'; |
|||
import { SharedModule } from '@shared/shared.module'; |
|||
import { TbIotHubItemLinkCardComponent } from './iot-hub-item-link-card.component'; |
|||
|
|||
@NgModule({ |
|||
declarations: [TbIotHubItemLinkCardComponent], |
|||
imports: [CommonModule, SharedModule], |
|||
exports: [TbIotHubItemLinkCardComponent] |
|||
}) |
|||
export class IotHubItemLinkModule {} |
|||
``` |
|||
|
|||
- [ ] **Step 2: Register module in `IotHubComponentsModule`** |
|||
|
|||
Modify `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` — add the import and add `IotHubItemLinkModule` to the `imports` array. |
|||
|
|||
After the existing imports near line 32, add: |
|||
|
|||
```typescript |
|||
import { IotHubItemLinkModule } from './iot-hub-item-link-card/iot-hub-item-link.module'; |
|||
``` |
|||
|
|||
Then update the `imports` array of the `@NgModule` decorator from: |
|||
|
|||
```typescript |
|||
imports: [ |
|||
CommonModule, |
|||
SharedModule |
|||
], |
|||
``` |
|||
|
|||
to: |
|||
|
|||
```typescript |
|||
imports: [ |
|||
CommonModule, |
|||
SharedModule, |
|||
IotHubItemLinkModule |
|||
], |
|||
``` |
|||
|
|||
- [ ] **Step 3: Build to verify the module wires up** |
|||
|
|||
```bash |
|||
cd ui-ngx && npx ng build --configuration=development 2>&1 | tail -40 |
|||
``` |
|||
|
|||
Expected: build succeeds (warnings allowed; no errors mentioning `TbIotHubItemLinkCardComponent` or `IotHubItemLinkModule`). |
|||
|
|||
- [ ] **Step 4: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module.ts \ |
|||
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts |
|||
git commit -m "feat(iot-hub): wrap item-link card in IotHubItemLinkModule" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Task 4: Add translation key |
|||
|
|||
**Files:** |
|||
- Modify: `ui-ngx/src/assets/locale/locale.constant-en_US.json` |
|||
|
|||
- [ ] **Step 1: Add the translation key** |
|||
|
|||
Open `ui-ngx/src/assets/locale/locale.constant-en_US.json`. Locate the `"iot-hub": { ... }` block that begins around line 3701 (the one that opens with `"iot-hub": "IoT Hub",`). Add a new entry next to similar one-liner keys (e.g., right after `"installed-from-iot-hub": "Installed from IoT Hub",` near line 3719): |
|||
|
|||
```json |
|||
"item-link-unavailable": "Item unavailable", |
|||
``` |
|||
|
|||
Make sure the surrounding commas are correct — the new line ends with a comma, and the line above also ends with a comma. |
|||
|
|||
- [ ] **Step 2: Verify JSON is valid** |
|||
|
|||
```bash |
|||
node -e "JSON.parse(require('fs').readFileSync('ui-ngx/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 item-link-unavailable translation key" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Task 5: Wire into item detail dialog (readme) |
|||
|
|||
**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` |
|||
|
|||
- [ ] **Step 1: Update the .ts file to pre-process readme and expose compile modules** |
|||
|
|||
In `iot-hub-item-detail-dialog.component.ts`: |
|||
|
|||
Add to the imports near the existing `resolveDocLinkPlaceholders` import: |
|||
|
|||
```typescript |
|||
import { replaceItemLinkPlaceholders } from './iot-hub-markdown.utils'; |
|||
import { IotHubItemLinkModule } from './iot-hub-item-link-card/iot-hub-item-link.module'; |
|||
import { Type } from '@angular/core'; |
|||
``` |
|||
|
|||
(`Type` may already be imported via Angular core; merge into the existing `@angular/core` import if so.) |
|||
|
|||
Inside the class, near the other readonly fields (after `readonly ItemType = ItemType;`), add: |
|||
|
|||
```typescript |
|||
readonly itemLinkCompileModules: Type<any>[] = [IotHubItemLinkModule]; |
|||
``` |
|||
|
|||
Update the existing `loadReadme()` method body (currently at line 307-312): |
|||
|
|||
```typescript |
|||
private loadReadme(): void { |
|||
const versionId = this.item.id as string; |
|||
this.iotHubApiService.getVersionReadme(versionId, { ignoreLoading: true }).subscribe( |
|||
content => this.readmeContent = replaceItemLinkPlaceholders( |
|||
this.resolveDocLinks(this.prefixResourceUrls(content || '')) |
|||
) |
|||
); |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 2: Update the template to pass compile modules** |
|||
|
|||
In `iot-hub-item-detail-dialog.component.html` at line 302, change: |
|||
|
|||
```html |
|||
<tb-markdown [data]="readmeContent"></tb-markdown> |
|||
``` |
|||
|
|||
to: |
|||
|
|||
```html |
|||
<tb-markdown [data]="readmeContent" |
|||
[additionalCompileModules]="itemLinkCompileModules"></tb-markdown> |
|||
``` |
|||
|
|||
Leave the changelog `<tb-markdown>` (line 306) unchanged — changelog is out of scope per the spec. |
|||
|
|||
- [ ] **Step 3: Build** |
|||
|
|||
```bash |
|||
cd ui-ngx && npx ng build --configuration=development 2>&1 | tail -20 |
|||
``` |
|||
|
|||
Expected: build succeeds. |
|||
|
|||
- [ ] **Step 4: 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 |
|||
git commit -m "feat(iot-hub): render \${item-link} cards in item readme" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Task 6: Wire into device install dialog (instructions) |
|||
|
|||
**Files:** |
|||
- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts` |
|||
- Modify: `ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html` |
|||
|
|||
- [ ] **Step 1: Add imports and compile-modules field** |
|||
|
|||
In `device-install-dialog.component.ts`, add to the imports: |
|||
|
|||
```typescript |
|||
import { IotHubItemLinkModule } from '../iot-hub-item-link-card/iot-hub-item-link.module'; |
|||
import { ITEM_LINK_KEY_REGEX, itemLinkCardTag } from '../iot-hub-markdown.utils'; |
|||
import { Type } from '@angular/core'; |
|||
``` |
|||
|
|||
(Merge `Type` into the existing `@angular/core` import line.) |
|||
|
|||
Inside the class, alongside other readonly fields, add: |
|||
|
|||
```typescript |
|||
readonly itemLinkCompileModules: Type<any>[] = [IotHubItemLinkModule]; |
|||
``` |
|||
|
|||
- [ ] **Step 2: Add `^item-link:(uuid)$` branch inside `resolveVariables`** |
|||
|
|||
In `resolveVariables()` (currently at line 427-477 in the same file), add a new clause **immediately after** the `gateway.downloadButton` clause (around line 444-446) and before the callout matcher. |
|||
|
|||
Change this section: |
|||
|
|||
```typescript |
|||
// Special action placeholders |
|||
if (key === 'gateway.downloadButton') { |
|||
return '<a href="#" data-action="download-gateway-docker-compose" class="tb-download-btn">⬇ Download docker-compose.yml</a>'; |
|||
} |
|||
// Callout boxes: ${note(...)}, ${warn(...)}, ${error(...)} |
|||
``` |
|||
|
|||
to: |
|||
|
|||
```typescript |
|||
// Special action placeholders |
|||
if (key === 'gateway.downloadButton') { |
|||
return '<a href="#" data-action="download-gateway-docker-compose" class="tb-download-btn">⬇ Download docker-compose.yml</a>'; |
|||
} |
|||
// IoT Hub item link card: ${item-link:<uuid>} |
|||
const itemLinkMatch = key.match(ITEM_LINK_KEY_REGEX); |
|||
if (itemLinkMatch) { |
|||
return itemLinkCardTag(itemLinkMatch[1]); |
|||
} |
|||
// Callout boxes: ${note(...)}, ${warn(...)}, ${error(...)} |
|||
``` |
|||
|
|||
- [ ] **Step 3: Update the template** |
|||
|
|||
In `device-install-dialog.component.html` at line 26-28, change: |
|||
|
|||
```html |
|||
<tb-markdown [data]="ws.markdown" |
|||
(ready)="onMarkdownReady(instructionContainer)"> |
|||
</tb-markdown> |
|||
``` |
|||
|
|||
to: |
|||
|
|||
```html |
|||
<tb-markdown [data]="ws.markdown" |
|||
[additionalCompileModules]="itemLinkCompileModules" |
|||
(ready)="onMarkdownReady(instructionContainer)"> |
|||
</tb-markdown> |
|||
``` |
|||
|
|||
- [ ] **Step 4: Build** |
|||
|
|||
```bash |
|||
cd ui-ngx && npx ng build --configuration=development 2>&1 | tail -20 |
|||
``` |
|||
|
|||
Expected: build succeeds. |
|||
|
|||
- [ ] **Step 5: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts \ |
|||
ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html |
|||
git commit -m "feat(iot-hub): render \${item-link} cards in device install instructions" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Task 7: Wire into solution install dialog (instructions) |
|||
|
|||
**Files:** |
|||
- Modify: `ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts` |
|||
- Modify: `ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html` |
|||
|
|||
- [ ] **Step 1: Update the .ts file** |
|||
|
|||
Replace the contents of `ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts` with: |
|||
|
|||
```typescript |
|||
/// |
|||
/// 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, Type } from '@angular/core'; |
|||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; |
|||
import { Router } from '@angular/router'; |
|||
import { SolutionTemplateInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models'; |
|||
import { |
|||
IotHubItemLinkModule |
|||
} from '@home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module'; |
|||
import { |
|||
replaceItemLinkPlaceholders |
|||
} from '@home/components/iot-hub/iot-hub-markdown.utils'; |
|||
|
|||
export interface SolutionInstallDialogData { |
|||
descriptor: SolutionTemplateInstalledItemDescriptor; |
|||
instructions?: boolean; |
|||
} |
|||
|
|||
@Component({ |
|||
selector: 'tb-solution-install-dialog', |
|||
templateUrl: './solution-install-dialog.component.html', |
|||
styleUrls: ['./solution-install-dialog.component.scss'], |
|||
standalone: false |
|||
}) |
|||
export class SolutionInstallDialogComponent { |
|||
|
|||
details: string; |
|||
dashboardId: string | null; |
|||
instructions: boolean; |
|||
|
|||
readonly itemLinkCompileModules: Type<any>[] = [IotHubItemLinkModule]; |
|||
|
|||
constructor( |
|||
@Inject(MAT_DIALOG_DATA) public data: SolutionInstallDialogData, |
|||
private dialogRef: MatDialogRef<SolutionInstallDialogComponent>, |
|||
private router: Router |
|||
) { |
|||
this.details = replaceItemLinkPlaceholders(data.descriptor.details || ''); |
|||
this.dashboardId = data.descriptor.dashboardId?.id || null; |
|||
this.instructions = !!data.instructions; |
|||
} |
|||
|
|||
gotoMainDashboard(): void { |
|||
if (this.dashboardId) { |
|||
this.dialogRef.close(); |
|||
this.router.navigateByUrl(`/dashboards/${this.dashboardId}`); |
|||
} |
|||
} |
|||
|
|||
close(): void { |
|||
this.dialogRef.close(); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 2: Update the template** |
|||
|
|||
In `solution-install-dialog.component.html` at line 29, change: |
|||
|
|||
```html |
|||
<tb-markdown [data]="details" lineNumbers fallbackToPlainMarkdown></tb-markdown> |
|||
``` |
|||
|
|||
to: |
|||
|
|||
```html |
|||
<tb-markdown [data]="details" |
|||
[additionalCompileModules]="itemLinkCompileModules" |
|||
lineNumbers |
|||
fallbackToPlainMarkdown></tb-markdown> |
|||
``` |
|||
|
|||
- [ ] **Step 3: Build** |
|||
|
|||
```bash |
|||
cd ui-ngx && npx ng build --configuration=development 2>&1 | tail -20 |
|||
``` |
|||
|
|||
Expected: build succeeds. |
|||
|
|||
- [ ] **Step 4: Commit** |
|||
|
|||
```bash |
|||
git add ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts \ |
|||
ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html |
|||
git commit -m "feat(iot-hub): render \${item-link} cards in solution install instructions" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Task 8: Manual visual QA |
|||
|
|||
**No file changes — verify the feature end-to-end in a browser.** |
|||
|
|||
- [ ] **Step 1: Start the dev server** |
|||
|
|||
```bash |
|||
cd ui-ngx && npm start |
|||
``` |
|||
|
|||
Wait for `Compiled successfully` and `Application bundle generation complete`. |
|||
|
|||
Open `http://localhost:4200` in a browser and log in as a tenant administrator. |
|||
|
|||
- [ ] **Step 2: Verify item readme rendering** |
|||
|
|||
In a separate terminal, query the configured IoT Hub for an item ID you can use as a test target: |
|||
|
|||
```bash |
|||
# Use the same baseUrl that your TB instance uses (typically https://iot-hub.thingsboard.io). |
|||
# Pick any published item you can find via the marketplace UI's network requests in DevTools, |
|||
# e.g. by opening the IoT Hub home page and copying an itemId from a response. |
|||
echo "Pick a known itemId from network responses in the IoT Hub UI" |
|||
``` |
|||
|
|||
Then, for the TEST PASS: |
|||
1. Open IoT Hub in the running app, find an item with a non-empty readme (e.g., a Solution Template), open the detail dialog. |
|||
2. In DevTools, intercept the readme response by editing it (Network → block + replay, or temporarily edit `readmeContent` via the Angular DevTools), and inject `${item-link:<knownItemId>}` somewhere in the readme markdown. |
|||
- Easier alternative: temporarily hardcode a test placeholder in `loadReadme()` by appending `'\n\n${item-link:<knownItemId>}\n'` to the fetched content, just for this QA pass. Revert before committing. |
|||
3. Reload the dialog. Expected: |
|||
- Skeleton card appears briefly with shimmer. |
|||
- Card resolves to: 48-px square thumbnail (image or colored compact icon), item name, person icon + creator name. |
|||
- Hover shows light blue tint and subtle shadow. |
|||
- Click opens `/iot-hub/<itemId>` in a **new tab**, which lands on the item type page with the detail dialog open. |
|||
- Middle-click also opens new tab; ctrl-click does the same. |
|||
|
|||
Revert any hardcoded test data before continuing. |
|||
|
|||
- [ ] **Step 3: Verify unavailable state** |
|||
|
|||
Repeat Step 2 but inject a placeholder with a UUID that does not exist (e.g., `${item-link:00000000-0000-0000-0000-000000000000}`). Expected: |
|||
- Card briefly shows skeleton. |
|||
- Resolves to disabled card: dim opacity, `link_off` icon in the thumbnail slot, "Item unavailable" label. |
|||
- Card is not clickable (no `<a>`, no hover blue). |
|||
|
|||
- [ ] **Step 4: Verify invalid placeholder is left untouched** |
|||
|
|||
Inject `${item-link:not-a-uuid}` into the readme. Expected: the rendered markdown shows the literal text `${item-link:not-a-uuid}` (no card, no error). This proves the regex is strict. |
|||
|
|||
- [ ] **Step 5: Verify device install instructions** |
|||
|
|||
Open any IoT Hub Device item, click Install, and walk to a step whose markdown is dynamically rendered. Inject `${item-link:<knownItemId>}` into one of the markdown templates served by the device package (or temporarily prepend it to `step.markdown` in `device-install-dialog.component.ts` near the existing `resolveVariables` call). Expected: same skeleton → loaded card behavior; click opens `/iot-hub/<itemId>` in a new tab. |
|||
|
|||
Revert temporary edits. |
|||
|
|||
- [ ] **Step 6: Verify solution install instructions** |
|||
|
|||
Install (or reopen the install instructions for) a Solution Template that has a `details` markdown. Inject `${item-link:<knownItemId>}` into `details` (temporarily, in the constructor or via a known solution template whose details you control). Expected: same skeleton → loaded behavior; click opens `/iot-hub/<itemId>` in a new tab. |
|||
|
|||
Revert temporary edits. |
|||
|
|||
- [ ] **Step 7: Confirm no regressions in existing markdown** |
|||
|
|||
In each of the three render sites, verify after the QA edits are reverted: |
|||
- Existing `${gateway.downloadButton}` placeholder still renders correctly in the device install instructions (unchanged behavior). |
|||
- Existing callout boxes (`${note(...)}`, `${warn(...)}`, `${error(...)}`) still render in the device install instructions. |
|||
- Existing `prefixResourceUrls` and `resolveDocLinks` continue to resolve image URLs and doc-link placeholders in readmes. |
|||
- Changelog tab in the item detail dialog still renders without an item-link card (it does not bind `additionalCompileModules`). |
|||
|
|||
- [ ] **Step 8: Stop the dev server** |
|||
|
|||
`Ctrl-C` in the terminal running `npm start`. |
|||
|
|||
- [ ] **Step 9: Final commit (if any QA-driven fix-ups were made)** |
|||
|
|||
If QA surfaced any issues that required code changes, commit them with a focused message. Otherwise this step is a no-op. |
|||
|
|||
```bash |
|||
git status |
|||
# If clean, no commit needed. |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## Self-review checklist (for the implementing agent) |
|||
|
|||
Before declaring complete, verify: |
|||
|
|||
1. **Spec coverage** — every decision in `docs/superpowers/specs/2026-05-06-iot-hub-item-link.md` has a corresponding task above. |
|||
2. **No leftover test scaffolding** — temporary hardcoded `${item-link:...}` placeholders used for QA are reverted. |
|||
3. **License headers** — every new `.ts` file uses `///` style, every new `.html` uses `<!-- -->`, every new `.scss` uses `/** */`. |
|||
4. **Build clean** — `npx ng build --configuration=development` finishes without errors. |
|||
5. **Three sites work, fourth doesn't** — readme, device install, solution install all render the card; changelog tab does **not** (by design — out of scope per spec). |
|||
@ -1,267 +0,0 @@ |
|||
# Gateway Support in Device Install Framework — Design Spec |
|||
|
|||
## Goal |
|||
|
|||
Extend the device install wizard to support gateway provisioning: creating a gateway device, configuring one or more connectors via shared attributes, and providing the gateway launch command (docker-compose download URL) in post-install instructions. |
|||
|
|||
## New Step Types |
|||
|
|||
### GATEWAY |
|||
|
|||
Creates a gateway device — a device with `additionalInfo: { gateway: true }`. |
|||
|
|||
- **Template:** device JSON (same format as DEVICE step), must include `"additionalInfo": {"gateway": true}` |
|||
- **After creation:** fetches device credentials (same as DEVICE) |
|||
- **Find-or-create:** no — always creates new (same as DEVICE) |
|||
- **Output variables:** |
|||
- `${gateway.id}` — device UUID |
|||
- `${gateway.name}` — device name |
|||
- `${gateway.token}` — access token |
|||
- `${gateway.dockerComposeUrl}` — `/api/device-connectivity/gateway-launch/${gateway.id}/docker-compose/download` |
|||
|
|||
### GATEWAY_CONNECTOR |
|||
|
|||
Configures a connector on a previously created gateway by saving connector config as shared attributes. |
|||
|
|||
- **Template:** connector config JSON — the object with `name`, `type`, `configurationJson`, `logLevel`, etc. |
|||
- **Behavior:** |
|||
1. Fetch current `active_connectors` shared attribute from the gateway (may be empty/missing — default to `[]`) |
|||
2. Append connector name to the array |
|||
3. Save updated `active_connectors` as shared attribute on the gateway |
|||
4. Save `{connectorName}: connectorConfig` as shared attribute on the gateway |
|||
- **Target entity:** uses `${gateway.id}` from the preceding GATEWAY step |
|||
- **Output variables:** `${gatewayConnector.name}` — the connector name from the template |
|||
- **Multiple steps:** each GATEWAY_CONNECTOR step appends to `active_connectors`. Two connectors → two steps → `active_connectors = ["Modbus Connector", "MQTT Connector"]` |
|||
|
|||
## Extension: Optional Attributes on Entity Steps |
|||
|
|||
All entity creation steps (DEVICE, GATEWAY, DEVICE_PROFILE, DASHBOARD, RULE_CHAIN) gain two optional fields in the step definition: |
|||
|
|||
```json |
|||
{ |
|||
"type": "GATEWAY", |
|||
"name": "${deviceName}", |
|||
"template": "gateway.json", |
|||
"serverAttributes": "server-attributes.json", |
|||
"sharedAttributes": "shared-attributes.json" |
|||
} |
|||
``` |
|||
|
|||
- `serverAttributes` — optional, path to JSON file in ZIP. After entity creation, file is read, variables resolved, and saved as `SERVER_SCOPE` attributes on the created entity. |
|||
- `sharedAttributes` — optional, path to JSON file in ZIP. Same, saved as `SHARED_SCOPE`. |
|||
|
|||
Both files contain a flat JSON object of key-value pairs: |
|||
```json |
|||
{ |
|||
"firmwareVersion": "1.2.3", |
|||
"configUrl": "${http.host}:${http.port}/config" |
|||
} |
|||
``` |
|||
|
|||
## Changes to Existing Models |
|||
|
|||
### DeviceInstallStep interface (device-package.models.ts) |
|||
|
|||
Add fields: |
|||
```typescript |
|||
export interface DeviceInstallStep { |
|||
type: InstallStepType; |
|||
name: string; |
|||
file?: string; |
|||
template?: string; |
|||
serverAttributes?: string; // NEW |
|||
sharedAttributes?: string; // NEW |
|||
} |
|||
``` |
|||
|
|||
### InstallStepType enum |
|||
|
|||
Add: |
|||
```typescript |
|||
GATEWAY = 'GATEWAY', |
|||
GATEWAY_CONNECTOR = 'GATEWAY_CONNECTOR' |
|||
``` |
|||
|
|||
### ENTITY_STEP_TYPES set |
|||
|
|||
Add `GATEWAY` and `GATEWAY_CONNECTOR`. |
|||
|
|||
### stepTypeAliasMap |
|||
|
|||
Add: |
|||
```typescript |
|||
GATEWAY: 'gateway', |
|||
GATEWAY_CONNECTOR: 'gatewayConnector' |
|||
``` |
|||
|
|||
## Variable Resolution Updates |
|||
|
|||
### New named entity outputs |
|||
|
|||
| Step type | Variables | |
|||
|-----------|-----------| |
|||
| GATEWAY | `${gateway.id}`, `${gateway.name}`, `${gateway.token}`, `${gateway.dockerComposeUrl}` | |
|||
| GATEWAY_CONNECTOR | `${gatewayConnector.name}` | |
|||
|
|||
### EntityStepOutput interface |
|||
|
|||
Add optional `dockerComposeUrl` field: |
|||
```typescript |
|||
export interface EntityStepOutput { |
|||
id: string; |
|||
name: string; |
|||
token?: string; |
|||
dockerComposeUrl?: string; // NEW |
|||
} |
|||
``` |
|||
|
|||
## Frontend Implementation (createEntity) |
|||
|
|||
### GATEWAY case |
|||
|
|||
Same as DEVICE: |
|||
1. Save device via `deviceService.saveDevice(template, {ignoreErrors: true})` |
|||
2. Fetch credentials via `deviceService.getDeviceCredentials(id)` |
|||
3. Return output with `dockerComposeUrl` computed from the device ID |
|||
|
|||
```typescript |
|||
case InstallStepType.GATEWAY: { |
|||
const result = await firstValueFrom(this.deviceService.saveDevice(template, {ignoreErrors: true})); |
|||
const creds = await firstValueFrom(this.deviceService.getDeviceCredentials(result.id.id, false, {ignoreErrors: true})); |
|||
return { |
|||
id: result.id.id, |
|||
name: result.name, |
|||
token: creds.credentialsId, |
|||
dockerComposeUrl: `/api/device-connectivity/gateway-launch/${result.id.id}/docker-compose/download` |
|||
}; |
|||
} |
|||
``` |
|||
|
|||
### GATEWAY_CONNECTOR case |
|||
|
|||
1. Read and resolve the connector template from ZIP |
|||
2. Extract `name` from the connector config |
|||
3. Fetch current `active_connectors` from gateway's shared attributes (or default to `[]`) |
|||
4. Append connector name |
|||
5. Save both attributes to gateway via `attributeService.saveEntityAttributes()` |
|||
|
|||
```typescript |
|||
case InstallStepType.GATEWAY_CONNECTOR: { |
|||
const gatewayOutput = this.entityOutputs.get('gateway'); |
|||
if (!gatewayOutput) throw new Error('GATEWAY step must precede GATEWAY_CONNECTOR'); |
|||
const gatewayEntityId = { entityType: 'DEVICE', id: gatewayOutput.id }; |
|||
|
|||
// Fetch current active_connectors |
|||
const attrs = await firstValueFrom(this.attributeService.getEntityAttributes( |
|||
gatewayEntityId, AttributeScope.SHARED_SCOPE, ['active_connectors'], {ignoreErrors: true} |
|||
)); |
|||
const activeConnectors: string[] = attrs.find(a => a.key === 'active_connectors')?.value || []; |
|||
|
|||
// Add this connector |
|||
const connectorName = template.name; |
|||
if (!activeConnectors.includes(connectorName)) { |
|||
activeConnectors.push(connectorName); |
|||
} |
|||
|
|||
// Save attributes |
|||
await firstValueFrom(this.attributeService.saveEntityAttributes( |
|||
gatewayEntityId, AttributeScope.SHARED_SCOPE, |
|||
[ |
|||
{ key: 'active_connectors', value: activeConnectors }, |
|||
{ key: connectorName, value: template } |
|||
], |
|||
{ignoreErrors: true} |
|||
)); |
|||
|
|||
return { id: gatewayOutput.id, name: connectorName }; |
|||
} |
|||
``` |
|||
|
|||
### Attribute saving after any entity step |
|||
|
|||
After `createEntity()` returns, check if the step has `serverAttributes` or `sharedAttributes`. If so, read the file, resolve variables, and save: |
|||
|
|||
```typescript |
|||
if (step.serverAttributes) { |
|||
const attrsJson = JSON.parse(this.resolveVariables(this.zipFiles.get(step.serverAttributes))); |
|||
const attrs = Object.entries(attrsJson).map(([key, value]) => ({ key, value })); |
|||
await firstValueFrom(this.attributeService.saveEntityAttributes(entityId, AttributeScope.SERVER_SCOPE, attrs, {ignoreErrors: true})); |
|||
} |
|||
if (step.sharedAttributes) { |
|||
const attrsJson = JSON.parse(this.resolveVariables(this.zipFiles.get(step.sharedAttributes))); |
|||
const attrs = Object.entries(attrsJson).map(([key, value]) => ({ key, value })); |
|||
await firstValueFrom(this.attributeService.saveEntityAttributes(entityId, AttributeScope.SHARED_SCOPE, attrs, {ignoreErrors: true})); |
|||
} |
|||
``` |
|||
|
|||
## Translation Keys |
|||
|
|||
Add: |
|||
``` |
|||
"iot-hub.device-install-step-type-GATEWAY": "Gateway", |
|||
"iot-hub.device-install-step-type-GATEWAY_CONNECTOR": "Gateway Connector" |
|||
``` |
|||
|
|||
## Example Gateway Package |
|||
|
|||
``` |
|||
modbus-sensor.zip/ |
|||
├── device-info.json |
|||
├── prerequisites.md |
|||
├── form.json |
|||
├── gateway.json |
|||
├── modbus-connector.json |
|||
├── dashboard.json |
|||
└── post-install.md |
|||
``` |
|||
|
|||
`device-info.json`: |
|||
```json |
|||
{ |
|||
"name": "Modbus Sensor", |
|||
"vendor": "Example", |
|||
"hardwareType": "SENSOR", |
|||
"connectivityTypes": ["GATEWAY_MODBUS"], |
|||
"installSteps": { |
|||
"GATEWAY_MODBUS": [ |
|||
{"type": "SHOW_INSTRUCTION", "name": "Prerequisites", "file": "prerequisites.md"}, |
|||
{"type": "SHOW_FORM", "name": "Configuration", "file": "form.json"}, |
|||
{"type": "GATEWAY", "name": "${deviceName} Gateway", "template": "gateway.json"}, |
|||
{"type": "GATEWAY_CONNECTOR", "name": "Modbus Connector", "template": "modbus-connector.json"}, |
|||
{"type": "DASHBOARD", "name": "Modbus Monitor", "template": "dashboard.json"}, |
|||
{"type": "SHOW_INSTRUCTION", "name": "Launch Gateway", "file": "post-install.md"} |
|||
] |
|||
} |
|||
} |
|||
``` |
|||
|
|||
`gateway.json`: |
|||
```json |
|||
{ |
|||
"name": "${deviceName} Gateway", |
|||
"type": "Gateway", |
|||
"additionalInfo": {"gateway": true} |
|||
} |
|||
``` |
|||
|
|||
`post-install.md`: |
|||
```markdown |
|||
## Launch Gateway |
|||
|
|||
1. [Download docker-compose.yml](${gateway.dockerComposeUrl}) |
|||
2. Place the file in a directory and run: |
|||
|
|||
\`\`\`bash |
|||
docker compose up |
|||
\`\`\` |
|||
|
|||
The gateway will connect to ThingsBoard at `${mqtt.host}:${mqtt.port}` using access token `${gateway.token}`. |
|||
``` |
|||
|
|||
## No Backend Changes |
|||
|
|||
All new logic is frontend-only: |
|||
- GATEWAY uses existing `saveDevice` API (same as DEVICE) |
|||
- GATEWAY_CONNECTOR uses existing `saveEntityAttributes` API |
|||
- Attribute saving uses existing `saveEntityAttributes` API |
|||
- `dockerComposeUrl` is a constructed URL string, not a new endpoint |
|||
@ -1,72 +0,0 @@ |
|||
# Transport Variables in Device Install Templates — Design Spec |
|||
|
|||
## Goal |
|||
|
|||
Add transport host/port variables to the device install wizard's template resolution so that post-install instructions and entity templates can reference the platform's configured transport endpoints (e.g., `${mqtt.host}`, `${coap.port}`). |
|||
|
|||
## Variables |
|||
|
|||
All 6 transport protocols are supported: |
|||
|
|||
| Variable | Example value | Source | |
|||
|----------|--------------|--------| |
|||
| `${http.host}` | `demo.thingsboard.io` | Admin settings `connectivity.http.host` | |
|||
| `${http.port}` | `8080` | Admin settings `connectivity.http.port` | |
|||
| `${https.host}` | `demo.thingsboard.io` | Admin settings `connectivity.https.host` | |
|||
| `${https.port}` | `443` | Admin settings `connectivity.https.port` | |
|||
| `${mqtt.host}` | `demo.thingsboard.io` | Admin settings `connectivity.mqtt.host` | |
|||
| `${mqtt.port}` | `1883` | Admin settings `connectivity.mqtt.port` | |
|||
| `${mqtts.host}` | `demo.thingsboard.io` | Admin settings `connectivity.mqtts.host` | |
|||
| `${mqtts.port}` | `8883` | Admin settings `connectivity.mqtts.port` | |
|||
| `${coap.host}` | `demo.thingsboard.io` | Admin settings `connectivity.coap.host` | |
|||
| `${coap.port}` | `5683` | Admin settings `connectivity.coap.port` | |
|||
| `${coaps.host}` | `demo.thingsboard.io` | Admin settings `connectivity.coaps.host` | |
|||
| `${coaps.port}` | `5684` | Admin settings `connectivity.coaps.port` | |
|||
|
|||
These variables are available in all template and instruction files, alongside form values and entity outputs. |
|||
|
|||
## Data Source |
|||
|
|||
Frontend fetches from existing admin settings API: |
|||
``` |
|||
GET /api/admin/settings/connectivity |
|||
``` |
|||
|
|||
Returns `DeviceConnectivitySettings` — a `Record<DeviceConnectivityProtocol, DeviceConnectivityInfo>` where each entry has `{ enabled, host, port }`. |
|||
|
|||
The `AdminService.getAdminSettings<DeviceConnectivitySettings>('connectivity')` method and `DeviceConnectivitySettings` model already exist in the codebase. |
|||
|
|||
## Resolution Priority |
|||
|
|||
The `resolveVariables()` function checks sources in this order: |
|||
1. Form field values (`formValues[key]`) — e.g., `${deviceName}` |
|||
2. Transport connectivity (`transportVars[key]`) — e.g., `${mqtt.host}` |
|||
3. Named entity outputs (`entityOutputs[alias].prop`) — e.g., `${device.id}` |
|||
|
|||
Transport variables use dot notation (`mqtt.host`) which currently falls through to entity output resolution. By adding a transport lookup before entity outputs, we ensure `${mqtt.host}` resolves to the transport config rather than looking for a non-existent entity alias called `mqtt`. |
|||
|
|||
## Changes |
|||
|
|||
**Single file:** `ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.ts` |
|||
|
|||
1. Inject `AdminService` (from `@core/http/admin.service`) |
|||
2. In `ngOnInit`, after ZIP parsing, fetch connectivity settings and flatten to `Record<string, string>`: |
|||
``` |
|||
{ 'http.host': '...', 'http.port': '8080', 'mqtt.host': '...', 'mqtt.port': '1883', ... } |
|||
``` |
|||
3. In `resolveVariables()`, check the transport map for dot-notation keys before falling through to entity outputs |
|||
|
|||
**No backend changes. No new models. No new endpoints.** |
|||
|
|||
## Example Usage in Templates |
|||
|
|||
Post-install instruction (`post-install.md`): |
|||
```markdown |
|||
#define THINGSBOARD_SERVER "${mqtt.host}" |
|||
#define THINGSBOARD_PORT ${mqtt.port} |
|||
``` |
|||
|
|||
Integration template (`integration.json`): |
|||
```json |
|||
{"baseUrl": "${http.host}:${http.port}"} |
|||
``` |
|||
@ -1,102 +0,0 @@ |
|||
# Provisioning Conflict Resolution — Design Spec |
|||
|
|||
## Goal |
|||
|
|||
When an entity with the same name already exists during device package provisioning, show a conflict resolution UI instead of an error. The user chooses how to proceed (use existing, overwrite, or create copy) and provisioning continues. |
|||
|
|||
## Conflict Detection |
|||
|
|||
Before creating each entity, pre-check by name. If an entity with the same name exists, show the conflict UI instead of attempting creation. |
|||
|
|||
| Entity type | Pre-check | Conflict options | |
|||
|-------------|-----------|-----------------| |
|||
| DEVICE_PROFILE | `findDeviceProfileByName` | Use existing / Overwrite | |
|||
| RULE_CHAIN | `findRuleChainByName` | Use existing / Overwrite | |
|||
| DEVICE | `findDeviceByName` (new) | Use existing / Overwrite | |
|||
| GATEWAY | `findDeviceByName` (new, same API) | Use existing / Overwrite | |
|||
| DASHBOARD | `findDashboardByName` (new) | Overwrite / Create copy | |
|||
| GATEWAY_CONNECTOR | no pre-check | — | |
|||
|
|||
## New Status: `conflict` |
|||
|
|||
```typescript |
|||
export type EntityStepStatus = 'pending' | 'running' | 'success' | 'error' | 'conflict'; |
|||
``` |
|||
|
|||
`EntityStepProgress` gains: |
|||
```typescript |
|||
existingEntity?: EntityStepOutput; // the found entity, for resolution |
|||
conflictType?: 'use-or-overwrite' | 'overwrite-or-copy'; // which buttons to show |
|||
``` |
|||
|
|||
## Resolution Actions |
|||
|
|||
**Use existing**: take the existing entity's ID/name/url, store as output, continue to next step. No API call. |
|||
|
|||
**Overwrite**: fetch existing entity by ID, merge template data (keeping the ID), save. For rule chains, also save metadata. Store result as output. |
|||
|
|||
**Create copy**: create new entity with the template as-is (TB allows duplicate dashboard names). Store result as output. |
|||
|
|||
## UI |
|||
|
|||
Conflict row styling: |
|||
- Amber/yellow background (not red — it's a decision, not an error) |
|||
- Warning icon (amber `⚠`) |
|||
- Description: "Device profile with this name already exists" |
|||
- Two buttons on the right |
|||
|
|||
For `use-or-overwrite`: |
|||
``` |
|||
⚠ Device Profile — Modbus TH Sensor |
|||
Entity with this name already exists |
|||
[Use existing] [Overwrite] |
|||
``` |
|||
|
|||
For `overwrite-or-copy`: |
|||
``` |
|||
⚠ Dashboard — Modbus TH Monitor |
|||
Dashboard with this name already exists |
|||
[Overwrite] [Create copy] |
|||
``` |
|||
|
|||
Button styling: |
|||
- Use existing / Create copy: outlined primary (safe option) |
|||
- Overwrite: outlined warning/amber (destructive) |
|||
|
|||
## Provisioning Step Flow |
|||
|
|||
1. For each entity step: |
|||
a. Set status = 'running' |
|||
b. Pre-check: search for existing entity by name |
|||
c. If found: set status = 'conflict', store existing entity, pause (return from loop) |
|||
d. If not found: create entity → success → continue |
|||
e. If creation fails: set status = 'error' → pause (existing behavior) |
|||
2. When user clicks a conflict resolution button: |
|||
a. Execute resolution (use existing / overwrite / create copy) |
|||
b. Set status = 'success' |
|||
c. Resume `runEntitySteps` from the next step |
|||
|
|||
## Read-Only Review Dialog |
|||
|
|||
No changes needed — provisioning step shows all entities as success with "Done" label. No conflict UI in review mode. |
|||
|
|||
## Changes |
|||
|
|||
### Models (`device-package.models.ts`) |
|||
- Add `'conflict'` to `EntityStepStatus` |
|||
- Add `existingEntity?: EntityStepOutput` and `conflictType?: string` to `EntityStepProgress` |
|||
|
|||
### Dialog Component (`device-install-dialog.component.ts`) |
|||
- Add `findDeviceByName` and `findDashboardByName` methods |
|||
- Refactor `createEntity` to pre-check before creating |
|||
- Add `resolveConflict(ep, resolution)` method |
|||
- Resume loop after conflict resolution |
|||
|
|||
### Dialog Template (`device-install-dialog.component.html`) |
|||
- Add conflict row rendering with amber styling and resolution buttons |
|||
|
|||
### Dialog Styles (`device-install-dialog.component.scss`) |
|||
- Add `.tb-progress-conflict` amber styles |
|||
|
|||
### Translations |
|||
- Add conflict-related translation keys |
|||
@ -1,289 +0,0 @@ |
|||
# IoT Hub item deep link — design |
|||
|
|||
**Status:** approved |
|||
**Date:** 2026-04-22 |
|||
**Author:** Andrii Shvaika |
|||
**Scope:** ThingsBoard CE frontend + IoT Hub backend contract (external) |
|||
|
|||
## Summary |
|||
|
|||
Allow two shareable deep-link URL shapes: |
|||
- `http://<tb-host>/iot-hub/{itemId}` — opens the detail view for the latest published version of the item. |
|||
- `http://<tb-host>/iot-hub/version/{itemVersionId}` — opens a specific version snapshot. Published versions open directly; unpublished versions are gated behind a security warning. |
|||
|
|||
The first URL is the canonical "share this marketplace item" link. The second targets creator review workflows — stable snapshots of an exact draft. |
|||
|
|||
## Goals |
|||
|
|||
- Two shareable, bookmarkable deep link shapes with distinct semantics: |
|||
- `/iot-hub/{itemId}` — always latest published version of the item (404 if none published). |
|||
- `/iot-hub/version/{itemVersionId}` — specific version; warning gate when unpublished, then "Unpublished preview" badge in the detail dialog. |
|||
- Zero backend changes in ThingsBoard. Install/update flows reuse the existing versionId-based pipeline. |
|||
|
|||
## Non-goals |
|||
|
|||
- Authenticated access to unpublished content. Authorization is "public-by-link": whoever has the UUID can fetch the content. |
|||
- Full standalone page for item detail. The detail view stays as an Angular Material dialog; the deep link navigates to the type-specific browse page (`/iot-hub/widgets`, `/iot-hub/dashboards`, etc.) and opens the dialog over it. |
|||
- Install-specific semantics for unpublished versions. Install reuses the normal flow; only the IoT Hub-side install counter policy may differ (see IoT Hub-side changes). |
|||
- A "latest including drafts" shorthand shape (`/iot-hub/{itemId}/preview`). If creators want to preview a specific draft, they use the version URL with the exact versionId. |
|||
|
|||
## User flows |
|||
|
|||
### Published link: `/iot-hub/{itemId}` |
|||
|
|||
1. User pastes or clicks `/iot-hub/{itemId}`. |
|||
2. Angular mounts `TbIotHubItemResolverComponent`. |
|||
3. Resolver calls `iotHubApiService.getPublishedVersion(itemId)` → IoT Hub `GET /api/items/{itemId}/published` (new endpoint). |
|||
4. On success, resolver navigates to `/iot-hub/{typeSegment(item.type)}` with router state `{ openItem: { version, preview: false } }` and `replaceUrl: true`. Detail dialog opens with no badge. |
|||
5. `TbIotHubItemsPageComponent.ngOnInit` consumes `history.state.openItem`, resolves installed state, and calls `IotHubActionsService.openItemDetail(...)`. |
|||
6. On 404 (no published version exists) or other error, resolver shows a toast and redirects to `/iot-hub`. |
|||
|
|||
### Version link: `/iot-hub/version/{itemVersionId}` |
|||
|
|||
1. User pastes or clicks `/iot-hub/version/{itemVersionId}`. |
|||
2. Angular mounts `TbIotHubItemResolverComponent`. |
|||
3. Resolver calls `iotHubApiService.getVersionInfo(itemVersionId)` → IoT Hub `GET /api/versions/{versionId}` (existing endpoint). |
|||
4. If the returned version is published → resolver navigates directly to `/iot-hub/{typeSegment(item.type)}` with router state `{ openItem: { version, preview: false } }` and `replaceUrl: true`. Detail dialog opens with no badge. |
|||
5. If the returned version is unpublished → resolver opens `TbIotHubUnpublishedWarningDialogComponent` (`disableClose: true`). |
|||
- Cancel → `router.navigate(['/iot-hub'])`. |
|||
- "I understand the risk, continue" → navigates to `/iot-hub/{typeSegment(item.type)}` with `{ openItem: { version, preview: true } }`; detail dialog opens with the "Unpublished preview" badge. |
|||
6. Install / Update / Remove / Open-entity actions behave as usual — all operate on the versionId already fetched, so install-from-version works end-to-end. |
|||
7. On 404 or other error, resolver shows a toast and redirects to `/iot-hub`. |
|||
|
|||
## Angular routing |
|||
|
|||
Two routes added to `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts`, placed after the existing named child routes (`widgets`, `dashboards`, `solution-templates`, `calculated-fields`, `rule-chains`, `devices`, `search`, `installed`, `creator/:creatorId`). The literal `version/:itemVersionId` must come **before** the `:itemId` wildcard so the router matches the literal first: |
|||
|
|||
```ts |
|||
{ path: 'version/:itemVersionId', component: TbIotHubItemResolverComponent, |
|||
data: { auth: [Authority.TENANT_ADMIN], title: 'iot-hub.item-detail' } }, |
|||
{ path: ':itemId', component: TbIotHubItemResolverComponent, |
|||
data: { auth: [Authority.TENANT_ADMIN], title: 'iot-hub.item-detail' } }, |
|||
``` |
|||
|
|||
The resolver branches on which param is present — `paramMap.get('itemVersionId')` signals the version-URL flow; `paramMap.get('itemId')` signals the published-URL flow. A UUID-shape check runs inside the resolver (not as a `UrlMatcher`) so an invalid id produces a friendly toast instead of a generic not-found page. |
|||
|
|||
## Components |
|||
|
|||
### `TbIotHubItemResolverComponent` (new) |
|||
|
|||
Location: `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts`. |
|||
|
|||
- Standalone: `false`. Declared in `IotHubModule`. |
|||
- Template: empty (`template: ''`). The component renders nothing; it is a router-reachable controller. |
|||
- `ngOnInit`: |
|||
1. Read `itemVersionId` and `itemId` from route params. Set `byVersion = itemVersionId != null`; pick the relevant id accordingly. |
|||
2. Reject non-UUID id → `iot-hub.deep-link-invalid-id` toast + redirect to `/iot-hub`. |
|||
3. Dispatch to `getVersionInfo(id)` (byVersion) or `getPublishedVersion(id)` (published), both with `{ ignoreErrors: true }`. |
|||
4. On error, map HTTP status to `iot-hub.deep-link-not-found` (404) or `iot-hub.deep-link-fetch-failed` (other) and redirect. |
|||
5. On success, call `handleResolved(version, byVersion)`: |
|||
- `byVersion` + version unpublished → open warning dialog; confirm routes to type-page with state (`preview: true`); cancel routes to `/iot-hub`. |
|||
- Otherwise → route directly to type-page with state (`preview: false`). |
|||
- All navigations use `replaceUrl: true` so the resolver URL does not pollute browser history. |
|||
- The published URL (`/iot-hub/{itemId}`) cannot surface unpublished content — `getPublishedVersion` only returns PUBLISHED versions — so the warning branch is unreachable for that flow. The `byVersion` gate in `handleResolved` makes this explicit. |
|||
|
|||
### `iot-hub-deep-link.utils.ts` (new) |
|||
|
|||
Shared helpers: |
|||
|
|||
```ts |
|||
export const isUUID = (s: string | null): s is string => !!s && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); |
|||
|
|||
export function typeSegment(t: ItemType): string | undefined { |
|||
switch (t) { |
|||
case ItemType.WIDGET: return 'widgets'; |
|||
case ItemType.DASHBOARD: return 'dashboards'; |
|||
case ItemType.SOLUTION_TEMPLATE: return 'solution-templates'; |
|||
case ItemType.CALCULATED_FIELD: return 'calculated-fields'; |
|||
case ItemType.RULE_CHAIN: return 'rule-chains'; |
|||
case ItemType.DEVICE: return 'devices'; |
|||
default: return undefined; |
|||
} |
|||
} |
|||
|
|||
export function isPublished(v: MpItemVersionView): boolean { |
|||
return !!v.publishedTime && v.publishedTime > 0; |
|||
} |
|||
``` |
|||
|
|||
A `typeSegment` returning `undefined` (future `ItemType` values) surfaces as `iot-hub.deep-link-fetch-failed` in the resolver. |
|||
|
|||
### `TbIotHubUnpublishedWarningDialogComponent` (new) |
|||
|
|||
Location: `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.{ts,html,scss}`. Declared in `IotHubComponentsModule`. |
|||
|
|||
- Title: translated `iot-hub.unpublished-warning-title` ("Unpublished content") with a red `warning` Material icon. |
|||
- Body: `iot-hub.unpublished-warning-text` paragraph ("This is a preview of unpublished content. It has not been reviewed by IoT Hub. Installing unverified content can introduce security and stability risks — only continue if you trust the creator."). |
|||
- Secondary line: `{item.name} • v {item.version}` so the user sees what they are acknowledging. |
|||
- Buttons: |
|||
- `Cancel` — returns `false`. |
|||
- `iot-hub.unpublished-warning-confirm` ("I understand the risk, continue") — returns `true`, styled with the project's danger accent. |
|||
- Dialog config: `panelClass: ['tb-dialog']`, `disableClose: true`, `autoFocus: false`. |
|||
- `MAT_DIALOG_DATA` payload: `{ item: MpItemVersionView }`. |
|||
|
|||
Structural sibling of `TbIotHubDeleteDialogComponent`. |
|||
|
|||
### `TbIotHubItemDetailDialogComponent` (modified) |
|||
|
|||
- `IotHubItemDetailDialogData` gains optional `preview?: boolean`. |
|||
- Component stores `this.preview = data.preview === true` and exposes it to the template. |
|||
- Template adds a preview badge next to the existing version chip in the sticky meta bar: |
|||
|
|||
```html |
|||
<div class="tb-unpublished-preview-badge" *ngIf="preview"> |
|||
<mat-icon>warning</mat-icon> |
|||
<span>{{ 'iot-hub.unpublished-preview' | translate }}</span> |
|||
</div> |
|||
``` |
|||
|
|||
- No other template or behavior changes. Install / Update / Remove / Open-entity actions are untouched. |
|||
- SCSS adds `.tb-unpublished-preview-badge` (red-on-light-red, matching the warning dialog accent). |
|||
|
|||
### `IotHubActionsService` (modified) |
|||
|
|||
```ts |
|||
openItemDetail( |
|||
item: MpItemVersionView, |
|||
installedItem?: IotHubInstalledItem, |
|||
installedItemsCount?: number, |
|||
mode?: IotHubItemDetailDialogMode, |
|||
showCreator?: boolean, |
|||
preview?: boolean |
|||
): Observable<any> |
|||
``` |
|||
|
|||
`preview` is forwarded into `IotHubItemDetailDialogData`. All existing callers ignore the new parameter (undefined → non-preview). |
|||
|
|||
### `TbIotHubItemsPageComponent` (modified) |
|||
|
|||
`ngOnInit` is extended to consume `history.state.openItem` exactly once per navigation: |
|||
|
|||
```ts |
|||
private maybeOpenDeepLinkedItem(): void { |
|||
const openItem = history.state?.openItem as |
|||
{ version: MpItemVersionView; preview?: boolean } | undefined; |
|||
if (!openItem || openItem.version.type !== this.config.type) return; |
|||
|
|||
history.replaceState({ ...history.state, openItem: undefined }, ''); |
|||
|
|||
this.resolveInstalledItem(openItem.version).subscribe(installed => { |
|||
this.iotHubActions.openItemDetail( |
|||
openItem.version, |
|||
installed, |
|||
installed ? 1 : 0, |
|||
'default', |
|||
true, |
|||
openItem.preview |
|||
).subscribe(result => this.handleDetailResult(result)); |
|||
}); |
|||
} |
|||
|
|||
private resolveInstalledItem(v: MpItemVersionView): Observable<IotHubInstalledItem | null> { |
|||
return this.iotHubApiService |
|||
.getInstalledItems(new PageLink(1), undefined, v.itemId) |
|||
.pipe(map(page => page.data[0] ?? null)); |
|||
} |
|||
``` |
|||
|
|||
`handleDetailResult` delegates to the same installed-count refresh logic already used by card clicks. The `history.replaceState` call clears `openItem` so a page refresh does not re-open the dialog from stale state. |
|||
|
|||
## API contract |
|||
|
|||
### `IotHubApiService` — one new method |
|||
|
|||
```ts |
|||
public getPublishedVersion(itemId: string, config?: IotHubRequestConfig): Observable<MpItemVersionView> { |
|||
return this.http.get<MpItemVersionView>( |
|||
`${this.baseUrl}/api/items/${itemId}/published`, |
|||
{ params: this.buildParams(config) } |
|||
); |
|||
} |
|||
``` |
|||
|
|||
The version URL reuses the existing `getVersionInfo(versionId, config)` which calls `GET /api/versions/{versionId}`. Both accept `{ ignoreErrors: true }` so the resolver can handle failures inline rather than surfacing the global interceptor toast. |
|||
|
|||
### ThingsBoard backend |
|||
|
|||
Unchanged. `IotHubController.installVersion` and `IotHubController.updateInstalledItem` already operate on versionIds; both deep-link flows funnel into them without modification. |
|||
|
|||
## i18n |
|||
|
|||
Add to `ui-ngx/src/assets/locale/locale.constant-en_US.json` (and mirror into other locales): |
|||
|
|||
- `iot-hub.item-detail` — "IoT Hub item" |
|||
- `iot-hub.unpublished-warning-title` — "Unpublished content" |
|||
- `iot-hub.unpublished-warning-text` — "This is a preview of unpublished content. It has not been reviewed by IoT Hub. Installing unverified content can introduce security and stability risks — only continue if you trust the creator." |
|||
- `iot-hub.unpublished-warning-confirm` — "I understand the risk, continue" |
|||
- `iot-hub.unpublished-preview` — "Unpublished preview" |
|||
- `iot-hub.deep-link-invalid-id` — "Invalid IoT Hub item link." |
|||
- `iot-hub.deep-link-not-found` — "This IoT Hub item doesn't exist or was removed." |
|||
- `iot-hub.deep-link-fetch-failed` — "Couldn't load IoT Hub item. Please try again." |
|||
|
|||
## Edge cases |
|||
|
|||
- **Invalid UUID shape** for either `itemId` or `itemVersionId` → `iot-hub.deep-link-invalid-id` toast + redirect to `/iot-hub`. |
|||
- **404 from IoT Hub**: |
|||
- Published URL: item has no published version, or item doesn't exist. Toast `iot-hub.deep-link-not-found` + redirect. |
|||
- Version URL: version doesn't exist or was removed. Same toast + redirect. |
|||
- **Network / 5xx error** → `iot-hub.deep-link-fetch-failed` toast + redirect. |
|||
- **Version URL resolves to a published version** → no warning, no badge. Serves as a stable snapshot link to that version. |
|||
- **Unsupported `ItemType`** (future value not in `typeSegment`) → treated as `iot-hub.deep-link-fetch-failed`. |
|||
- **User hits Browser Back from the warning dialog** → dialog destroys with the resolver component; no zombie dialog. |
|||
- **User lacks `TENANT_ADMIN`** → the `/iot-hub` parent route guard blocks; no additional guard needed. |
|||
- **Deep link for an already-installed item** → detail dialog shows its usual "Installed / Update / Open entity" actions against the resolved versionId. Both URL shapes produce identical behavior once the dialog is open. |
|||
- **Refresh after deep link has been resolved** → URL is now `/iot-hub/{typePage}`; `history.state.openItem` is cleared; user sees the type-page with no dialog (expected). |
|||
|
|||
## Testing |
|||
|
|||
- `TbIotHubItemResolverComponent` unit tests with mocked `IotHubApiService` and `Router`: |
|||
- published URL happy path (no warning, no badge) |
|||
- version URL happy path, version is published (no warning, no badge) |
|||
- version URL happy path, version is unpublished (warning → confirm → dialog with badge) |
|||
- version URL, warning cancel → redirect to `/iot-hub` |
|||
- invalid UUID (either param) |
|||
- 404 (both URL shapes) |
|||
- 5xx / network error |
|||
- unsupported `ItemType` |
|||
- `isPublished()` and `typeSegment()` unit tests. |
|||
- `TbIotHubUnpublishedWarningDialogComponent` component tests: renders item name/version; Cancel returns `false`; Confirm returns `true`. |
|||
- `TbIotHubItemsPageComponent.maybeOpenDeepLinkedItem` integration test with seeded `history.state`: asserts dialog opens, state is cleared, type mismatch is ignored. |
|||
- No ThingsBoard backend tests (no backend changes). |
|||
|
|||
## Files touched |
|||
|
|||
New: |
|||
- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-item-resolver.component.ts` |
|||
- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-deep-link.utils.ts` |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.ts` |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.html` |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-unpublished-warning-dialog.component.scss` |
|||
|
|||
Modified: |
|||
- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-routing.module.ts` |
|||
- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub.module.ts` |
|||
- `ui-ngx/src/app/modules/home/pages/iot-hub/iot-hub-items-page.component.ts` |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts` |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts` |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html` |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss` |
|||
- `ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-actions.service.ts` |
|||
- `ui-ngx/src/app/core/http/iot-hub-api.service.ts` |
|||
- `ui-ngx/src/assets/locale/locale.constant-*.json` |
|||
|
|||
## IoT Hub-side changes required |
|||
|
|||
These live in the IoT Hub repository, not ThingsBoard CE. One new endpoint plus behavior/CORS contracts on the existing by-versionId family. |
|||
|
|||
1. **New endpoint** `GET /api/items/{itemId}/published` |
|||
- Returns `MpItemVersionView` for the latest version of the item that is in the PUBLISHED state. |
|||
- `404` when the item has no published version or doesn't exist. |
|||
- Anonymous cross-origin access (same CORS policy as `/api/versions/published`). |
|||
- Powers the `/iot-hub/{itemId}` deep link. |
|||
2. **Behavior contract on existing `GET /api/versions/{versionId}`**: must return the requested version regardless of its state (PUBLISHED, DRAFT, PENDING_REVIEW, …). This powers the `/iot-hub/version/{itemVersionId}` deep link. Anonymous cross-origin; the versionId UUID itself is the soft-secret gate. |
|||
3. **`MpItemVersionView` response for unpublished versions** must allow the frontend to tell published from unpublished. Either `publishedTime` must be falsy (`null` / `0`) for non-published versions, or an explicit `state` field must be added. Pick one; the frontend uses `isPublished(v)` based on `publishedTime` today. |
|||
4. **Related by-versionId endpoints must also serve unpublished versions** (required by the install flow proxied through TB): |
|||
- `GET /api/versions/{versionId}/readme` |
|||
- `GET /api/versions/{versionId}/fileData` |
|||
- `POST /api/versions/{versionId}/install` |
|||
5. **Install counter policy**: decide whether `POST /api/versions/{versionId}/install` against an unpublished version increments counters. Recommended: skip, to avoid inflating published install metrics with creator self-tests. |
|||
6. **CORS**: ensure `/api/items/{itemId}/published` and the full `/api/versions/{versionId}/...` family permit cross-origin GET from any origin. |
|||
@ -1,145 +0,0 @@ |
|||
# IoT Hub `${item-link:uuid}` Markdown Component — Design |
|||
|
|||
**Date:** 2026-05-06 |
|||
**Status:** Approved |
|||
**Branch:** `feature/iot-hub` |
|||
|
|||
## Summary |
|||
|
|||
Add an `${item-link:<uuid>}` markdown placeholder that renders a small card |
|||
(thumbnail + name + creator) for any IoT Hub marketplace item, modeled on |
|||
the cards already shown in the home-page search popup. Used by IoT Hub |
|||
content authors to cross-link items from readme and install instructions. |
|||
|
|||
## Decisions |
|||
|
|||
| Question | Decision | |
|||
|---|---| |
|||
| UUID identifies | Marketplace **item ID** (stable across versions) | |
|||
| Render scope | Item readme + device install instructions + solution install instructions | |
|||
| Click behavior | Plain anchor → `/iot-hub/{itemId}` with `target="_blank"` | |
|||
| Syntax | `${item-link:<uuid>}` — ID only, type derived from API response | |
|||
| Unavailable item handling | Render disabled card labeled "Item unavailable" | |
|||
| Resolution strategy | Render skeleton, fetch async, swap when ready (per card) | |
|||
|
|||
## Architecture |
|||
|
|||
Three building blocks, scoped tightly: |
|||
|
|||
1. **`replaceItemLinkPlaceholders(markdown: string): string`** — pure |
|||
string transform. Rewrites `${item-link:<uuid>}` to |
|||
`<tb-iot-hub-item-link-card itemId="<uuid>"></tb-iot-hub-item-link-card>`. |
|||
2. **`TbIotHubItemLinkCardComponent`** — Angular component that owns |
|||
fetch, state (`loading` / `loaded` / `unavailable`), and visual. |
|||
3. **`IotHubItemLinkModule`** — declares the component; passed as |
|||
`additionalCompileModules` on every `tb-markdown` instance that needs it. |
|||
|
|||
`tb-markdown` already supports compile-time injection of additional |
|||
modules; placeholder rewriting + module registration is all that is |
|||
needed to make the component render inside markdown. |
|||
|
|||
## Placeholder syntax |
|||
|
|||
- Format: `${item-link:<uuid>}` |
|||
- Regex: strict 36-char UUID match |
|||
(`/\$\{item-link:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\}/g`) |
|||
- Non-UUID payloads (typos) are left as-is in rendered output, so the |
|||
author sees the issue during preview. |
|||
- Placeholders inside fenced code blocks are still replaced — matches |
|||
the existing `prefixResourceUrls` / `resolveDocLinks` precedent in the |
|||
same files. |
|||
|
|||
## Integration points |
|||
|
|||
| File | Hook | |
|||
|---|---| |
|||
| `iot-hub-item-detail-dialog.component.ts` `loadReadme()` | Add as a step in the existing pipeline next to `prefixResourceUrls` and `resolveDocLinks` | |
|||
| `device-install-dialog.component.ts` `resolveVariables()` | Add an `^item-link:(uuid)$` branch alongside `gateway.downloadButton` and the callout matchers | |
|||
| `solution-install-dialog.component.ts` constructor | New step before assigning `this.details` | |
|||
|
|||
All three call the same backing helper. Each `tb-markdown` instance |
|||
in those templates gets `[additionalCompileModules]="[IotHubItemLinkModule]"`. |
|||
|
|||
## Component spec |
|||
|
|||
**Location:** |
|||
`ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/` |
|||
|
|||
**Inputs:** |
|||
- `@Input() itemId: string` |
|||
|
|||
**Lifecycle:** |
|||
- `ngOnInit()` calls |
|||
`iotHubApiService.getPublishedVersion(itemId, { ignoreErrors: true, ignoreLoading: true })`. |
|||
- Success → `state = 'loaded'`, store item. |
|||
- Error / 404 / network failure → `state = 'unavailable'`. |
|||
|
|||
**Template — three states:** |
|||
|
|||
- **loading:** skeleton card matching the loaded layout (gray thumb + |
|||
two text shimmer lines). |
|||
- **loaded:** anchor to `/iot-hub/{itemId}` (`target="_blank"`, |
|||
`rel="noopener noreferrer"`), thumbnail slot + name + creator row. |
|||
- **unavailable:** non-clickable card, 60 % opacity, `link_off` icon |
|||
in the thumbnail slot, label "Item unavailable" (translated). |
|||
|
|||
**Thumbnail rules** (mirror the search popup at |
|||
`iot-hub-home.component.html:103-115`): |
|||
- Compact types (`CALCULATED_FIELD`, `ALARM_RULE`, `RULE_CHAIN`): |
|||
colored square + `getCompactIcon()` + item color. |
|||
- Other types: image via `iotHubApiService.resolveResourceUrl(item.image)`, |
|||
fallback to a `mat-icon` of the type when no image is present. |
|||
- The same branching exists in `iot-hub-home.component.ts` (search popup) |
|||
and `iot-hub-item-detail-dialog.component.ts`. The new card duplicates |
|||
it locally — extracting a shared helper is out of scope for this PR. |
|||
|
|||
**Styling:** |
|||
- Component-scoped SCSS. |
|||
- Fixed width ~320 px, block-level (each card on its own line in markdown). |
|||
- Subtle CSS-keyframe shimmer for the skeleton state. |
|||
- Hover: matches search-popup card hover. |
|||
|
|||
**Click:** |
|||
- Plain anchor — middle-click / ctrl-click / "open in new tab" all work |
|||
natively. Existing route `/iot-hub/:itemId` |
|||
(`TbIotHubItemResolverComponent`) handles resolution, the |
|||
unpublished-version warning, and opening the detail dialog on the |
|||
type page. |
|||
|
|||
## Translations |
|||
|
|||
Add the following keys (and propagate to other locale files): |
|||
|
|||
| Key | English | |
|||
|---|---| |
|||
| `iot-hub.item-link-unavailable` | "Item unavailable" | |
|||
|
|||
## Out of scope |
|||
|
|||
- Changelog rendering (explicitly excluded). |
|||
- IoT Hub creator-side authoring helpers (placeholder is plain text in |
|||
raw markdown; nothing required on the IoT Hub backend). |
|||
- Batch endpoint for resolving multiple items in one request — N parallel |
|||
requests is fine for the expected 0–5 references per page. |
|||
- A non-block (inline) variant of the card. |
|||
- Hover preview / tooltip / type chip on the card itself. |
|||
|
|||
## Testing |
|||
|
|||
- Component unit tests: state transitions (loading → loaded, loading |
|||
→ unavailable), thumbnail logic for compact vs. non-compact types, |
|||
fallback when image missing. |
|||
- Regex unit test for `replaceItemLinkPlaceholders` covering: valid |
|||
UUID, invalid UUID (left untouched), placeholder inside fenced code |
|||
(still replaced — documented behavior), multiple placeholders in one |
|||
document. |
|||
- Manual visual QA in all three render sites. |
|||
|
|||
## Risks / open items |
|||
|
|||
- `tb-markdown` recompiles on every `data` change; if a parent toggles |
|||
the markdown rapidly, the card re-fetches. Acceptable: readmes / |
|||
instructions don't churn during normal viewing. |
|||
- `getPublishedVersion` returns the **current** published version. If a |
|||
newer published version changes the name/thumbnail, links update |
|||
automatically — that is the documented behavior of the ID-only syntax. |
|||
@ -1,50 +0,0 @@ |
|||
///
|
|||
/// 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 { EntityType } from '@shared/models/entity-type.models'; |
|||
import { IotHubInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models'; |
|||
import { getEntityDetailsPageURL } from '@core/utils'; |
|||
|
|||
export const ITEM_TYPE_TO_ENTITY_TYPE: Record<string, EntityType> = { |
|||
'WIDGET': EntityType.WIDGET_TYPE, |
|||
'DASHBOARD': EntityType.DASHBOARD, |
|||
'CALCULATED_FIELD': EntityType.CALCULATED_FIELD, |
|||
'ALARM_RULE': EntityType.CALCULATED_FIELD, |
|||
'RULE_CHAIN': EntityType.RULE_CHAIN, |
|||
'DEVICE': EntityType.DEVICE_PROFILE |
|||
}; |
|||
|
|||
export function resolveEntityDetailsUrl(descriptor: IotHubInstalledItemDescriptor, itemType: string): string | null { |
|||
if (!descriptor) { |
|||
return null; |
|||
} |
|||
const entityType = ITEM_TYPE_TO_ENTITY_TYPE[itemType]; |
|||
if (!entityType) { |
|||
return null; |
|||
} |
|||
let entityId: string | null = null; |
|||
switch (descriptor.type) { |
|||
case 'WIDGET': entityId = descriptor.widgetTypeId?.id; break; |
|||
case 'DASHBOARD': entityId = descriptor.dashboardId?.id; break; |
|||
case 'CALCULATED_FIELD': entityId = descriptor.calculatedFieldId?.id; break; |
|||
case 'ALARM_RULE': entityId = descriptor.calculatedFieldId?.id; break; |
|||
case 'RULE_CHAIN': entityId = descriptor.ruleChainId?.id; break; |
|||
} |
|||
if (!entityId) { |
|||
return null; |
|||
} |
|||
return getEntityDetailsPageURL(entityId, entityType) || null; |
|||
} |
|||
@ -0,0 +1,80 @@ |
|||
///
|
|||
/// 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 { |
|||
Directive, |
|||
ElementRef, |
|||
OnDestroy, |
|||
inject, AfterViewInit, Renderer2, |
|||
} from '@angular/core'; |
|||
import { MatAutocompleteTrigger } from '@angular/material/autocomplete'; |
|||
import { Subscription } from 'rxjs'; |
|||
import { onParentScrollOrWindowResize } from '@core/utils'; |
|||
|
|||
@Directive({ |
|||
selector: 'input[matAutocomplete], textarea[matAutocomplete]', |
|||
standalone: false |
|||
}) |
|||
export class AutocompleteAutoScrollRepositionDirective implements AfterViewInit, OnDestroy { |
|||
private readonly trigger = inject(MatAutocompleteTrigger, { host: true }); |
|||
private readonly elementRef = inject(ElementRef<HTMLElement>); |
|||
private readonly renderer = inject(Renderer2); |
|||
|
|||
private parentScrollSubscription: Subscription = null; |
|||
private isIntersecting: boolean = false; |
|||
|
|||
private intersectionObserver = new IntersectionObserver((entries) => { |
|||
if (this.isIntersecting !== entries[0].isIntersecting) { |
|||
this.isIntersecting = entries[0].isIntersecting; |
|||
this.updatePanelVisibility(); |
|||
} |
|||
}, {threshold: [0.5]}); |
|||
|
|||
constructor() { |
|||
} |
|||
|
|||
ngAfterViewInit(): void { |
|||
this.parentScrollSubscription = onParentScrollOrWindowResize(this.elementRef.nativeElement).subscribe(() => { |
|||
if (this.trigger.panelOpen) { |
|||
this.trigger.updatePosition(); |
|||
} |
|||
}); |
|||
this.intersectionObserver.observe(this.elementRef.nativeElement); |
|||
} |
|||
|
|||
ngOnDestroy(): void { |
|||
if (this.parentScrollSubscription) { |
|||
this.parentScrollSubscription.unsubscribe(); |
|||
this.parentScrollSubscription = null; |
|||
} |
|||
if (this.intersectionObserver) { |
|||
this.intersectionObserver.unobserve(this.elementRef.nativeElement); |
|||
this.intersectionObserver.disconnect(); |
|||
this.intersectionObserver = null; |
|||
} |
|||
} |
|||
|
|||
private updatePanelVisibility(): void { |
|||
if (this.trigger.panelOpen) { |
|||
if (this.isIntersecting) { |
|||
this.renderer.removeStyle(this.trigger.autocomplete.panel.nativeElement, 'display'); |
|||
} else { |
|||
this.renderer.setStyle(this.trigger.autocomplete.panel.nativeElement, 'display', 'none'); |
|||
} |
|||
} |
|||
} |
|||
|
|||
} |
|||
Loading…
Reference in new issue