From d6d313da214dd24fd1927483c9ff21cf3f084ae6 Mon Sep 17 00:00:00 2001 From: Andrii Shvaika Date: Wed, 29 Apr 2026 11:03:11 +0300 Subject: [PATCH] feat(iot-hub): random-value form fields and doc-link button placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STRING and PASSWORD fields in SHOW_FORM gain optional randomGenerator, randomSize, and randomByDefault properties. randomByDefault pre-fills the field on form open; an autorenew icon (next to the eye toggle on PASSWORD) regenerates on demand. Reuses generateSecret from @core/utils. Two new markdown placeholders, ${product.button} and ${datasheet.button}, render as inline buttons linking to the manufacturer's product page and datasheet. They work in SHOW_INSTRUCTION steps (install dialog) and in overview.md (detail dialog readme). URLs come from device-info.json's productURL/datasheetURL — install reads from the parsed package, detail dialog reads from item.dataDescriptor. --- .../device-install-dialog.component.html | 16 ++++++ .../device-install-dialog.component.scss | 28 ++++++++++ .../device-install-dialog.component.ts | 33 +++++++++++- .../iot-hub-item-detail-dialog.component.scss | 28 ++++++++++ .../iot-hub-item-detail-dialog.component.ts | 13 ++++- .../models/iot-hub/device-package.models.ts | 51 +++++++++++++++++++ .../assets/locale/locale.constant-en_US.json | 1 + 7 files changed, 168 insertions(+), 2 deletions(-) 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 ad0ef8fd1e..22705663c8 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 @@ -62,6 +62,14 @@ (click)="passwordVisible[field.key] = !passwordVisible[field.key]"> {{ passwordVisible[field.key] ? 'visibility_off' : 'visibility' }} + @if (field.randomGenerator) { + + } @if (ws.formGroup.controls[field.key]?.hasError('required')) { {{ field.label + ' is required' }} } @@ -86,6 +94,14 @@ {{ field.label }} + @if (field.randomGenerator) { + + } @if (ws.formGroup.controls[field.key]?.hasError('required')) { {{ field.label + ' is required' }} } 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 192d569f56..ebdf5da07c 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 @@ -210,6 +210,34 @@ } } + .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); + } + } + .tb-callout { display: flex; align-items: flex-start; 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 b50ad22f5c..c042db2ebe 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 @@ -33,6 +33,7 @@ import { RuleChainService } from '@core/http/rule-chain.service'; import { AttributeService } from '@core/http/attribute.service'; import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; import { EntityId } from '@shared/models/id/entity-id'; +import { generateSecret } from '@core/utils'; import { installMethodLabels as INSTALL_METHOD_LABELS, peOnlyInstallMethods, @@ -44,6 +45,7 @@ import { FormFieldDefinition, FormFieldType, InstallStepType, + resolveDocLinkPlaceholders, stepTypeAliasMap } from '@shared/models/iot-hub/device-package.models'; @@ -74,6 +76,7 @@ export interface WizardStep { } const ENTITY_STEP_MIN_DELAY = 2000; +const DEFAULT_RANDOM_SIZE = 20; @Component({ selector: 'tb-device-install-dialog', @@ -422,6 +425,14 @@ export class TbDeviceInstallDialogComponent extends DialogComponent { if (key in this.formValues) { return String(this.formValues[key]); @@ -591,7 +602,7 @@ export class TbDeviceInstallDialogComponent extends DialogComponent 0) { validators.push(Validators.pattern(field.validators[0].pattern)); } - const initialValue = storedValues?.[field.key] ?? field.defaultValue ?? (field.type === FormFieldType.BOOLEAN ? false : ''); + const initialValue = this.resolveInitialFieldValue(field, storedValues?.[field.key]); controls[field.key] = new UntypedFormControl(initialValue, validators); if (field.type === FormFieldType.PASSWORD) { this.passwordVisible[field.key] = true; // Show passwords in review mode @@ -604,6 +615,26 @@ export class TbDeviceInstallDialogComponent extends DialogComponent this.readmeContent = this.prefixResourceUrls(content || '') + content => this.readmeContent = this.resolveDocLinks(this.prefixResourceUrls(content || '')) ); } @@ -307,4 +308,14 @@ export class TbIotHubItemDetailDialogComponent extends DialogComponent; + productURL?: string; + datasheetURL?: string; } export enum FormFieldType { @@ -241,6 +243,9 @@ export interface FormFieldDefinition { helpImage?: string; validators?: FormFieldValidator[]; options?: FormFieldOption[]; + randomGenerator?: boolean; + randomSize?: number; + randomByDefault?: boolean; } export interface EntityStepOutput { @@ -265,3 +270,49 @@ 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/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 8f147493a5..a7089fd1b9 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3698,6 +3698,7 @@ }, "iot-hub": { "iot-hub": "IoT Hub", + "generate-random-value": "Generate random value", "home-title": "ThingsBoard IoT Hub", "home-subtitle-prefix": "Discover ready-to-use", "and-devices": "& Devices",