Browse Source

Feature/iot hub item link (#15584)

* docs(iot-hub): spec for ${item-link:uuid} markdown component

Design for a new markdown placeholder that renders an item card
(thumbnail + name + creator) inline in IoT Hub readmes and install
instructions, opening the linked item in a new tab.

* docs(iot-hub): correct branch name in item-link spec

* docs(iot-hub): implementation plan for ${item-link:uuid} markdown component

Step-by-step task plan covering the placeholder utility, the link card
component, module wiring, three integration sites (item readme, device
install instructions, solution install instructions), translations, and
manual visual QA.

* feat(iot-hub): add item-link placeholder utility for markdown

* feat(iot-hub): add TbIotHubItemLinkCardComponent for markdown item links

* refactor(iot-hub): remove impossible-state guards in item-link card

* feat(iot-hub): wrap item-link card in IotHubItemLinkModule

* feat(iot-hub): add item-link-unavailable translation key

* feat(iot-hub): render ${item-link} cards in item readme

* feat(iot-hub): render ${item-link} cards in device install instructions

* feat(iot-hub): render ${item-link} cards in solution install instructions

* fix(iot-hub): match item-link card thumbnail to search popup (99x56, contain)
pull/15615/head
Andrew Shvayka 2 months ago
committed by GitHub
parent
commit
79c367bf5d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 974
      docs/superpowers/plans/2026-05-06-iot-hub-item-link.md
  2. 145
      docs/superpowers/specs/2026-05-06-iot-hub-item-link.md
  3. 1
      ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html
  4. 11
      ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts
  5. 4
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts
  6. 3
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html
  7. 9
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts
  8. 66
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.html
  9. 160
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.scss
  10. 106
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.ts
  11. 27
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module.ts
  12. 32
      ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts
  13. 5
      ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html
  14. 12
      ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts
  15. 1
      ui-ngx/src/assets/locale/locale.constant-en_US.json

974
docs/superpowers/plans/2026-05-06-iot-hub-item-link.md

@ -0,0 +1,974 @@
# 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).

145
docs/superpowers/specs/2026-05-06-iot-hub-item-link.md

@ -0,0 +1,145 @@
# 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
ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html

@ -24,6 +24,7 @@
@case ('instruction') {
<div class="tb-device-install-instruction" #instructionContainer>
<tb-markdown [data]="ws.markdown"
[additionalCompileModules]="itemLinkCompileModules"
(ready)="onMarkdownReady(instructionContainer)">
</tb-markdown>
</div>

11
ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts

@ -14,7 +14,7 @@
/// limitations under the License.
///
import { ChangeDetectorRef, Component, Inject, OnInit, ViewChild } from '@angular/core';
import { ChangeDetectorRef, Component, Inject, OnInit, Type, ViewChild } from '@angular/core';
import { Router } from '@angular/router';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
@ -34,6 +34,8 @@ import { AttributeService } from '@core/http/attribute.service';
import { AttributeScope } from '@shared/models/telemetry/telemetry.models';
import { EntityId } from '@shared/models/id/entity-id';
import { generateSecret } from '@core/utils';
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 {
installMethodLabels as INSTALL_METHOD_LABELS,
peOnlyInstallMethods,
@ -88,6 +90,8 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
@ViewChild('installStepper', {static: false}) stepper: MatStepper;
readonly itemLinkCompileModules: Type<any>[] = [IotHubItemLinkModule];
loading = true;
packageInfo: DevicePackageInfo;
zipFiles = new Map<string, string>();
@ -444,6 +448,11 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
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(...)}
const calloutMatch = key.match(/^(note|warn|error)\((.+)\)$/s);
if (calloutMatch) {

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

@ -30,6 +30,7 @@ import { TbIotHubSearchComponent } from './iot-hub-search.component';
import { TbIotHubInstalledItemsTableComponent } from './iot-hub-installed-items-table.component';
import { TbIotHubInstalledItemsDialogComponent } from './iot-hub-installed-items-dialog.component';
import { IotHubActionsService } from './iot-hub-actions.service';
import { IotHubItemLinkModule } from './iot-hub-item-link-card/iot-hub-item-link.module';
@NgModule({
declarations: [
@ -48,7 +49,8 @@ import { IotHubActionsService } from './iot-hub-actions.service';
],
imports: [
CommonModule,
SharedModule
SharedModule,
IotHubItemLinkModule
],
providers: [
IotHubActionsService

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

@ -299,7 +299,8 @@
@if (readmeContent || shouldShowChangelog()) {
<div class="dlg-readme">
@if (readmeContent) {
<tb-markdown [data]="readmeContent"></tb-markdown>
<tb-markdown [data]="readmeContent"
[additionalCompileModules]="itemLinkCompileModules"></tb-markdown>
}
@if (shouldShowChangelog()) {
<h3>{{ 'iot-hub.changelog' | translate }}</h3>

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

@ -14,7 +14,7 @@
/// limitations under the License.
///
import { Component, Inject } from '@angular/core';
import { Component, Inject, Type } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
@ -31,6 +31,8 @@ import { getEntityDetailsPageURL } from '@core/utils';
import { SolutionInstallDialogComponent } from '@home/components/solution/solution-install-dialog.component';
import { SolutionTemplateInstalledItemDescriptor } from '@shared/models/iot-hub/iot-hub-installed-item.models';
import { IotHubActionsService } from '@home/components/iot-hub/iot-hub-actions.service';
import { replaceItemLinkPlaceholders } from './iot-hub-markdown.utils';
import { IotHubItemLinkModule } from './iot-hub-item-link-card/iot-hub-item-link.module';
export type IotHubItemDetailDialogMode = 'default' | 'add';
@ -52,6 +54,7 @@ export interface IotHubItemDetailDialogData {
export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubItemDetailDialogComponent> {
readonly ItemType = ItemType;
readonly itemLinkCompileModules: Type<any>[] = [IotHubItemLinkModule];
item: MpItemVersionView;
mode: IotHubItemDetailDialogMode;
showCreator: boolean;
@ -307,7 +310,9 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent<TbIotHubI
private loadReadme(): void {
const versionId = this.item.id as string;
this.iotHubApiService.getVersionReadme(versionId, { ignoreLoading: true }).subscribe(
content => this.readmeContent = this.resolveDocLinks(this.prefixResourceUrls(content || ''))
content => this.readmeContent = replaceItemLinkPlaceholders(
this.resolveDocLinks(this.prefixResourceUrls(content || ''))
)
);
}

66
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.html

@ -0,0 +1,66 @@
<!--
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>
}
}

160
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.scss

@ -0,0 +1,160 @@
/**
* 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: 360px;
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-shrink: 0;
width: 99px;
height: 56px;
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.12);
background: rgba(0, 0, 0, 0.04);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
&--compact {
width: 56px;
height: 56px;
border: none;
border-radius: 4.667px;
color: #fff;
}
&--unavailable {
width: 56px;
height: 56px;
border: none;
color: rgba(0, 0, 0, 0.45);
}
}
.tb-iot-hub-item-link-thumb-icon {
font-size: 28px;
width: 28px;
height: 28px;
line-height: 28px;
color: #fff;
}
.tb-iot-hub-item-link-thumb-fallback {
font-size: 28px;
width: 28px;
height: 28px;
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; }
}

106
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.ts

@ -0,0 +1,106 @@
///
/// 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 {
const item = this.item!;
return item.image ? this.iotHubApiService.resolveResourceUrl(item.image) : null;
}
getCompactIcon(): string {
const item = this.item!;
if (item.icon) {
return item.icon;
}
switch (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 {
const item = this.item!;
switch (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}`;
}
}

27
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module.ts

@ -0,0 +1,27 @@
///
/// 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 {}

32
ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts

@ -0,0 +1,32 @@
///
/// Copyright © 2016-2026 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
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));
}

5
ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html

@ -26,7 +26,10 @@
</button>
</mat-toolbar>
<div mat-dialog-content>
<tb-markdown [data]="details" lineNumbers fallbackToPlainMarkdown></tb-markdown>
<tb-markdown [data]="details"
[additionalCompileModules]="itemLinkCompileModules"
lineNumbers
fallbackToPlainMarkdown></tb-markdown>
</div>
<div mat-dialog-actions class="flex flex-row items-center justify-end">
<button mat-button color="primary"

12
ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts

@ -14,10 +14,16 @@
/// limitations under the License.
///
import { Component, Inject } from '@angular/core';
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;
@ -36,12 +42,14 @@ export class SolutionInstallDialogComponent {
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 = data.descriptor.details || '';
this.details = replaceItemLinkPlaceholders(data.descriptor.details || '');
this.dashboardId = data.descriptor.dashboardId?.id || null;
this.instructions = !!data.instructions;
}

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

@ -3717,6 +3717,7 @@
"all-widgets": "All widgets",
"all-iot-hub-widgets": "All IoT Hub widgets",
"installed-from-iot-hub": "Installed from IoT Hub",
"item-link-unavailable": "Item unavailable",
"include-deprecated": "Include deprecated",
"widget-category-value": "{{ category }} widgets",
"search-categories": "Search categories...",

Loading…
Cancel
Save