From 79c367bf5deddc438d6f66e98d6a25c2ecef228d Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Wed, 6 May 2026 13:12:26 +0300 Subject: [PATCH] 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) --- .../plans/2026-05-06-iot-hub-item-link.md | 974 ++++++++++++++++++ .../specs/2026-05-06-iot-hub-item-link.md | 145 +++ .../device-install-dialog.component.html | 1 + .../device-install-dialog.component.ts | 11 +- .../iot-hub/iot-hub-components.module.ts | 4 +- .../iot-hub-item-detail-dialog.component.html | 3 +- .../iot-hub-item-detail-dialog.component.ts | 9 +- .../iot-hub-item-link-card.component.html | 66 ++ .../iot-hub-item-link-card.component.scss | 160 +++ .../iot-hub-item-link-card.component.ts | 106 ++ .../iot-hub-item-link.module.ts | 27 + .../iot-hub/iot-hub-markdown.utils.ts | 32 + .../solution-install-dialog.component.html | 5 +- .../solution-install-dialog.component.ts | 12 +- .../assets/locale/locale.constant-en_US.json | 1 + 15 files changed, 1548 insertions(+), 8 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-06-iot-hub-item-link.md create mode 100644 docs/superpowers/specs/2026-05-06-iot-hub-item-link.md create mode 100644 ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.html create mode 100644 ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module.ts create mode 100644 ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts diff --git a/docs/superpowers/plans/2026-05-06-iot-hub-item-link.md b/docs/superpowers/plans/2026-05-06-iot-hub-item-link.md new file mode 100644 index 0000000000..b3fb150346 --- /dev/null +++ b/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:}` 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:}` to a `` 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 `` +- `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 ``; +} + +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) => '')); +} +``` + +Run it: +```bash +node /tmp/check-item-link-regex.js +``` + +Expected output: +``` +valid: +invalid: ${item-link:not-a-uuid} +two valid: Two: and +inside code fence (still replaced — documented behavior): ``` + +``` +``` + +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 = 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 + +@switch (state) { + @case ('loading') { + + } + @case ('loaded') { + + @if (isCompact()) { + + } @else { + + } + + + } + @case ('unavailable') { + + } +} +``` + +- [ ] **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[] = [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 + +``` + +to: + +```html + +``` + +Leave the changelog `` (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[] = [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 '⬇ Download docker-compose.yml'; + } + // Callout boxes: ${note(...)}, ${warn(...)}, ${error(...)} +``` + +to: + +```typescript + // Special action placeholders + if (key === 'gateway.downloadButton') { + return '⬇ Download docker-compose.yml'; + } + // IoT Hub item link card: ${item-link:} + 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 + + +``` + +to: + +```html + + +``` + +- [ ] **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[] = [IotHubItemLinkModule]; + + constructor( + @Inject(MAT_DIALOG_DATA) public data: SolutionInstallDialogData, + private dialogRef: MatDialogRef, + 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 + +``` + +to: + +```html + +``` + +- [ ] **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:}` somewhere in the readme markdown. + - Easier alternative: temporarily hardcode a test placeholder in `loadReadme()` by appending `'\n\n${item-link:}\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/` 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 ``, 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:}` 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/` 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:}` 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/` 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). diff --git a/docs/superpowers/specs/2026-05-06-iot-hub-item-link.md b/docs/superpowers/specs/2026-05-06-iot-hub-item-link.md new file mode 100644 index 0000000000..38f7fe7895 --- /dev/null +++ b/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:}` 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:}` — 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:}` to + ``. +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:}` +- 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. diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html index 22705663c8..93a5f5ea19 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html @@ -24,6 +24,7 @@ @case ('instruction') {
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts index c042db2ebe..8c9f7b3662 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts +++ b/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[] = [IotHubItemLinkModule]; + loading = true; packageInfo: DevicePackageInfo; zipFiles = new Map(); @@ -444,6 +448,11 @@ export class TbDeviceInstallDialogComponent extends DialogComponent⬇ Download docker-compose.yml
'; } + // IoT Hub item link card: ${item-link:} + 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) { diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts index 44bc1ad4c8..0a41f12768 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-components.module.ts @@ -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 diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html index e2023d96e9..ab772579de 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.html @@ -299,7 +299,8 @@ @if (readmeContent || shouldShowChangelog()) {
@if (readmeContent) { - + } @if (shouldShowChangelog()) {

{{ 'iot-hub.changelog' | translate }}

diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts index e6d5ad9278..fb1632f1dd 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.ts @@ -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 { readonly ItemType = ItemType; + readonly itemLinkCompileModules: Type[] = [IotHubItemLinkModule]; item: MpItemVersionView; mode: IotHubItemDetailDialogMode; showCreator: boolean; @@ -307,7 +310,9 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent this.readmeContent = this.resolveDocLinks(this.prefixResourceUrls(content || '')) + content => this.readmeContent = replaceItemLinkPlaceholders( + this.resolveDocLinks(this.prefixResourceUrls(content || '')) + ) ); } diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.html new file mode 100644 index 0000000000..6ac5883cd4 --- /dev/null +++ b/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 @@ + +@switch (state) { + @case ('loading') { + + } + @case ('loaded') { + + @if (isCompact()) { + + } @else { + + } + + + } + @case ('unavailable') { + + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.scss new file mode 100644 index 0000000000..2713303d6c --- /dev/null +++ b/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; } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link-card.component.ts new file mode 100644 index 0000000000..c2ca0ba1ea --- /dev/null +++ b/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 = 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}`; + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-link-card/iot-hub-item-link.module.ts new file mode 100644 index 0000000000..24571271ff --- /dev/null +++ b/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 {} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts new file mode 100644 index 0000000000..b0a87fb11e --- /dev/null +++ b/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 ``; +} + +export function replaceItemLinkPlaceholders(markdown: string): string { + if (!markdown) { + return markdown; + } + return markdown.replace(ITEM_LINK_PLACEHOLDER_REGEX, (_match, uuid) => itemLinkCardTag(uuid)); +} diff --git a/ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html b/ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html index e5736bf4f3..80ab8748f9 100644 --- a/ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html @@ -26,7 +26,10 @@
- +