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/angular.json b/ui-ngx/angular.json index a5e140322b..007af200ba 100644 --- a/ui-ngx/angular.json +++ b/ui-ngx/angular.json @@ -109,7 +109,8 @@ "node_modules/@geoman-io/leaflet-geoman-free/dist/leaflet-geoman.css", "node_modules/prismjs/themes/prism.css", "node_modules/prismjs/plugins/line-numbers/prism-line-numbers.css", - "node_modules/ace-diff/dist/ace-diff.min.css" + "node_modules/ace-diff/dist/ace-diff.min.css", + "node_modules/photoswipe/dist/photoswipe.css" ], "stylePreprocessorOptions": { "includePaths": [ diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 082d2ad965..200b489508 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -77,6 +77,7 @@ "ngx-sharebuttons": "^17.0.0", "ngx-translate-messageformat-compiler": "^7.2.0", "objectpath": "^2.0.0", + "photoswipe": "^5.4.4", "qrcode": "^1.5.4", "raphael": "^2.3.0", "rxjs": "~7.8.2", diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index 71a93e3ae5..aefb08bd34 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -202,7 +202,6 @@ import { ApiKeyGeneratedDialogComponent } from '@home/components/api-key/api-key import { ApiKeysTableDialogComponent } from '@home/components/api-key/api-keys-table-dialog.component'; import { AuditLogFilterComponent } from "@home/components/audit-log/audit-log-filter.component"; import { EventsDialogComponent } from '@home/dialogs/events-dialog.component'; -import { SolutionInstallDialogComponent } from '@home/components/solution/solution-install-dialog.component'; @NgModule({ declarations: @@ -360,7 +359,6 @@ import { SolutionInstallDialogComponent } from '@home/components/solution/soluti AuditLogHeaderComponent, AuditLogFilterComponent, EventsDialogComponent, - SolutionInstallDialogComponent, ], imports: [ CommonModule, @@ -511,7 +509,6 @@ import { SolutionInstallDialogComponent } from '@home/components/solution/soluti ApiKeysTableComponent, ApiKeysTableDialogComponent, EventsDialogComponent, - SolutionInstallDialogComponent, ], providers: [ WidgetComponentService, 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..733ed0a906 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 @@ -21,105 +21,55 @@
@switch (ws.type) { - @case ('instruction') { -
- - + @case ('placeholder') { + + } + + @case ('connectivity') { +
+

{{ 'iot-hub.device-install-select-connectivity' | translate }}

+
+ @for (ct of availableInstallMethods; track ct) { + + } +
} + @case ('instruction') { + + + } + @case ('form') {
- @if (ws.formGroup) { -
- @for (field of ws.formFields; track field.key) { - @switch (field.type) { - @case ('BOOLEAN') { - - {{ field.label }} - - } - @case ('SELECT') { - - {{ field.label }} - - @for (opt of field.options; track opt.value) { - {{ opt.label }} - } - - @if (ws.formGroup.controls[field.key]?.hasError('required')) { - {{ field.label + ' is required' }} - } - - } - @case ('PASSWORD') { - - {{ field.label }} - - - @if (field.randomGenerator) { - - } - @if (ws.formGroup.controls[field.key]?.hasError('required')) { - {{ field.label + ' is required' }} - } - @if (ws.formGroup.controls[field.key]?.hasError('pattern')) { - {{ getPatternErrorMessage(field) }} - } - - } - @case ('INTEGER') { - - {{ field.label }} - - @if (ws.formGroup.controls[field.key]?.hasError('required')) { - {{ field.label + ' is required' }} - } - @if (ws.formGroup.controls[field.key]?.hasError('pattern')) { - {{ getPatternErrorMessage(field) }} - } - - } - @default { - - {{ field.label }} - - @if (field.randomGenerator) { - - } - @if (ws.formGroup.controls[field.key]?.hasError('required')) { - {{ field.label + ' is required' }} - } - @if (ws.formGroup.controls[field.key]?.hasError('pattern')) { - {{ getPatternErrorMessage(field) }} - } - - } - } - @if (field.helpText) { -
{{ field.helpText }}
- } - @if (field.helpImage) { - - } - } -
- } + +
} @@ -226,64 +176,14 @@
- @if (!wizardStarted && !showPeOnlyPanel) { - -
-
-

{{ 'iot-hub.device-install-select-connectivity' | translate }}

-
- @for (ct of availableInstallMethods; track ct) { - - } -
-
-
- - - - - - } @else if (showPeOnlyPanel) { - -
- - - - - } @else if (reviewMode) { + @if (reviewMode) {
@for (ws of wizardSteps; track ws.label) { -
- -
+
} @@ -334,6 +234,14 @@ @if (currentWizardStep; as step) { @switch (step.type) { + @case ('connectivity') { + + + } @case ('instruction') { @if (isLastWizardStep) { diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss index ebdf5da07c..aff20e4d58 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss @@ -28,6 +28,9 @@ height: 1000px; max-width: 100%; } + @media #{$mat-gt-md} { + width: 1000px; + } } :host { @@ -86,217 +89,114 @@ .tb-device-install-connectivity { p { color: rgba(0, 0, 0, 0.54); - font-size: 14px; - line-height: 20px; + font-size: 16px; + line-height: 24px; margin: 0; } - .tb-connectivity-button { - min-width: 100px; - } - - .tb-connectivity-button.selected { - background: #305680; - color: #fff; - } -} - -.tb-tab-content { - padding: 16px 24px; -} - -// Instruction view -.tb-device-install-instruction { - font-size: 14px; - line-height: 24px; - letter-spacing: 0.2px; - color: rgba(0, 0, 0, 0.76); - - ::ng-deep tb-markdown .tb-markdown-view { - padding: 16px 24px 24px; - - h1, h2, h3, h4, h5, h6 { - font-size: 20px; - font-weight: 600; - line-height: 24px; - letter-spacing: 0.1px; - color: rgba(0, 0, 0, 0.76); - margin: 0; - padding: 0; - } - - > h1, > h2, > h3, > h4, > h5, > h6 { - padding: 0; - margin-top: 20px; - } - - > :first-child { - margin-top: 0; - } - - h1 { - font-size: 24px; - line-height: 32px; - padding-right: 0; - } - - p { - font-size: 14px; - font-weight: 400; - line-height: 24px; - letter-spacing: 0.2px; - color: rgba(0, 0, 0, 0.76); - margin: 0; - } - - > p, > div { - padding-left: 0; - padding-right: 0; - } - - p + p { - margin-top: 8px; - } - - h1 + p, h2 + p, h3 + p, - h1 + ul, h2 + ul, h3 + ul, - h1 + ol, h2 + ol, h3 + ol { - margin-top: 8px; - } - - ul, ol { - padding-left: 21px; - padding-right: 0; - margin: 0; - font-size: 14px; - line-height: 24px; - letter-spacing: 0.2px; - color: rgba(0, 0, 0, 0.76); - - + h1, + h2, + h3, + h4, + h5, + h6 { - padding-top: 0; - margin-top: 20px; - } - } - - li { - padding-bottom: 0; - margin-bottom: 0; - line-height: 24px; - } + // Card grid — design uses 276px cards with 12px gap, wrapping into rows. + // The 2-card variant uses a wider min-track so they don't squeeze. + .tb-connectivity-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 12px; - code:not([class*=language-]) { - font-size: 14px; + &.tb-connectivity-cards-pair { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } } - ::ng-deep { - - .tb-download-btn { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 6px 16px; - border: 1px solid #305680; - border-radius: 4px; - color: #305680; - font-size: 14px; - font-weight: 500; - text-decoration: none; - cursor: pointer; - - &:hover { - background: rgba(48, 86, 128, 0.08); - } - } - - .tb-doc-link-btn { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 8px 16px; - margin: 4px 8px 4px 0; - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 6px; - background: #fff; - color: rgba(0, 0, 0, 0.76); - font-size: 14px; - font-weight: 500; - line-height: 20px; - text-decoration: none; - cursor: pointer; - - i.material-icons { - font-size: 18px; - line-height: 18px; - color: rgba(0, 0, 0, 0.54); - } - - &:hover { - background: rgba(0, 0, 0, 0.04); - border-color: rgba(0, 0, 0, 0.24); - } + // Connection-card-vertical from the design: primary-tinted surface + // (4 % alpha), 6px radius, 1px subtle border that becomes primary on + // hover/select. + .tb-connectivity-card { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 16px; + height: 116px; + background: rgba($tb-primary-color, 0.04); + border: 1px solid transparent; + border-radius: 6px; + cursor: pointer; + text-align: left; + transition: border-color 0.12s ease-out, box-shadow 0.12s ease-out; + + &:hover, + &.selected { + border-color: $tb-primary-color; } - .tb-callout { - display: flex; - align-items: flex-start; - gap: 10px; - padding: 12px 16px; - border-radius: 4px; - border: 1px solid; - margin: 12px 0; - font-size: 14px; - line-height: 20px; - - .tb-callout-icon { - font-size: 20px; - flex-shrink: 0; - } - - &.tb-callout-note { - background: #e3f2fd; - border-color: #90caf9; - color: #1565c0; - } + &.selected { + box-shadow: 0 0 0 1px $tb-primary-color inset; - &.tb-callout-warn { - background: #fff8e1; - border-color: #ffe082; - color: #f57f17; + // Selected state inverts the icon: primary-coloured container + // with the masked glyph painted in white. + .tb-connectivity-card-icon { + background: $tb-primary-color; } - &.tb-callout-error { - background: #fce4ec; - border-color: #ef9a9a; - color: #c62828; + .tb-connectivity-card-icon-mask { + background-color: white; } } + } - .tb-gallery { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin: 12px 0; - } + // 48×48 white icon container with subtle drop shadow + 4px radius, + // hosting a 40×40 mask-image rendered in the primary color. + .tb-connectivity-card-icon { + width: 48px; + height: 48px; + border-radius: 4px; + background: white; + display: flex; + align-items: center; + justify-content: center; + box-shadow: + 0 1px 2px 0 rgba(0, 0, 0, 0.06), + 0 1px 3px 0 rgba(0, 0, 0, 0.10); + } - .tb-gallery-img { - max-height: 160px; - border-radius: 4px; - border: 1px solid rgba(0, 0, 0, 0.12); - cursor: pointer; - transition: max-height 0.2s ease; - object-fit: contain; + .tb-connectivity-card-icon-mask { + display: block; + width: 40px; + height: 40px; + background-color: $tb-primary-color; + -webkit-mask-image: var(--tb-icon-url); + mask-image: var(--tb-icon-url); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-size: contain; + mask-size: contain; + } - &:hover { - border-color: rgba(0, 0, 0, 0.24); - } + .tb-connectivity-card-name { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.15px; + color: rgba(0, 0, 0, 0.87); + } - &.tb-gallery-img-expanded { - max-height: none; - max-width: 100%; - } - } + // PE-Only pill — primary 1px border + primary text on white background. + .tb-connectivity-card-badge { + position: absolute; + top: 16px; + right: 16px; + height: 24px; + padding: 0 8px; + border: 1px solid $tb-primary-color; + border-radius: 4px; + background: white; + color: $tb-primary-color; + font-size: 12px; + font-weight: 500; + line-height: 22px; + letter-spacing: 0.4px; } } 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 98d5542d64..e784b11898 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,15 +14,20 @@ /// 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 { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { + PeConnectivityMethodPromptData, + TbPeConnectivityMethodPromptComponent +} from '@home/components/iot-hub/pe-connectivity-method-prompt.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { DialogComponent } from '@shared/components/dialog.component'; import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; import { MatStepper } from '@angular/material/stepper'; import { firstValueFrom } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; import { PageLink } from '@shared/models/page/page-link'; import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; import { IotHubApiService } from '@core/http/iot-hub-api.service'; @@ -35,6 +40,7 @@ import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; import { EntityId } from '@shared/models/id/entity-id'; import { generateSecret } from '@core/utils'; import { + installMethodIcons as INSTALL_METHOD_ICONS, installMethodLabels as INSTALL_METHOD_LABELS, peOnlyInstallMethods, DeviceInstallStep, @@ -45,7 +51,6 @@ import { FormFieldDefinition, FormFieldType, InstallStepType, - resolveDocLinkPlaceholders, stepTypeAliasMap } from '@shared/models/iot-hub/device-package.models'; @@ -57,7 +62,7 @@ export interface DeviceInstallDialogData { installState?: Record; } -export type WizardStepType = 'instruction' | 'form' | 'progress'; +export type WizardStepType = 'connectivity' | 'placeholder' | 'instruction' | 'form' | 'progress'; export interface WizardStep { type: WizardStepType; @@ -94,20 +99,15 @@ export class TbDeviceInstallDialogComponent extends DialogComponent(); // Connectivity - showConnectivitySelector = false; - showPeOnlyPanel = false; availableInstallMethods: string[] = []; selectedInstallMethod: string | null = null; installMethodLabels = INSTALL_METHOD_LABELS; - - get isSelectedPeOnly(): boolean { - return this.selectedInstallMethod !== null && peOnlyInstallMethods.has(this.selectedInstallMethod); - } + installMethodIcons = INSTALL_METHOD_ICONS; + peOnlyInstallMethods = peOnlyInstallMethods; // Wizard wizardSteps: WizardStep[] = []; wizardStarted = false; - passwordVisible: Record = {}; reviewMode = false; // Variable resolution state @@ -116,6 +116,8 @@ export class TbDeviceInstallDialogComponent extends DialogComponent = {}; gatewayDockerComposeContent: string | null = null; + resolveMarkdownVariable: (key: string) => string | undefined = this._resolveMarkdownVariable.bind(this); + constructor( protected store: Store, protected router: Router, @@ -127,7 +129,9 @@ export class TbDeviceInstallDialogComponent extends DialogComponent( + TbPeConnectivityMethodPromptComponent, + { + data: { connectorName: this.installMethodLabels.get(ct) || ct }, + autoFocus: false, + panelClass: ['tb-dialog'] + } + ); } onTabChanged(index: number): void { @@ -204,7 +235,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent { + this.stepper?.next(); + this.onStepActivated(); + }, 0); return; } this.startWizard(); @@ -402,13 +446,6 @@ export class TbDeviceInstallDialogComponent extends DialogComponent { - img.addEventListener('click', () => { - img.classList.toggle('tb-gallery-img-expanded'); - }); - }); } resolveImagePath(path: string): string { @@ -418,72 +455,51 @@ export class TbDeviceInstallDialogComponent extends DialogComponent's [resolveImagePath] input. + // Defined as a property so the template binding stays stable across change detection. + readonly resolveImagePathFn: (path: string) => string = (path: string) => this.resolveImagePath(path); // --- Variable Resolution --- resolveVariables(content: string): string { - if (this.packageInfo) { - content = resolveDocLinkPlaceholders( - content, - this.packageInfo.name || '', - { productURL: this.packageInfo.productURL, datasheetURL: this.packageInfo.datasheetURL }, - { productPage: 'Product page', datasheet: 'Datasheet' } - ); - } return content.replace(/\$\{([^}]+)}/g, (_match, key) => { - if (key in this.formValues) { - return String(this.formValues[key]); - } - if (key in this.transportVars) { - return this.transportVars[key]; - } - // Special action placeholders - if (key === 'gateway.downloadButton') { - return '⬇ Download docker-compose.yml'; - } - // Callout boxes: ${note(...)}, ${warn(...)}, ${error(...)} - const calloutMatch = key.match(/^(note|warn|error)\((.+)\)$/s); - if (calloutMatch) { - const type = calloutMatch[1]; - const text = calloutMatch[2]; - const icons: Record = { note: 'info_outline', warn: 'warning_amber', error: 'error_outline' }; - return `
${icons[type]}${text}
`; - } - // Image gallery: ${images.gallery(path1,path2,path3)} - const galleryMatch = key.match(/^images\.gallery\((.+)\)$/); - if (galleryMatch) { - const paths = galleryMatch[1].split(',').map((p: string) => p.trim()); - const images = paths - .map((p: string) => this.zipImages.get(p)) - .filter((src: string | undefined) => !!src) - .map((src: string) => ``) - .join(''); - return ``; - } - const dotIdx = key.indexOf('.'); - if (dotIdx > 0) { - const alias = key.substring(0, dotIdx); - const prop = key.substring(dotIdx + 1); - const output = this.entityOutputs.get(alias); - if (output && prop in output) { - return String((output as any)[prop]); - } + const res = this.resolveVariable(key); + if (res) { + return res; } return '${' + key + '}'; }); } - resolveImages(content: string): string { - return content.replace(/!\[([^\]]*)]\(([^)]+)\)/g, (match, alt, path) => { - if (path.startsWith('data:') || path.startsWith('http')) { - return match; + resolveVariable(key: string): string | undefined { + if (key in this.formValues) { + return String(this.formValues[key]); + } + if (key in this.transportVars) { + return this.transportVars[key]; + } + const dotIdx = key.indexOf('.'); + if (dotIdx > 0) { + const alias = key.substring(0, dotIdx); + const prop = key.substring(dotIdx + 1); + const output = this.entityOutputs.get(alias); + if (output && prop in output) { + return String((output as any)[prop]); } - const dataUri = this.zipImages.get(path); - return dataUri ? `![${alt}](${dataUri})` : match; - }); + } + return undefined; + } + + private _resolveMarkdownVariable(key: string): string | undefined { + const res = this.resolveVariable(key); + if (res) { + return res; + } + // Special action placeholders + if (key === 'gateway.downloadButton') { + return '⬇ Download docker-compose.yml'; + } + return undefined; } // --- Private --- @@ -520,7 +536,6 @@ export class TbDeviceInstallDialogComponent extends DialogComponent 1 && !this.selectedInstallMethod) { + // First step: connection method selector. Remaining steps are + // appended via appendInstallSteps() once a method is confirmed. + this.wizardSteps.push({ + type: 'connectivity', + label: this.translate.instant('iot-hub.connection-method'), + rawSteps: [], + completed: false + }); + // Dummy placeholder steps shown in the indicator until the user + // picks a method. They are replaced with real steps for the + // chosen connectivity in confirmConnectivity(). + for (const key of [ + 'iot-hub.step-prerequisites', + 'iot-hub.step-configuration', + 'iot-hub.step-provisioning' + ]) { + this.wizardSteps.push({ + type: 'placeholder', + label: this.translate.instant(key), + rawSteps: [], + completed: false + }); + } + return; + } + this.appendInstallSteps(); + } + + private appendInstallSteps(): void { + const rawSteps = this.packageInfo.installSteps[this.selectedInstallMethod] || []; let i = 0; while (i < rawSteps.length) { const step = rawSteps[i]; @@ -582,7 +627,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent +@if (formGroup) { +
+ @for (field of fields; track field.key; let i = $index) { + + + @switch (field.type) { + @case (FormFieldType.BOOLEAN) { + {{ field.label }} + } + @case (FormFieldType.SELECT) { + + {{ field.label }} + + @for (opt of field.options; track opt.value) { + {{ opt.label }} + } + + @if (field.helpText) { + {{ field.helpText }} + } + @if (formGroup.controls[field.key]?.hasError('required')) { + {{ field.label + ' is required' }} + } + + } + @case (FormFieldType.PASSWORD) { + + {{ field.label }} + + + @if (field.randomGenerator) { + + } + @if (field.helpText) { + {{ field.helpText }} + } + @if (formGroup.controls[field.key]?.hasError('required')) { + {{ field.label + ' is required' }} + } + @if (formGroup.controls[field.key]?.hasError('pattern')) { + {{ getPatternErrorMessage(field) }} + } + + } + @case (FormFieldType.INTEGER) { + + {{ field.label }} + + @if (field.helpText) { + {{ field.helpText }} + } + @if (formGroup.controls[field.key]?.hasError('required')) { + {{ field.label + ' is required' }} + } + @if (formGroup.controls[field.key]?.hasError('pattern')) { + {{ getPatternErrorMessage(field) }} + } + + } + @default { + + {{ field.label }} + + @if (field.randomGenerator) { + + } + @if (field.helpText) { + {{ field.helpText }} + } + @if (formGroup.controls[field.key]?.hasError('required')) { + {{ field.label + ' is required' }} + } + @if (formGroup.controls[field.key]?.hasError('pattern')) { + {{ getPatternErrorMessage(field) }} + } + + } + } + + @if (field.type === FormFieldType.BOOLEAN && field.helpText) { +
{{ field.helpText }}
+ } + @if (field.helpImage) { + + } + } +
+} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/install-form-renderer/install-form-renderer.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/install-form-renderer/install-form-renderer.component.scss new file mode 100644 index 0000000000..daaa824570 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/install-form-renderer/install-form-renderer.component.scss @@ -0,0 +1,29 @@ +/** + * 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; +} + +.tb-install-form-field-help { + font-size: 12px; + color: rgba(0, 0, 0, 0.6); + margin-top: 4px; +} + +.tb-install-form-field-help-image { + max-width: 100%; + margin-top: 8px; +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/install-form-renderer/install-form-renderer.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/install-form-renderer/install-form-renderer.component.ts new file mode 100644 index 0000000000..ea129ce0ea --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/install-form-renderer/install-form-renderer.component.ts @@ -0,0 +1,82 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { UntypedFormGroup } from '@angular/forms'; +import { FormFieldDefinition, FormFieldType } from '@shared/models/iot-hub/device-package.models'; +import { generateSecret } from '@core/utils'; + +const DEFAULT_RANDOM_SIZE = 20; + +/** + * Reusable form renderer for the device install dialog's SHOW_FORM step. Consumes + * the FormFieldDefinition[] parsed from the package's form.json directly. + * + * The renderer is presentation-only. The caller owns the FormGroup and supplies + * the FormFieldDefinition[] array. Optional resolveImagePath callback maps + * relative help-image paths to data URIs (the dialog supplies it from the parsed + * package ZIP). + */ +@Component({ + selector: 'tb-install-form-renderer', + templateUrl: './install-form-renderer.component.html', + styleUrls: ['./install-form-renderer.component.scss'], + standalone: false +}) +export class InstallFormRendererComponent implements OnChanges { + + @Input() fields: FormFieldDefinition[] = []; + @Input() formGroup!: UntypedFormGroup; + @Input() resolveImagePath?: (path: string) => string; + /** When true, PASSWORD inputs render unmasked by default (used by review mode). */ + @Input() reviewMode = false; + + passwordVisible: Record = {}; + + readonly FormFieldType = FormFieldType; + + ngOnChanges(changes: SimpleChanges): void { + if (changes.fields || changes.reviewMode) { + this.passwordVisible = {}; + if (this.reviewMode) { + for (const f of this.fields) { + if (f.type === FormFieldType.PASSWORD) { + this.passwordVisible[f.key] = true; + } + } + } + } + } + + togglePasswordVisible(key: string): void { + this.passwordVisible[key] = !this.passwordVisible[key]; + } + + regenerate(field: FormFieldDefinition): void { + const control = this.formGroup.controls[field.key]; + if (!control) return; + control.patchValue(generateSecret(field.randomSize ?? DEFAULT_RANDOM_SIZE)); + control.markAsDirty(); + } + + getPatternErrorMessage(field: FormFieldDefinition): string { + return field.validators?.[0]?.message || 'Invalid format'; + } + + imagePath(path: string): string { + return this.resolveImagePath ? this.resolveImagePath(path) : path; + } +} 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 94bc1c5610..0a59797d00 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 @@ -26,11 +26,16 @@ import { TbIotHubUpdateDialogComponent } from './iot-hub-update-dialog.component import { TbIotHubDeleteDialogComponent } from './iot-hub-delete-dialog.component'; import { TbIotHubUnpublishedWarningDialogComponent } from './iot-hub-unpublished-warning-dialog.component'; import { TbDeviceInstallDialogComponent } from './device-install-dialog/device-install-dialog.component'; +import { InstallFormRendererComponent } from './device-install-dialog/install-form-renderer/install-form-renderer.component'; 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 { TbIotHubSelectCfEntityDialogComponent } from './iot-hub-select-cf-entity-dialog.component'; +import { TbPeConnectivityMethodPromptComponent } from './pe-connectivity-method-prompt.component'; +import { TbIotHubMarkdownComponent } from './iot-hub-markdown.component'; +import { SolutionInstallDialogComponent } from './solution-install-dialog.component'; import { IotHubActionsService } from './iot-hub-actions.service'; +import { IotHubItemLinkModule } from './iot-hub-item-link-card/iot-hub-item-link.module'; @NgModule({ declarations: [ @@ -46,11 +51,16 @@ import { IotHubActionsService } from './iot-hub-actions.service'; TbIotHubSearchComponent, TbIotHubInstalledItemsTableComponent, TbIotHubInstalledItemsDialogComponent, - TbIotHubSelectCfEntityDialogComponent + TbIotHubSelectCfEntityDialogComponent, + TbPeConnectivityMethodPromptComponent, + TbIotHubMarkdownComponent, + SolutionInstallDialogComponent, + InstallFormRendererComponent ], imports: [ CommonModule, - SharedModule + SharedModule, + IotHubItemLinkModule ], providers: [ IotHubActionsService @@ -68,7 +78,10 @@ import { IotHubActionsService } from './iot-hub-actions.service'; TbIotHubSearchComponent, TbIotHubInstalledItemsTableComponent, TbIotHubInstalledItemsDialogComponent, - TbIotHubSelectCfEntityDialogComponent + TbIotHubSelectCfEntityDialogComponent, + TbPeConnectivityMethodPromptComponent, + TbIotHubMarkdownComponent, + SolutionInstallDialogComponent ] }) export class IotHubComponentsModule { } diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html index 0bae4f8d60..c55d3c4655 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.html @@ -45,7 +45,7 @@

{{ 'iot-hub.install-error-title' | translate }}

{{ 'iot-hub.install-error-message' | translate:{ name: item.name } }}

@if (item?.type === ItemType.SOLUTION_TEMPLATE) { - + } @else {
{{ errorMessage }} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts index 4b56427f06..cc27cffd4d 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-install-dialog.component.ts @@ -28,7 +28,7 @@ import { TranslateService } from '@ngx-translate/core'; import { EntityType } from '@shared/models/entity-type.models'; import { EntityId } from '@shared/models/id/entity-id'; import { resolveEntityDetailsUrl } from './iot-hub-components.models'; -import { SolutionInstallDialogComponent } from '@home/components/solution/solution-install-dialog.component'; +import { SolutionInstallDialogComponent } from '@home/components/iot-hub/solution-install-dialog.component'; export interface IotHubInstallDialogData { item: MpItemVersionView; 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..c72e209037 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 @@ -85,7 +85,7 @@
@if (item.description) {
- +
}
@@ -150,7 +150,7 @@
@if (item.description) {
- +
}
@@ -299,11 +299,11 @@ @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.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss index 4370d57586..f95b4073c7 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-item-detail-dialog.component.scss @@ -166,23 +166,11 @@ } -// Description — rendered via tb-markdown, same overrides as dlg-readme +// Description — strip padding from tb-iot-hub-markdown so the body +// sits flush with the surrounding info layout. .dlg-description { - ::ng-deep tb-markdown .tb-markdown-view { + ::ng-deep tb-iot-hub-markdown .tb-markdown-view { padding: 0; - - > p, > div { - padding: 0; - } - - p { - font-size: 14px; - font-weight: 400; - line-height: 24px; - letter-spacing: 0.2px; - color: rgba(0, 0, 0, 0.76); - margin: 0; - } } } @@ -451,135 +439,12 @@ color: #198038; } -// Readme content — Design: px-24, pt-16, pb-24, gap-20 +// Readme content — wraps tb-iot-hub-markdown, which carries the +// markdown-view styles. Only the changelog heading layout lives here. .dlg-readme { - font-size: 14px; - line-height: 24px; - letter-spacing: 0.2px; - color: rgba(0, 0, 0, 0.76); - > h3 { padding-left: 24px; } - - // Override markdown-view styles to match Design: px-24, pt-16, pb-24, gap-20 - ::ng-deep tb-markdown .tb-markdown-view { - padding: 16px 24px 24px; - - // Headings: Design:20px SemiBold, line-height 24, tracking 0.1 - // Override markdown-view's padding: 30px 32px 10px on direct headings - h1, h2, h3, h4, h5, h6 { - font-size: 20px; - font-weight: 600; - line-height: 24px; - letter-spacing: 0.1px; - color: rgba(0, 0, 0, 0.76); - margin: 0; - padding: 0; - } - - // Direct child headings: override markdown-view's > h padding - > h1, > h2, > h3, > h4, > h5, > h6 { - padding: 0; - margin-top: 20px; - } - - // First heading: no top margin - > :first-child { - margin-top: 0; - } - - h1 { - font-size: 24px; - line-height: 32px; - padding-right: 0; - } - - // Paragraphs: Design:14px Regular, tracking 0.2, line-height 24 - // Override markdown-view's font-size: 16px and padding - p { - font-size: 14px; - font-weight: 400; - line-height: 24px; - letter-spacing: 0.2px; - color: rgba(0, 0, 0, 0.76); - margin: 0; - } - - > p, > div { - padding-left: 0; - padding-right: 0; - } - - p + p { - margin-top: 8px; - } - - // Heading followed by content: 8px gap - h1 + p, h2 + p, h3 + p, - h1 + ul, h2 + ul, h3 + ul, - h1 + ol, h2 + ol, h3 + ol { - margin-top: 8px; - } - - // Lists: Design:ms-21 per li, 14px, line-height 24, no extra margin/padding - // Override markdown-view's padding-left: 62px, margin: 16px - ul, ol { - padding-left: 21px; - padding-right: 0; - margin: 0; - font-size: 14px; - line-height: 24px; - letter-spacing: 0.2px; - color: rgba(0, 0, 0, 0.76); - - // Override markdown-view's heading-after-list padding - + h1, + h2, + h3, + h4, + h5, + h6 { - padding-top: 0; - margin-top: 20px; - } - } - - // List items: override markdown-view's padding-bottom: .75em - li { - padding-bottom: 0; - margin-bottom: 0; - line-height: 24px; - } - - // Code blocks: keep smaller font - code:not([class*=language-]) { - font-size: 14px; - } - - .tb-doc-link-btn { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 8px 16px; - margin: 4px 8px 4px 0; - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 6px; - background: #fff; - color: rgba(0, 0, 0, 0.76); - font-size: 14px; - font-weight: 500; - line-height: 20px; - text-decoration: none; - cursor: pointer; - - i.material-icons { - font-size: 18px; - line-height: 18px; - color: rgba(0, 0, 0, 0.54); - } - - &:hover { - background: rgba(0, 0, 0, 0.04); - border-color: rgba(0, 0, 0, 0.24); - } - } - } } // Carousel (solution templates) 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 9aaffbd3a6..1579bc763c 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'; @@ -24,11 +24,10 @@ import { MpItemVersionView, cfTypeTranslations, cfTypeIcons, ruleChainTypeTransl import { ItemType, itemTypeTranslations } from '@shared/models/iot-hub/iot-hub-item.models'; import { IotHubInstalledItem } from '@shared/models/iot-hub/iot-hub-installed-item.models'; import { IotHubApiService } from '@core/http/iot-hub-api.service'; -import { resolveDocLinkPlaceholders } from '@shared/models/iot-hub/device-package.models'; import { TranslateService } from '@ngx-translate/core'; import { EntityType } from '@shared/models/entity-type.models'; import { getEntityDetailsPageURL } from '@core/utils'; -import { SolutionInstallDialogComponent } from '@home/components/solution/solution-install-dialog.component'; +import { SolutionInstallDialogComponent } from '@home/components/iot-hub/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'; @@ -307,22 +306,8 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent this.readmeContent = this.resolveDocLinks(this.prefixResourceUrls(content || '')) + content => this.readmeContent = content ); } - private prefixResourceUrls(markdown: string): string { - const baseUrl = this.iotHubApiService.baseUrl; - return markdown.replace(/([("])(\/api\/resources\/[^)"]*)/g, `$1${baseUrl}$2`); - } - - private resolveDocLinks(markdown: string): string { - const dd = this.item.dataDescriptor; - return resolveDocLinkPlaceholders( - markdown, - this.item.name || dd?.name || '', - { productURL: dd?.productURL, datasheetURL: dd?.datasheetURL }, - { productPage: 'Product page', datasheet: 'Datasheet' } - ); - } } 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.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.html new file mode 100644 index 0000000000..a7c2d3bd04 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.html @@ -0,0 +1,25 @@ + + + diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.scss new file mode 100644 index 0000000000..57e3d25101 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.scss @@ -0,0 +1,461 @@ +/** + * 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. + */ + +$text-color: rgba(0,0,0,0.76); +$link-color: #2a7dec; +$copy-button-color: #2a7dec; + +$code-block-border-color: rgba(42, 125, 236, .2); +$code-default-color: #212529; +$code-text-color: #eb5757; +$code-keyword-color: #2a7dec; + +$table-border-color: rgba(42, 125, 236, .2); +$table-header-background-color: #f9fbff; +$table-header-text-color: rgba(33, 37, 41, .6); +$table-text-color: rgba(15, 22, 29, 0.8); + +$gallery-border-hover-color: #2a7dec; + +:host { + display: block; + font-size: 14px; + line-height: 24px; + letter-spacing: 0.2px; + color: $text-color; + + --mat-button-outlined-label-text-color: #{$text-color}; + + ::ng-deep tb-markdown .tb-markdown-view { + padding: 16px 24px 24px; + &.table-wrapper { + padding: 0; + } + &:not(.table-wrapper) { + > :first-child { + margin-top: 0; + } + } + + // Headings: design 20px SemiBold, line-height 24, tracking 0.1. + h1, h2, h3, h4, h5, h6 { + font-size: 20px; + font-weight: 600; + line-height: 24px; + letter-spacing: 0.1px; + color: $text-color; + margin: 0; + padding: 0; + } + + > h1, > h2, > h3, > h4, > h5, > h6 { + padding: 0 0 6px; + margin-top: 20px; + } + + h1 { + font-size: 24px; + line-height: 32px; + padding-right: 0; + } + + // Paragraphs: design 14px Regular, tracking 0.2, line-height 24. + p { + font-size: 14px; + font-weight: 400; + line-height: 24px; + letter-spacing: 0.2px; + color: $text-color; + margin: 0; + } + + > p, > div { + padding-left: 0; + padding-right: 0; + } + + p + p { + margin-top: 8px; + } + + h1 + p, h2 + p, h3 + p, + h1 + ul, h2 + ul, h3 + ul, + h1 + ol, h2 + ol, h3 + ol { + margin-top: 8px; + } + + // Lists: design ms-21 per li, 14px, line-height 24. + ul, ol { + padding-left: 21px; + padding-right: 0; + margin: 0; + font-size: 14px; + line-height: 24px; + letter-spacing: 0.2px; + color: $text-color; + + + h1, + h2, + h3, + h4, + h5, + h6 { + padding-top: 0; + margin-top: 20px; + } + } + + ul { + margin-top: 8px; + margin-bottom: 8px; + } + + li { + padding-bottom: 4px; + margin-bottom: 0; + line-height: 24px; + } + + img { + max-width: 100%; + } + + a:not(.mdc-button) { + font-weight: 500; + color: $link-color; + text-decoration: none; + border: none; + + &:hover { + color: $link-color; + text-decoration: underline; + border: none; + } + } + + code:not([class*=language-]) { + color: $code-text-color; + font-family: monospace; + font-size: 14px; + } + + // Code-wrapper + clipboard button + div.code-wrapper { + position: relative; + + button.clipboard-btn { + pointer-events: none; + outline: none; + position: absolute; + width: 206px; + height: 32px; + top: 0; + right: 0; + background: 0 0; + border: none; + user-select: none; + + &.multiline { + right: 6px; + } + + p { + padding: 6px 8px 0; + top: 1px; + transition: .2s; + color: $copy-button-color; + background: rgba(255, 255, 255, .85); + backdrop-filter: blur(4px); + opacity: 0; + font-weight: 500; + right: 32px; + position: absolute; + } + + div { + background-color: #fff; + position: absolute; + width: 38px; + height: 28px; + top: 3px; + right: 3px; + padding: 8px 10px 0; + + img { + position: initial; + width: 18px; + height: 18px; + filter: invert(51%) sepia(6%) saturate(172%) hue-rotate(177deg) brightness(94%) contrast(92%); + } + } + } + + &:hover { + cursor: pointer; + + pre[class*="language-"] { + border: solid 1px $copy-button-color; + } + + button.clipboard-btn { + p { + opacity: 1; + } + + div img { + filter: invert(49%) sepia(97%) saturate(3730%) hue-rotate(200deg) brightness(95%) contrast(95%); + } + } + } + } + + th, td { + div.code-wrapper { + display: inline-block; + width: 100%; + + button.clipboard-btn { + top: -6px; + padding: 0 3px; + } + } + } + + // Code blocks (Prism) + pre[class*="language-"] { + font-size: 14px; + border: 1px solid $code-block-border-color; + border-radius: 4px; + background: 0 0; + padding: 8px 16px; + color: $code-default-color; + + .token.atrule, .token.attr-value, .token.keyword { + color: $code-keyword-color; + } + + .token.selector, .token.attr-name, .token.string, .token.char, .token.builtin, .token.inserted { + color: $code-text-color; + } + + .token.punctuation { + color: $code-default-color; + } + + &.line-numbers { + padding-left: 66px; + + > code span.line-numbers-rows { + top: -11px; + bottom: -11px; + left: -66px; + width: 50px; + border: none; + padding: 8px 12px 8px 18px; + text-align: right; + background: #f9fbff; + + > span:before { + color: rgba(33, 37, 41, .6); + padding-right: 0; + } + } + + &.no-line-numbers { + padding-left: 16px; + + > code span.line-numbers-rows { + display: none; + } + } + } + } + + // Tables + > table { + width: 100%; + border: 1px solid $table-border-color; + border-radius: 4px; + border-collapse: unset; + border-spacing: 0; + margin-top: 20px; + margin-bottom: 20px; + overflow: hidden; + table-layout: fixed; + + &.auto { + table-layout: auto; + } + + > thead { + background-color: $table-header-background-color; + color: $table-header-text-color; + + > tr > th { + border-bottom: 1px solid $table-border-color; + font-size: 14px; + padding: 8px 12px; + text-align: left; + margin: 0; + @media screen and (max-width: 400px) { + font-size: 12px; + padding: 8px 4px; + code:not([class*=language-]) { + font-size: 12px; + } + } + } + } + + > tbody { + > tr:not(:last-child) > td { + border-bottom: 1px solid $table-border-color; + } + + > tr > td { + font-size: 14px; + padding: 8px 12px; + text-align: left; + margin: 0; + color: $table-text-color; + @media screen and (max-width: 400px) { + font-size: 12px; + padding: 8px 4px; + code:not([class*=language-]) { + font-size: 12px; + } + } + } + } + + th, td { + font-size: 14px; + padding: 8px; + margin: 0; + text-align: left; + } + + td[align=center], th[align=center] { + text-align: center; + } + + td[align=right], th[align=right] { + text-align: right; + } + + tr td div { + padding-left: 0; + padding-right: 0; + } + } + + .tb-callout { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 16px; + border-radius: 4px; + border: 1px solid; + margin: 12px 0; + font-size: 14px; + line-height: 20px; + + .tb-callout-icon { + font-size: 20px; + flex-shrink: 0; + } + + &.tb-callout-note { + background: #e3f2fd; + border-color: #90caf9; + color: #1565c0; + } + + &.tb-callout-warn { + background: #fff8e1; + border-color: #ffe082; + color: #f57f17; + } + + &.tb-callout-error { + background: #fce4ec; + border-color: #ef9a9a; + color: #c62828; + } + } + + .tb-gallery-images { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; + margin-top: 8px; + margin-bottom: 8px; + + .tb-gallery-image { + position: relative; + cursor: pointer; + border: 2px solid #c1c3c8; + border-radius: 8px; + overflow: hidden; + margin: 0; + padding: 0; + background: #fff; + transition: border-color .2s ease, transform .2s ease, box-shadow .2s ease; + display: block; + &:hover { + border-color: $gallery-border-hover-color; + transform: translateY(-2px); + box-shadow: 0 6px 18px #0000002e; + outline: none; + .tb-image-tooltip { + opacity: 1; + transform: translateY(0); + } + } + .tb-image-container { + display: block; + height: 115px; + overflow: hidden; + background: #fff; + img { + width: 100%; + max-width: 100%; + height: 100%; + object-fit: contain; + object-position: top center; + display: block; + margin: 0; + padding: 0; + } + } + .tb-image-tooltip { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: .4rem .6rem; + background: #000000b8; + color: #fff; + font-size: .75rem; + line-height: 1.3; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0; + transform: translateY(4px); + transition: opacity .2s ease, transform .2s ease; + pointer-events: none; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.ts new file mode 100644 index 0000000000..57df5a8074 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.component.ts @@ -0,0 +1,212 @@ +/// +/// 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, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges, + Type +} from '@angular/core'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { IotHubItemLinkModule } from './iot-hub-item-link-card/iot-hub-item-link.module'; +import { IotHubApiService } from '@core/http/iot-hub-api.service'; +import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; +import { + escapeHtmlAttr, + replaceItemLinkPlaceholders, + resolveDocLinkPlaceholders, + sanitizeInlineHtml +} from '@home/components/iot-hub/iot-hub-markdown.utils'; +import { DevicePackageInfo } from '@shared/models/iot-hub/device-package.models'; +import { isNotEmptyStr } from '@core/utils'; + +@Component({ + selector: 'tb-iot-hub-markdown', + standalone: false, + templateUrl: './iot-hub-markdown.component.html', + styleUrls: ['./iot-hub-markdown.component.scss'] +}) +export class TbIotHubMarkdownComponent implements OnInit, OnChanges { + + @Input() data: string | undefined; + + @Input() item: MpItemVersionView | undefined; + + @Input() packageInfo: DevicePackageInfo | undefined; + + @Input() imageMap: Map | undefined; + + @Input() onResolveVariable: (key: string) => string | undefined = () => undefined; + + @Input() + @coerceBoolean() + lineNumbers = false; + + @Input() + @coerceBoolean() + fallbackToPlainMarkdown = false; + + @Input() + codeBlockMaxHeightPx: number; + + @Output() ready = new EventEmitter(); + + additionalStyles: string[] = []; + + parsedData: string; + + readonly itemLinkCompileModules: Type[] = [IotHubItemLinkModule]; + + constructor( + private iotHubApiService: IotHubApiService, + private elementRef: ElementRef + ) {} + + ngOnInit(): void { + if (this.codeBlockMaxHeightPx) { + const codeBlockMaxHeightStyle = `pre[class*="language-"] \n{ + max-height: ${this.codeBlockMaxHeightPx}px;\n + }`; + this.additionalStyles.push(codeBlockMaxHeightStyle); + } + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (propName === 'data' && change.currentValue !== change.previousValue) { + this.parsedData = this.parseData(this.data); + } + } + } + + onReady() { + const container = this.elementRef.nativeElement; + this.ready.emit(container); + } + + private parseData(content: string | undefined): string { + let parsed = this.prefixResourceUrls(content || ''); + parsed = this.resolveDocLinks(parsed); + parsed = replaceItemLinkPlaceholders(parsed); + parsed = this.resolveImages(parsed); + parsed = this.resolveVariables(parsed); + return parsed; + } + + private prefixResourceUrls(markdown: string): string { + const baseUrl = this.iotHubApiService.baseUrl; + return markdown.replace(/([("])(\/api\/resources\/[^)"]*)/g, `$1${baseUrl}$2`); + } + + private resolveDocLinks(markdown: string): string { + if (this.item || this.packageInfo) { + const dd = this.item ? this.item.dataDescriptor : this.packageInfo; + return resolveDocLinkPlaceholders( + markdown, + this.item?.name || dd?.name || '', + { productURL: dd?.productURL, datasheetURL: dd?.datasheetURL }, + { productPage: 'Product page', datasheet: 'Datasheet' } + ); + } else { + return markdown; + } + } + + private resolveImages(content: string): string { + if (this.imageMap) { + return content.replace(/!\[([^\]]*)]\(([^)]+)\)/g, (match, alt, path) => { + if (path.startsWith('data:') || path.startsWith('http')) { + return match; + } + const dataUri = this.imageMap.get(path); + return dataUri ? `![${alt}](${dataUri})` : match; + }); + } else { + return content; + } + } + + private resolveImage(uri: string): string { + if (this.imageMap && uri) { + if (uri.startsWith('data:') || uri.startsWith('http')) { + return uri; + } + const dataUri = this.imageMap.get(uri); + return dataUri ? dataUri : uri; + } else { + return uri; + } + } + + private resolveVariables(content: string): string { + // Image gallery is handled first because its inner ${...} contents + // may include nested braces and span multiple lines, which the + // generic ${key} regex below cannot parse. + // + // Format: ${images.gallery({src: 'p1', alt: 'a1', caption: 'c1'}, {src: 'p2'}, ...)} + // Each image entry is a JS object literal with a required `src` + // and optional `alt` / `caption` string fields. + content = content.replace(/\$\{\s*images\.gallery\(([\s\S]*?)\)\s*}/g, (_match, inner: string) => { + const objects: string[] = inner.match(/\{[\s\S]*?}/g) || []; + const items = objects + .map((obj: string) => { + const src = (obj.match(/src\s*:\s*(['"])((?:(?!\1).)*)\1/) || [])[2] || ''; + const alt = (obj.match(/alt\s*:\s*(['"])((?:(?!\1).)*)\1/) || [])[2] || ''; + const caption = (obj.match(/caption\s*:\s*(['"])((?:(?!\1).)*)\1/) || [])[2] || ''; + return { src: this.resolveImage(src), alt, caption }; + }) + .filter((item: { src?: string }) => !!item.src); + const images = items + .map(item => { + let galleryImageHtml = ``; + return galleryImageHtml; + }) + .join(''); + return ``; + }); + + return content.replace(/\$\{([^}]+)}/g, (_match, key) => { + // Callout boxes: ${note(...)}, ${warn(...)}, ${error(...)} + const calloutMatch = key.match(/^(note|warn|error)\((.+)\)$/s); + if (calloutMatch) { + const type = calloutMatch[1]; + const text = calloutMatch[2]; + const icons: Record = { note: 'info_outline', warn: 'warning_amber', error: 'error_outline' }; + return `
${icons[type]}${text}
`; + } + + // Special variables + const res = this.onResolveVariable(key); + if (res) { + return res; + } + return '${' + key + '}'; + }); + } +} 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..6685a898f5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-markdown.utils.ts @@ -0,0 +1,124 @@ +/// +/// 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 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)); +} + +export interface DocLinks { + productURL?: string; + datasheetURL?: string; +} + +export interface DocLinkLabels { + productPage: string; + datasheet: string; +} + +export function resolveDocLinkPlaceholders( + markdown: string, + name: string, + links: DocLinks, + labels: DocLinkLabels +): string { + return markdown + .replace(/\$\{product\.button}/g, () => + links.productURL ? buildDocLinkButton(links.productURL, `${name} ${labels.productPage}`, 'open_in_new') : '') + .replace(/\$\{datasheet\.button}/g, () => + links.datasheetURL ? buildDocLinkButton(links.datasheetURL, `${name} ${labels.datasheet}`, 'description') : ''); +} + +function buildDocLinkButton(url: string, text: string, icon: string): string { + const safeUrl = escapeHtmlAttr(url); + const safeText = escapeHtml(text); + return `` + + `${icon}${safeText}`; +} + +export function escapeHtml(value: string): string { + return value.replace(/[&<>]/g, ch => ch === '&' ? '&' : ch === '<' ? '<' : '>'); +} + +export function escapeHtmlAttr(value: string): string { + return value.replace(/[&<>"']/g, ch => { + switch (ch) { + case '&': return '&'; + case '<': return '<'; + case '>': return '>'; + case '"': return '"'; + default: return '''; + } + }); +} + +// Inline-only tags + a small attribute whitelist that are safe to keep +// in caption strings (or other small bits of user-authored HTML inside +// generated markdown). Anything else is dropped — disallowed tags are +// replaced by their text content and disallowed attributes are removed. +const SAFE_INLINE_TAGS: ReadonlySet = new Set([ + 'B', 'STRONG', 'I', 'EM', 'U', 'S', 'MARK', + 'SMALL', 'SUB', 'SUP', 'BR', 'CODE', 'SPAN' +]); + +const SAFE_INLINE_ATTRS: ReadonlySet = new Set(['class', 'style']); + +export function sanitizeInlineHtml(value: string): string { + if (!value) { + return ''; + } + const doc = new DOMParser().parseFromString(`
${value}
`, 'text/html'); + const root = doc.body.firstElementChild as HTMLElement | null; + if (!root) { + return ''; + } + const sanitize = (parent: Element): void => { + const children = Array.from(parent.childNodes); + for (const node of children) { + if (node.nodeType !== Node.ELEMENT_NODE) { + continue; + } + const el = node as Element; + if (!SAFE_INLINE_TAGS.has(el.tagName)) { + // Replace disallowed elements with their plain text content. + parent.replaceChild(doc.createTextNode(el.textContent || ''), el); + continue; + } + for (const attr of Array.from(el.attributes)) { + if (!SAFE_INLINE_ATTRS.has(attr.name)) { + el.removeAttribute(attr.name); + continue; + } + if (attr.name === 'style' && /(expression\s*\(|javascript:|url\s*\()/i.test(attr.value)) { + el.removeAttribute(attr.name); + } + } + sanitize(el); + } + }; + sanitize(root); + return root.innerHTML; +} + diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.html index e21abb8663..78e8fc3b66 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.html @@ -33,7 +33,7 @@

{{ 'iot-hub.update-error-title' | translate }}

{{ 'iot-hub.update-error-message' | translate:{ name: data.itemName } }}

@if (data?.itemType === ItemType.SOLUTION_TEMPLATE) { - + } @else {
{{ errorMessage }} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.ts index 71f7b93716..e11528aa6b 100644 --- a/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/iot-hub-update-dialog.component.ts @@ -26,7 +26,7 @@ import { IotHubApiService } from '@core/http/iot-hub-api.service'; import { DialogService } from '@core/services/dialog.service'; import { TranslateService } from '@ngx-translate/core'; import { resolveEntityDetailsUrl } from './iot-hub-components.models'; -import { SolutionInstallDialogComponent } from '@home/components/solution/solution-install-dialog.component'; +import { SolutionInstallDialogComponent } from '@home/components/iot-hub/solution-install-dialog.component'; export interface IotHubUpdateDialogData { installedItemId: string; diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/pe-connectivity-method-prompt.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/pe-connectivity-method-prompt.component.html new file mode 100644 index 0000000000..1c4b8e25b1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/pe-connectivity-method-prompt.component.html @@ -0,0 +1,38 @@ + +
+ + +

+ {{ data.connectorName }} + {{ 'iot-hub.pe-connectivity-prompt-title-suffix' | translate }} +

+
+ + {{ 'iot-hub.pe-connectivity-prompt-try-pe' | translate }} + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/pe-connectivity-method-prompt.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/pe-connectivity-method-prompt.component.scss new file mode 100644 index 0000000000..a66a867374 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/pe-connectivity-method-prompt.component.scss @@ -0,0 +1,65 @@ +/** + * 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 "../../../../../scss/constants"; + +:host { + display: block; + width: 500px; + max-width: 100%; +} + +.tb-pe-connectivity-prompt { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 20px; + padding: 40px; + background: white; + border-radius: 8px; +} + +.tb-pe-connectivity-prompt-close { + position: absolute; + top: 8px; + right: 8px; +} + +.tb-pe-connectivity-prompt-illustration { + width: 140px; + height: 140px; +} + +.tb-pe-connectivity-prompt-title { + font-size: 24px; + font-weight: 500; + line-height: 32px; + letter-spacing: 0.15px; + color: rgba(0, 0, 0, 0.87); + margin: 0; +} + +.tb-pe-connectivity-prompt-connector { + color: $tb-primary-color; +} + +.tb-pe-connectivity-prompt-actions { + display: flex; + align-items: center; + gap: 8px; +} diff --git a/ui-ngx/src/app/modules/home/components/iot-hub/pe-connectivity-method-prompt.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/pe-connectivity-method-prompt.component.ts new file mode 100644 index 0000000000..11173a21cb --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/iot-hub/pe-connectivity-method-prompt.component.ts @@ -0,0 +1,48 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DialogComponent } from '@shared/components/dialog.component'; + +export interface PeConnectivityMethodPromptData { + connectorName: string; +} + +@Component({ + selector: 'tb-pe-connectivity-method-prompt', + standalone: false, + templateUrl: './pe-connectivity-method-prompt.component.html', + styleUrls: ['./pe-connectivity-method-prompt.component.scss'] +}) +export class TbPeConnectivityMethodPromptComponent extends DialogComponent { + + constructor( + protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: PeConnectivityMethodPromptData + ) { + super(store, router, dialogRef); + } + + close(): void { + this.dialogRef.close(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html b/ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.html similarity index 73% rename from ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html rename to ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.html index e5736bf4f3..243ae759d3 100644 --- a/ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.html @@ -16,22 +16,25 @@ -->
- -

@if (instructions) {info_outline}{{ (instructions ? 'iot-hub.solution-instructions' : 'iot-hub.solution-install-title') | translate }}

+
+ @if (instructions) {info_outline} +
{{ (instructions ? 'iot-hub.solution-instructions' : 'iot-hub.solution-install-title') | translate }}
- +
- +
- @if (dashboardId) { diff --git a/ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.scss b/ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.scss similarity index 56% rename from ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.scss rename to ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.scss index d1af6f174c..ae8f543c10 100644 --- a/ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.scss @@ -17,47 +17,49 @@ @import "../../../../../scss/constants"; :host { - h2 { - .mat-icon { - padding-right: 8px; - box-sizing: initial; - vertical-align: sub; - } + .dlg-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 12px 8px 24px; + flex-shrink: 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + } + .dlg-title { + font-size: 24px; + font-weight: 500; + line-height: 32px; + color: rgba(0, 0, 0, 0.87); + } + .mat-mdc-dialog-actions { + border-top: 1px solid rgba(0, 0, 0, 0.12); } } .tb-dialog-content { max-width: 100%; + width: 1200px; display: flex; flex-direction: column; + @media #{$mat-lt-xxl} { + width: 900px; + } + + @media #{$mat-lt-lg} { + width: 768px; + } + + @media #{$mat-lt-md} { + width: 100%; + } + @media #{$mat-xs} { min-width: 100% !important; height: 100%; } } -:host ::ng-deep { - .mat-mdc-dialog-content { - tb-markdown { - .tb-markdown-view { - .table-wrapper { - overflow-y: auto; - margin: 30px 32px; - border: 1px solid rgba(42, 125, 236, .2); - border-radius: 4px; - padding: 0; - width: calc(100% - 64px); - table { - width: 100%; - table-layout: auto; - white-space: nowrap; - margin: 0; - border: 0; - border-radius: 0; - } - } - } - } - } +:host .tb-dialog-content ::ng-deep tb-iot-hub-markdown .tb-markdown-view { + padding: 0; } diff --git a/ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts b/ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.ts similarity index 91% rename from ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts rename to ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.ts index 5ef74c4879..12539d039e 100644 --- a/ui-ngx/src/app/modules/home/components/solution/solution-install-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/iot-hub/solution-install-dialog.component.ts @@ -18,6 +18,9 @@ import { Component, Inject } 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 { + replaceItemLinkPlaceholders +} from '@home/components/iot-hub/iot-hub-markdown.utils'; export interface SolutionInstallDialogData { descriptor: SolutionTemplateInstalledItemDescriptor; @@ -41,7 +44,7 @@ export class SolutionInstallDialogComponent { private dialogRef: MatDialogRef, 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; } diff --git a/ui-ngx/src/app/shared/directives/photoswipe-gallery.directive.ts b/ui-ngx/src/app/shared/directives/photoswipe-gallery.directive.ts new file mode 100644 index 0000000000..f4bd39e72b --- /dev/null +++ b/ui-ngx/src/app/shared/directives/photoswipe-gallery.directive.ts @@ -0,0 +1,219 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Directive, ElementRef, Input, OnDestroy, OnInit } from '@angular/core'; +import PhotoSwipeLightbox from 'photoswipe/lightbox'; +import PhotoSwipe from 'photoswipe'; +import cssjs from '@core/css/css'; + +const PHOTO_GALLERY_STYLE_ID = 'photoswipe-gallery-style'; +const PHOTO_GALLERY_CLASS = 'tb-photoswipe-gallery'; +const PHOTO_GALLERY_STYLE = + '{\n'+ + ' background: rgba(10, 10, 20, 0.55);\n' + + ' backdrop-filter: blur(18px);\n' + + ' opacity: 1;\n' + + '}\n' + + '\n' + + '.pswp__tb-photoswipe-caption {\n' + + ' position: fixed;\n' + + ' bottom: 1.5rem;\n' + + ' left: 50%;\n' + + ' transform: translate(-50%);\n' + + ' z-index: 10000;\n' + + ' display: flex;\n' + + ' flex-direction: column;\n' + + ' align-items: center;\n' + + ' gap: .25rem;\n' + + ' max-width: 80vw;\n' + + ' text-align: center;\n' + + ' pointer-events: none;\n' + + ' line-height: 1.75;\n' + + '}\n' + + '\n' + + '.pswp__tb-photoswipe-caption .tb-gallery-caption {\n' + + ' color: #fff;\n' + + ' font-size: 1.125rem;\n' + + ' line-height: 1.5;\n' + + ' background: #000000a6;\n' + + ' padding: .5rem 1.25rem;\n' + + ' border-radius: 8px;\n' + + ' backdrop-filter: blur(8px);\n' + + ' -webkit-backdrop-filter: blur(8px);\n' + + '}\n' + + '\n' + + '.pswp__tb-photoswipe-caption .tb-gallery-counter {\n' + + ' color: #fff9;\n' + + ' font-size: .8rem;\n' + + '}\n'+ + '\n' + + '.pswp__item img.pswp__img {\n' + + ' display: block;\n' + + ' max-width: 90vw;\n' + + ' max-height: 78vh;\n' + + ' object-fit: contain;\n' + + ' border-radius: 4px; \n' + + ' box-shadow: 0 20px 60px #00000080;\n' + + '}\n' + + '\n' + + '.pswp__item .pswp__img--placeholder {\n' + + ' border-radius: 4px; \n' + + '}\n' + + '\n' + + '.pswp__button {\n' + + ' border-radius: 50%;\n' + + ' border: 1px solid rgba(255, 255, 255, .2);\n' + + ' background: #1e1e2899;\n' + + ' backdrop-filter: blur(8px);\n' + + ' -webkit-backdrop-filter: blur(8px);\n' + + ' color: #fff;\n' + + ' transition: background .18s ease, transform .18s ease;\n' + + ' outline: none;\n' + + '}\n' + + '\n' + + '.pswp__button:hover {\n' + + ' background: #3c3c50d9;\n' + + ' transform: scale(1.08);\n' + + ' outline: none;\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--close {\n' + + ' width: 40px;\n' + + ' height: 40px;\n' + + ' margin-top: 16px;\n' + + ' margin-right: 16px;\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--close .pswp__icn {\n' + + ' width: 24px;\n' + + ' height: 24px;\n' + + ' top: 7px;\n' + + ' left: 7px;\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--arrow {\n' + + ' width: 48px;\n' + + ' height: 48px;\n' + + ' margin-top: -24px;\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--arrow .pswp__icn {\n' + + ' width: 32px;\n' + + ' height: 32px;\n' + + ' margin-top: 0;\n' + + ' top: 7px;\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--arrow.pswp__button--arrow--prev {\n' + + ' left: 16px;\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--arrow.pswp__button--arrow--prev .pswp__icn {\n' + + ' left: 12px;\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--arrow.pswp__button--arrow--next {\n' + + ' right: 16px\n' + + '}\n' + + '\n' + + '.pswp__button.pswp__button--arrow.pswp__button--arrow--next .pswp__icn { \n' + + ' right: 12px;\n' + + '}'; + +@Directive({ + selector: '[tbPhotoSwipeGallery]', + standalone: false +}) +export class PhotoSwipeGalleryDirective implements OnInit, OnDestroy { + + @Input() galleryChildrenSelector = '.tb-image'; + @Input() imageCaptionSelector = '.tb-image-tooltip'; + + private lightbox: PhotoSwipeLightbox; + + constructor( + private elementRef: ElementRef + ) {} + + ngOnInit(): void { + this.initPhotoSwipeGalleryStyle(); + this.lightbox = new PhotoSwipeLightbox({ + gallery: this.elementRef.nativeElement, + children: this.galleryChildrenSelector, + pswpModule: PhotoSwipe, + counter: false, + bgOpacity: 0, + mainClass: PHOTO_GALLERY_CLASS + }); + this.lightbox.addFilter('domItemData', (itemData, element) => { + let image: HTMLImageElement; + if (element instanceof HTMLImageElement) { + image = element; + } else { + image = element.querySelector('img'); + } + itemData.src = image.src; + itemData.width = image.naturalWidth; + itemData.height = image.naturalHeight; + itemData.thumbCropped = true; + return itemData; + }); + this.lightbox.on('uiRegister', () => { + this.lightbox.pswp.ui.registerElement({ + name: 'tb-photoswipe-caption', + order: 9, + isButton: false, + appendTo: 'root', + html: '', + onInit: (el, pswp) => { + const caption = el.querySelector('.tb-gallery-caption'); + const counter = el.querySelector('.tb-gallery-counter'); + this.lightbox.pswp.on('change', () => { + counter.innerText = pswp.currIndex + 1 + pswp.options.indexIndicatorSep + pswp.getNumItems(); + const currSlideElement = this.lightbox.pswp.currSlide.data.element; + let imageTooltip: Element; + if (currSlideElement) { + imageTooltip = currSlideElement.querySelector(this.imageCaptionSelector); + } + if (imageTooltip) { + caption.style.display = 'block'; + caption.innerHTML = imageTooltip.innerHTML || ''; + } else { + caption.style.display = 'none'; + } + }); + } + }); + }); + this.lightbox.init(); + } + + ngOnDestroy(): void { + if (this.lightbox) { + this.lightbox.destroy(); + } + } + + private initPhotoSwipeGalleryStyle(): void { + const existingElement = document.getElementById(PHOTO_GALLERY_STYLE_ID); + if (!existingElement) { + const cssParser = new cssjs(); + cssParser.testMode = false; + cssParser.cssPreviewNamespace = PHOTO_GALLERY_CLASS; + cssParser.createStyleElement(PHOTO_GALLERY_STYLE_ID, PHOTO_GALLERY_STYLE); + } + } +} diff --git a/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts b/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts index 0e5ace4371..97c3a3f7b6 100644 --- a/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts +++ b/ui-ngx/src/app/shared/models/iot-hub/device-package.models.ts @@ -130,6 +130,63 @@ export const installMethodLabels = new Map( ] ); +export const installMethodIcons = new Map( + [ + // Direct connect — assets/direct-connect-icon + [InstallMethod.DIRECT_HTTP, 'assets/direct-connect-icon/http.svg'], + [InstallMethod.DIRECT_MQTT, 'assets/direct-connect-icon/mqtt.svg'], + [InstallMethod.DIRECT_COAP, 'assets/direct-connect-icon/coap.svg'], + [InstallMethod.DIRECT_LWM2M, 'assets/direct-connect-icon/lwm2m.svg'], + [InstallMethod.DIRECT_SNMP, 'assets/direct-connect-icon/snmp.svg'], + // Gateway connectors — assets/gateway-connect-icon + [InstallMethod.GATEWAY_MQTT, 'assets/gateway-connect-icon/mqtt.svg'], + [InstallMethod.GATEWAY_MODBUS, 'assets/gateway-connect-icon/modbus.svg'], + [InstallMethod.GATEWAY_OPCUA, 'assets/gateway-connect-icon/opc-ua.svg'], + [InstallMethod.GATEWAY_BACNET, 'assets/gateway-connect-icon/bacnet.svg'], + [InstallMethod.GATEWAY_BLE, 'assets/gateway-connect-icon/ble.svg'], + [InstallMethod.GATEWAY_CAN, 'assets/gateway-connect-icon/can.svg'], + [InstallMethod.GATEWAY_FTP, 'assets/gateway-connect-icon/ftp.svg'], + [InstallMethod.GATEWAY_OCPP, 'assets/gateway-connect-icon/ocpp.svg'], + [InstallMethod.GATEWAY_ODBC, 'assets/gateway-connect-icon/odbc.svg'], + [InstallMethod.GATEWAY_REQUEST, 'assets/gateway-connect-icon/request.svg'], + [InstallMethod.GATEWAY_REST, 'assets/gateway-connect-icon/rest.svg'], + [InstallMethod.GATEWAY_SNMP, 'assets/gateway-connect-icon/snmp.svg'], + [InstallMethod.GATEWAY_SOCKET, 'assets/gateway-connect-icon/socket.svg'], + [InstallMethod.GATEWAY_XMPP, 'assets/gateway-connect-icon/xmpp.svg'], + // ChirpStack — reuses the integration icon + [InstallMethod.CHIRPSTACK, 'assets/integration-icon/chirpstack.svg'], + // PE integrations — assets/integration-icon + [InstallMethod.INTEGRATION_APACHE_PULSAR, 'assets/integration-icon/apache-pulsar.svg'], + [InstallMethod.INTEGRATION_AWS_IOT, 'assets/integration-icon/aws-iot.svg'], + [InstallMethod.INTEGRATION_AWS_KINESIS, 'assets/integration-icon/aws-kinesis.svg'], + [InstallMethod.INTEGRATION_AWS_SQS, 'assets/integration-icon/aws-sqs.svg'], + [InstallMethod.INTEGRATION_AZURE_EVENT_HUB, 'assets/integration-icon/azure-event-hub.svg'], + [InstallMethod.INTEGRATION_AZURE_IOT_HUB, 'assets/integration-icon/azure-iot-hub.svg'], + [InstallMethod.INTEGRATION_AZURE_SERVICE_BUS, 'assets/integration-icon/azure-service-bus.svg'], + [InstallMethod.INTEGRATION_CHIRPSTACK, 'assets/integration-icon/chirpstack.svg'], + [InstallMethod.INTEGRATION_COAP, 'assets/integration-icon/coap.svg'], + [InstallMethod.INTEGRATION_CUSTOM, 'assets/integration-icon/custom.svg'], + [InstallMethod.INTEGRATION_HTTP, 'assets/integration-icon/http.svg'], + [InstallMethod.INTEGRATION_IOT_CREATORS, 'assets/integration-icon/iotcreators.com.svg'], + [InstallMethod.INTEGRATION_KAFKA, 'assets/integration-icon/kafka.svg'], + [InstallMethod.INTEGRATION_KPN_THINGS, 'assets/integration-icon/kpn.svg'], + [InstallMethod.INTEGRATION_LORIOT, 'assets/integration-icon/loriot.svg'], + [InstallMethod.INTEGRATION_MQTT, 'assets/integration-icon/mqtt.svg'], + [InstallMethod.INTEGRATION_OPC_UA, 'assets/integration-icon/opc-ua.svg'], + [InstallMethod.INTEGRATION_PARTICLE, 'assets/integration-icon/particle.svg'], + [InstallMethod.INTEGRATION_PUB_SUB, 'assets/integration-icon/pub-sub.svg'], + [InstallMethod.INTEGRATION_RABBITMQ, 'assets/integration-icon/rabbitmq.svg'], + [InstallMethod.INTEGRATION_SIGFOX, 'assets/integration-icon/sigfox.svg'], + [InstallMethod.INTEGRATION_TCP, 'assets/integration-icon/tcp.svg'], + [InstallMethod.INTEGRATION_THINGPARK, 'assets/integration-icon/thingpark.svg'], + [InstallMethod.INTEGRATION_THINGPARK_ENTERPRISE, 'assets/integration-icon/thingpark-enterprise.svg'], + [InstallMethod.INTEGRATION_TTI, 'assets/integration-icon/things-stack-industries.svg'], + [InstallMethod.INTEGRATION_TTN, 'assets/integration-icon/things-stack-сommunity.svg'], + [InstallMethod.INTEGRATION_TUYA, 'assets/integration-icon/tuya.svg'], + [InstallMethod.INTEGRATION_UDP, 'assets/integration-icon/udp.svg'] + ] +); + export const peOnlyInstallMethods: ReadonlySet = new Set([ InstallMethod.INTEGRATION_APACHE_PULSAR, InstallMethod.INTEGRATION_AWS_IOT, @@ -175,7 +232,7 @@ export enum InstallStepType { RULE_CHAIN = 'RULE_CHAIN' } -export const ENTITY_STEP_TYPES: Set = new Set([ +export const ENTITY_STEP_TYPES = new Set([ InstallStepType.DEVICE_PROFILE, InstallStepType.DEVICE, InstallStepType.GATEWAY, @@ -270,49 +327,3 @@ export interface EntityStepProgress { conflictType?: ConflictType; resolution?: string; } - -export interface DocLinks { - productURL?: string; - datasheetURL?: string; -} - -export interface DocLinkLabels { - productPage: string; - datasheet: string; -} - -export function resolveDocLinkPlaceholders( - markdown: string, - name: string, - links: DocLinks, - labels: DocLinkLabels -): string { - return markdown - .replace(/\$\{product\.button}/g, () => - links.productURL ? buildDocLinkButton(links.productURL, `${name} ${labels.productPage}`, 'open_in_new') : '') - .replace(/\$\{datasheet\.button}/g, () => - links.datasheetURL ? buildDocLinkButton(links.datasheetURL, `${name} ${labels.datasheet}`, 'description') : ''); -} - -function buildDocLinkButton(url: string, text: string, icon: string): string { - const safeUrl = escapeHtmlAttr(url); - const safeText = escapeHtml(text); - return `` + - `${icon}${safeText}`; -} - -function escapeHtml(value: string): string { - return value.replace(/[&<>]/g, ch => ch === '&' ? '&' : ch === '<' ? '<' : '>'); -} - -function escapeHtmlAttr(value: string): string { - return value.replace(/[&<>"']/g, ch => { - switch (ch) { - case '&': return '&'; - case '<': return '<'; - case '>': return '>'; - case '"': return '"'; - default: return '''; - } - }); -} diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index e38264101c..d6da51eb85 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -242,6 +242,7 @@ import { MAT_BUTTON_TOGGLE_DEFAULT_OPTIONS } from '@angular/material/button-togg import { RgbaInputComponent } from '@shared/components/color-picker/rgba-input.component'; import { HslaInputComponent } from '@shared/components/color-picker/hsla-input.component'; import { InputChangeDirective } from '@shared/components/color-picker/input-change.directive'; +import { PhotoSwipeGalleryDirective } from '@shared/directives/photoswipe-gallery.directive'; export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { return markedOptionsService; @@ -402,6 +403,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) TruncateWithTooltipDirective, ContextMenuDirective, ChipOverflowDirective, + PhotoSwipeGalleryDirective, NospacePipe, MillisecondsToTimeStringPipe, EnumToArrayPipe, @@ -676,6 +678,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) TruncateWithTooltipDirective, ContextMenuDirective, ChipOverflowDirective, + PhotoSwipeGalleryDirective, NospacePipe, MillisecondsToTimeStringPipe, EnumToArrayPipe, diff --git a/ui-ngx/src/assets/direct-connect-icon/coap.svg b/ui-ngx/src/assets/direct-connect-icon/coap.svg new file mode 100644 index 0000000000..9405b5b791 --- /dev/null +++ b/ui-ngx/src/assets/direct-connect-icon/coap.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui-ngx/src/assets/direct-connect-icon/http.svg b/ui-ngx/src/assets/direct-connect-icon/http.svg new file mode 100644 index 0000000000..9e97fe918c --- /dev/null +++ b/ui-ngx/src/assets/direct-connect-icon/http.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui-ngx/src/assets/direct-connect-icon/lwm2m.svg b/ui-ngx/src/assets/direct-connect-icon/lwm2m.svg new file mode 100644 index 0000000000..6762a50a7c --- /dev/null +++ b/ui-ngx/src/assets/direct-connect-icon/lwm2m.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/direct-connect-icon/mqtt.svg b/ui-ngx/src/assets/direct-connect-icon/mqtt.svg new file mode 100644 index 0000000000..50ef36f2f7 --- /dev/null +++ b/ui-ngx/src/assets/direct-connect-icon/mqtt.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ui-ngx/src/assets/direct-connect-icon/snmp.svg b/ui-ngx/src/assets/direct-connect-icon/snmp.svg new file mode 100644 index 0000000000..d11c791135 --- /dev/null +++ b/ui-ngx/src/assets/direct-connect-icon/snmp.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/bacnet.svg b/ui-ngx/src/assets/gateway-connect-icon/bacnet.svg new file mode 100644 index 0000000000..a967a94c6c --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/bacnet.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/ble.svg b/ui-ngx/src/assets/gateway-connect-icon/ble.svg new file mode 100644 index 0000000000..a8fbd84952 --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/ble.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/can.svg b/ui-ngx/src/assets/gateway-connect-icon/can.svg new file mode 100644 index 0000000000..d01d4b2d3a --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/can.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/ftp.svg b/ui-ngx/src/assets/gateway-connect-icon/ftp.svg new file mode 100644 index 0000000000..41f46d4f1c --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/ftp.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/modbus.svg b/ui-ngx/src/assets/gateway-connect-icon/modbus.svg new file mode 100644 index 0000000000..e93eb319ba --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/modbus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/mqtt.svg b/ui-ngx/src/assets/gateway-connect-icon/mqtt.svg new file mode 100644 index 0000000000..50ef36f2f7 --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/mqtt.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/ocpp.svg b/ui-ngx/src/assets/gateway-connect-icon/ocpp.svg new file mode 100644 index 0000000000..218010ee50 --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/ocpp.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/odbc.svg b/ui-ngx/src/assets/gateway-connect-icon/odbc.svg new file mode 100644 index 0000000000..44dac5506c --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/odbc.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/opc-ua.svg b/ui-ngx/src/assets/gateway-connect-icon/opc-ua.svg new file mode 100644 index 0000000000..a1eb4bf55c --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/opc-ua.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/request.svg b/ui-ngx/src/assets/gateway-connect-icon/request.svg new file mode 100644 index 0000000000..de4a5616f1 --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/request.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/rest.svg b/ui-ngx/src/assets/gateway-connect-icon/rest.svg new file mode 100644 index 0000000000..919462645b --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/rest.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/snmp.svg b/ui-ngx/src/assets/gateway-connect-icon/snmp.svg new file mode 100644 index 0000000000..d11c791135 --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/snmp.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/socket.svg b/ui-ngx/src/assets/gateway-connect-icon/socket.svg new file mode 100644 index 0000000000..636b022f2a --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/socket.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/gateway-connect-icon/xmpp.svg b/ui-ngx/src/assets/gateway-connect-icon/xmpp.svg new file mode 100644 index 0000000000..236ae42abb --- /dev/null +++ b/ui-ngx/src/assets/gateway-connect-icon/xmpp.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/apache-pulsar.svg b/ui-ngx/src/assets/integration-icon/apache-pulsar.svg new file mode 100644 index 0000000000..1ff16cb1ed --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/apache-pulsar.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/aws-iot.svg b/ui-ngx/src/assets/integration-icon/aws-iot.svg new file mode 100644 index 0000000000..ce93615737 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/aws-iot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui-ngx/src/assets/integration-icon/aws-kinesis.svg b/ui-ngx/src/assets/integration-icon/aws-kinesis.svg new file mode 100644 index 0000000000..fdf672f1d4 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/aws-kinesis.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/aws-sqs.svg b/ui-ngx/src/assets/integration-icon/aws-sqs.svg new file mode 100644 index 0000000000..2b9fdbf1b5 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/aws-sqs.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/azure-event-hub.svg b/ui-ngx/src/assets/integration-icon/azure-event-hub.svg new file mode 100644 index 0000000000..fec9fd51a4 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/azure-event-hub.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/azure-iot-hub.svg b/ui-ngx/src/assets/integration-icon/azure-iot-hub.svg new file mode 100644 index 0000000000..7f080283a8 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/azure-iot-hub.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/azure-service-bus.svg b/ui-ngx/src/assets/integration-icon/azure-service-bus.svg new file mode 100644 index 0000000000..bdc50a6978 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/azure-service-bus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/chirpstack.svg b/ui-ngx/src/assets/integration-icon/chirpstack.svg new file mode 100644 index 0000000000..d4cfc414cc --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/chirpstack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui-ngx/src/assets/integration-icon/coap.svg b/ui-ngx/src/assets/integration-icon/coap.svg new file mode 100644 index 0000000000..63d096c7d9 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/coap.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/custom.svg b/ui-ngx/src/assets/integration-icon/custom.svg new file mode 100644 index 0000000000..ba1077e068 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/custom.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/http.svg b/ui-ngx/src/assets/integration-icon/http.svg new file mode 100644 index 0000000000..d964e6b91c --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/http.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui-ngx/src/assets/integration-icon/iotcreators.com.svg b/ui-ngx/src/assets/integration-icon/iotcreators.com.svg new file mode 100644 index 0000000000..64224848b6 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/iotcreators.com.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/kafka.svg b/ui-ngx/src/assets/integration-icon/kafka.svg new file mode 100644 index 0000000000..2ff631845c --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/kafka.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/kpn.svg b/ui-ngx/src/assets/integration-icon/kpn.svg new file mode 100644 index 0000000000..edc4afe28d --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/kpn.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/loriot.svg b/ui-ngx/src/assets/integration-icon/loriot.svg new file mode 100644 index 0000000000..8c4f139439 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/loriot.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/mqtt.svg b/ui-ngx/src/assets/integration-icon/mqtt.svg new file mode 100644 index 0000000000..05ed77deac --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/mqtt.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/ocean-connect.svg b/ui-ngx/src/assets/integration-icon/ocean-connect.svg new file mode 100644 index 0000000000..97ff84f3fc --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/ocean-connect.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui-ngx/src/assets/integration-icon/opc-ua.svg b/ui-ngx/src/assets/integration-icon/opc-ua.svg new file mode 100644 index 0000000000..53968794c6 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/opc-ua.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui-ngx/src/assets/integration-icon/particle.svg b/ui-ngx/src/assets/integration-icon/particle.svg new file mode 100644 index 0000000000..cc823fa6f8 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/particle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui-ngx/src/assets/integration-icon/pub-sub.svg b/ui-ngx/src/assets/integration-icon/pub-sub.svg new file mode 100644 index 0000000000..7ed9730d64 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/pub-sub.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/rabbitmq.svg b/ui-ngx/src/assets/integration-icon/rabbitmq.svg new file mode 100644 index 0000000000..9b2d81160f --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/rabbitmq.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/sigfox.svg b/ui-ngx/src/assets/integration-icon/sigfox.svg new file mode 100644 index 0000000000..6bd29d8c56 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/sigfox.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui-ngx/src/assets/integration-icon/tcp.svg b/ui-ngx/src/assets/integration-icon/tcp.svg new file mode 100644 index 0000000000..5972d82f07 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/tcp.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/thingpark-enterprise.svg b/ui-ngx/src/assets/integration-icon/thingpark-enterprise.svg new file mode 100644 index 0000000000..677421c7a3 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/thingpark-enterprise.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/thingpark.svg b/ui-ngx/src/assets/integration-icon/thingpark.svg new file mode 100644 index 0000000000..2518cc273f --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/thingpark.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/integration-icon/things-stack-industries.svg b/ui-ngx/src/assets/integration-icon/things-stack-industries.svg new file mode 100644 index 0000000000..e85c828a24 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/things-stack-industries.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/things-stack-сommunity.svg b/ui-ngx/src/assets/integration-icon/things-stack-сommunity.svg new file mode 100644 index 0000000000..e85c828a24 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/things-stack-сommunity.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/ttn.svg b/ui-ngx/src/assets/integration-icon/ttn.svg new file mode 100644 index 0000000000..88a388db9d --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/ttn.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/tuya.svg b/ui-ngx/src/assets/integration-icon/tuya.svg new file mode 100644 index 0000000000..3498d0a1c0 --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/tuya.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ui-ngx/src/assets/integration-icon/udp.svg b/ui-ngx/src/assets/integration-icon/udp.svg new file mode 100644 index 0000000000..e8f81f83db --- /dev/null +++ b/ui-ngx/src/assets/integration-icon/udp.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-ngx/src/assets/iot-hub/pe-only-illustration.svg b/ui-ngx/src/assets/iot-hub/pe-only-illustration.svg new file mode 100644 index 0000000000..c93239d290 --- /dev/null +++ b/ui-ngx/src/assets/iot-hub/pe-only-illustration.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 7fc3eca27b..aa9842cb09 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -4209,6 +4209,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...", @@ -4352,10 +4353,14 @@ "review-device-instructions": "Review device instructions", "open-item-type": "Open {{type}}", "back-to-marketplace": "Back to Marketplace", - "device-install-select-connectivity": "Select your connection type:", - "device-install-pe-only-title": "ThingsBoard PE required", - "device-install-pe-only-message": "The {{method}} install method requires ThingsBoard Professional Edition. Upgrade your installation to use this device package.", - "device-install-pe-only-learn-more": "Learn about ThingsBoard PE", + "device-install-select-connectivity": "Choose a connection method for your device", + "pe-only": "PE Only", + "pe-connectivity-prompt-title-suffix": "available only in ThingsBoard Professional Edition", + "pe-connectivity-prompt-try-pe": "Try Professional Edition", + "connection-method": "Connection method", + "step-prerequisites": "Prerequisites", + "step-configuration": "Configuration", + "step-provisioning": "Provisioning", "device-install-step-error": "Failed", "device-install-step-pending": "Pending", "device-install-step-running": "Creating...", diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index e9564f5279..e13220ff76 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -8742,6 +8742,11 @@ pbf@^3.2.1, pbf@^3.3.0: ieee754 "^1.1.12" resolve-protobuf-schema "^2.1.0" +photoswipe@^5.4.4: + version "5.4.4" + resolved "https://registry.yarnpkg.com/photoswipe/-/photoswipe-5.4.4.tgz#e045dc036453493188d5c8665b0e8f1000ac4d6e" + integrity sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA== + picocolors@^1.0.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"