Browse Source

feat(iot-hub): refactor device install dialog to use mat-stepper

- Replace single-panel view switching with mat-horizontal-stepper
- Group consecutive entity steps into a single "Provisioning" stepper step
- Each SHOW_INSTRUCTION and SHOW_FORM is its own stepper step
- Add 2s minimum display time per entity creation step
- Connectivity selector shown before stepper (not a stepper step)
- Progress step auto-advances to next step after completion
- Stepper shows completed checkmarks for finished steps
pull/15347/head
Andrii Shvaika 4 months ago
parent
commit
7bfec4fb1c
  1. 369
      ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.html
  2. 167
      ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.scss
  3. 302
      ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.ts

369
ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.html

@ -21,197 +21,212 @@
</div>
} @else {
<div class="tb-device-install-header">
<h2 class="tb-device-install-title">{{ packageInfo?.name || data.item.name }}</h2>
@if (selectedConnectivity) {
<span class="tb-device-install-badge">{{ getConnectivityLabel(selectedConnectivity) }}</span>
<h2 class="tb-device-install-title">{{ 'iot-hub.device-install-title' | translate:{ name: packageInfo?.name || data.item.name } }}</h2>
@if (selectedConnectivity && wizardStarted) {
<span class="tb-device-install-badge">{{ connectivityLabels.get(selectedConnectivity) }}</span>
}
<button mat-icon-button (click)="cancel()" tabindex="-1">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-divider></mat-divider>
<div class="tb-device-install-body">
@switch (currentView) {
@case ('connectivity') {
<div class="tb-device-install-connectivity">
<p>{{ 'iot-hub.device-install-select-connectivity' | translate }}</p>
<div class="tb-connectivity-options">
@for (ct of availableConnectivityTypes; track ct) {
<button mat-stroked-button
class="tb-connectivity-button"
[class.selected]="selectedConnectivity === ct"
(click)="selectConnectivity(ct)">
{{ getConnectivityLabel(ct) }}
</button>
}
</div>
</div>
}
@case ('instruction') {
<div class="tb-device-install-instruction">
<tb-markdown [data]="currentMarkdown"
[usePlainMarkdown]="true">
</tb-markdown>
@if (!wizardStarted) {
<!-- Connectivity selector -->
<div class="tb-device-install-body">
<div class="tb-device-install-connectivity">
<p>{{ 'iot-hub.device-install-select-connectivity' | translate }}</p>
<div class="tb-connectivity-options">
@for (ct of availableConnectivityTypes; track ct) {
<button mat-stroked-button
class="tb-connectivity-button"
[class.selected]="selectedConnectivity === ct"
(click)="selectConnectivity(ct)">
{{ connectivityLabels.get(ct) || ct }}
</button>
}
</div>
}
@case ('form') {
<div class="tb-device-install-form">
@if (currentFormGroup) {
<form [formGroup]="currentFormGroup">
@for (field of currentFormFields; track field.key) {
@switch (field.type) {
@case ('BOOLEAN') {
<mat-checkbox [formControlName]="field.key">
{{ field.label }}
</mat-checkbox>
}
@case ('SELECT') {
<mat-form-field appearance="outline">
<mat-label>{{ field.label }}</mat-label>
<mat-select [formControlName]="field.key">
@for (opt of field.options; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
@if (currentFormGroup.controls[field.key]?.hasError('required')) {
<mat-error>{{ field.label + ' is required' }}</mat-error>
}
</mat-form-field>
}
@case ('PASSWORD') {
<mat-form-field appearance="outline">
<mat-label>{{ field.label }}</mat-label>
<input matInput [type]="passwordVisible[field.key] ? 'text' : 'password'"
[formControlName]="field.key">
<button mat-icon-button matSuffix type="button"
(click)="passwordVisible[field.key] = !passwordVisible[field.key]">
<mat-icon>{{ passwordVisible[field.key] ? 'visibility_off' : 'visibility' }}</mat-icon>
</button>
@if (currentFormGroup.controls[field.key]?.hasError('required')) {
<mat-error>{{ field.label + ' is required' }}</mat-error>
}
@if (currentFormGroup.controls[field.key]?.hasError('pattern')) {
<mat-error>{{ getPatternErrorMessage(field) }}</mat-error>
}
</mat-form-field>
}
@case ('INTEGER') {
<mat-form-field appearance="outline">
<mat-label>{{ field.label }}</mat-label>
<input matInput type="number" [formControlName]="field.key">
@if (currentFormGroup.controls[field.key]?.hasError('required')) {
<mat-error>{{ field.label + ' is required' }}</mat-error>
}
@if (currentFormGroup.controls[field.key]?.hasError('pattern')) {
<mat-error>{{ getPatternErrorMessage(field) }}</mat-error>
}
</mat-form-field>
}
@default {
<mat-form-field appearance="outline">
<mat-label>{{ field.label }}</mat-label>
<input matInput [formControlName]="field.key">
@if (currentFormGroup.controls[field.key]?.hasError('required')) {
<mat-error>{{ field.label + ' is required' }}</mat-error>
}
@if (currentFormGroup.controls[field.key]?.hasError('pattern')) {
<mat-error>{{ getPatternErrorMessage(field) }}</mat-error>
}
</mat-form-field>
}
</div>
</div>
<mat-dialog-actions align="end">
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary"
[disabled]="!selectedConnectivity"
(click)="confirmConnectivity()">
{{ 'action.next' | translate }}
</button>
</mat-dialog-actions>
} @else {
<!-- Stepper wizard -->
<div class="tb-device-install-stepper-container">
<mat-horizontal-stepper #installStepper
[linear]="true"
labelPosition="bottom">
<ng-template matStepperIcon="edit">
<mat-icon>done</mat-icon>
</ng-template>
@for (ws of wizardSteps; track ws.label; let i = $index; let last = $last) {
<mat-step [editable]="false"
[completed]="ws.type === 'progress' ? ws.progressDone : false"
[stepControl]="ws.type === 'form' ? ws.formGroup : null">
<ng-template matStepLabel>{{ ws.label }}</ng-template>
<div class="tb-wizard-step-content">
@switch (ws.type) {
@case ('instruction') {
<div class="tb-device-install-instruction">
<tb-markdown [data]="ws.markdown"
[usePlainMarkdown]="true">
</tb-markdown>
</div>
}
@if (field.helpText) {
<div class="tb-form-field-help">{{ field.helpText }}</div>
@case ('form') {
<div class="tb-device-install-form">
@if (ws.formGroup) {
<form [formGroup]="ws.formGroup">
@for (field of ws.formFields; track field.key) {
@switch (field.type) {
@case ('BOOLEAN') {
<mat-checkbox [formControlName]="field.key">
{{ field.label }}
</mat-checkbox>
}
@case ('SELECT') {
<mat-form-field appearance="outline">
<mat-label>{{ field.label }}</mat-label>
<mat-select [formControlName]="field.key">
@for (opt of field.options; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
@if (ws.formGroup.controls[field.key]?.hasError('required')) {
<mat-error>{{ field.label + ' is required' }}</mat-error>
}
</mat-form-field>
}
@case ('PASSWORD') {
<mat-form-field appearance="outline">
<mat-label>{{ field.label }}</mat-label>
<input matInput [type]="passwordVisible[field.key] ? 'text' : 'password'"
[formControlName]="field.key">
<button mat-icon-button matSuffix type="button"
(click)="passwordVisible[field.key] = !passwordVisible[field.key]">
<mat-icon>{{ passwordVisible[field.key] ? 'visibility_off' : 'visibility' }}</mat-icon>
</button>
@if (ws.formGroup.controls[field.key]?.hasError('required')) {
<mat-error>{{ field.label + ' is required' }}</mat-error>
}
@if (ws.formGroup.controls[field.key]?.hasError('pattern')) {
<mat-error>{{ getPatternErrorMessage(field) }}</mat-error>
}
</mat-form-field>
}
@case ('INTEGER') {
<mat-form-field appearance="outline">
<mat-label>{{ field.label }}</mat-label>
<input matInput type="number" [formControlName]="field.key">
@if (ws.formGroup.controls[field.key]?.hasError('required')) {
<mat-error>{{ field.label + ' is required' }}</mat-error>
}
@if (ws.formGroup.controls[field.key]?.hasError('pattern')) {
<mat-error>{{ getPatternErrorMessage(field) }}</mat-error>
}
</mat-form-field>
}
@default {
<mat-form-field appearance="outline">
<mat-label>{{ field.label }}</mat-label>
<input matInput [formControlName]="field.key">
@if (ws.formGroup.controls[field.key]?.hasError('required')) {
<mat-error>{{ field.label + ' is required' }}</mat-error>
}
@if (ws.formGroup.controls[field.key]?.hasError('pattern')) {
<mat-error>{{ getPatternErrorMessage(field) }}</mat-error>
}
</mat-form-field>
}
}
@if (field.helpText) {
<div class="tb-form-field-help">{{ field.helpText }}</div>
}
@if (field.helpImage) {
<img class="tb-form-field-help-image" [src]="field.helpImage" [alt]="field.label">
}
}
</form>
}
</div>
}
@if (field.helpImage) {
<img class="tb-form-field-help-image" [src]="field.helpImage" [alt]="field.label">
@case ('progress') {
<div class="tb-device-install-progress">
@for (ep of ws.entitySteps; track ep.step.name) {
<div class="tb-progress-row">
<div class="tb-progress-icon">
@switch (ep.status) {
@case ('pending') {
<mat-icon class="tb-progress-pending">radio_button_unchecked</mat-icon>
}
@case ('running') {
<mat-spinner diameter="20"></mat-spinner>
}
@case ('success') {
<mat-icon class="tb-progress-success">check_circle</mat-icon>
}
@case ('error') {
<mat-icon class="tb-progress-error">error</mat-icon>
}
}
</div>
<div class="tb-progress-info">
<span class="tb-progress-label">{{ ep.resolvedName || ep.step.name }}</span>
@if (ep.status === 'error' && ep.errorMessage) {
<span class="tb-progress-error-msg">{{ ep.errorMessage }}</span>
}
</div>
</div>
}
</div>
}
}
</form>
}
</div>
}
@case ('progress') {
<div class="tb-device-install-progress">
@for (ep of entitySteps; track ep.step.name) {
<div class="tb-progress-row">
<div class="tb-progress-icon">
@switch (ep.status) {
@case ('pending') {
<mat-icon class="tb-progress-pending">radio_button_unchecked</mat-icon>
}
@case ('running') {
<mat-spinner diameter="20"></mat-spinner>
}
@case ('success') {
<mat-icon class="tb-progress-success">check_circle</mat-icon>
}
@case ('error') {
<mat-icon class="tb-progress-error">error</mat-icon>
}
}
</div>
<div>
<div class="tb-progress-label">{{ ep.resolvedName || ep.step.name }}</div>
@if (ep.status === 'error' && ep.errorMessage) {
<div class="tb-progress-error-msg">{{ ep.errorMessage }}</div>
}
</div>
</div>
}
</div>
@if (progressError) {
<div class="tb-device-install-error">{{ progressError }}</div>
</mat-step>
}
}
@case ('done') {
<div class="tb-device-install-done">
@if (currentMarkdown) {
<tb-markdown [data]="currentMarkdown"
[usePlainMarkdown]="true">
</tb-markdown>
} @else {
<p class="tb-done-message">{{ 'iot-hub.install-success-desc' | translate:{ name: packageInfo?.name } }}</p>
</mat-horizontal-stepper>
</div>
<mat-dialog-actions align="end">
@if (currentWizardStep; as step) {
@switch (step.type) {
@case ('instruction') {
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary" (click)="nextStep()">
{{ (isLastWizardStep ? 'action.done' : 'action.next') | translate }}
</button>
}
@case ('form') {
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary" (click)="nextStep()">
{{ 'action.next' | translate }}
</button>
}
@case ('progress') {
@if (step.progressError) {
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary" (click)="retryEntitySteps(step)">
{{ 'action.retry' | translate }}
</button>
} @else if (step.progressDone && isLastWizardStep) {
<button mat-flat-button color="primary" (click)="done()">
{{ 'action.done' | translate }}
</button>
}
}
</div>
}
}
</div>
<mat-dialog-actions align="end">
@switch (currentView) {
@case ('connectivity') {
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary"
[disabled]="!selectedConnectivity"
(click)="next()">
{{ 'action.next' | translate }}
</button>
}
@case ('instruction') {
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary" (click)="next()">
{{ (isLastStep() ? 'action.done' : 'action.next') | translate }}
</button>
}
@case ('form') {
<button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<button mat-flat-button color="primary" (click)="next()">
{{ 'action.next' | translate }}
</button>
}
@case ('progress') {
<button mat-button (click)="cancel()" [disabled]="!progressError">{{ 'action.cancel' | translate }}</button>
@if (progressError) {
<button mat-flat-button color="primary" (click)="retryEntitySteps()">
{{ 'action.retry' | translate }}
</button>
}
}
@case ('done') {
<button mat-flat-button color="primary" (click)="done()">
{{ 'action.done' | translate }}
</button>
}
}
</mat-dialog-actions>
</mat-dialog-actions>
}
}

167
ui-ngx/src/app/modules/home/pages/iot-hub/device-install-dialog/device-install-dialog.component.scss

@ -17,9 +17,9 @@
:host {
display: flex;
flex-direction: column;
width: 640px;
width: 720px;
max-width: 90vw;
max-height: 80vh;
max-height: 85vh;
}
.tb-device-install-header {
@ -54,13 +54,6 @@
}
}
.tb-device-install-body {
flex: 1;
overflow-y: auto;
padding: 0 24px 24px;
min-height: 200px;
}
.tb-device-install-loading {
display: flex;
align-items: center;
@ -68,6 +61,14 @@
padding: 48px 0;
}
// Connectivity selector (before stepper)
.tb-device-install-body {
flex: 1;
overflow-y: auto;
padding: 0 24px 24px;
min-height: 120px;
}
.tb-device-install-connectivity {
display: flex;
flex-direction: column;
@ -96,106 +97,133 @@
}
}
// Stepper container
.tb-device-install-stepper-container {
flex: 1;
overflow-y: auto;
min-height: 200px;
::ng-deep {
.mat-horizontal-stepper-header-container {
padding: 0 16px;
}
.mat-horizontal-content-container {
padding: 0 24px 16px;
}
.mat-step-header {
padding: 0 12px;
}
}
}
// Step content wrapper
.tb-wizard-step-content {
min-height: 120px;
}
// Instruction view
.tb-device-install-instruction {
::ng-deep tb-markdown {
display: block;
}
::ng-deep {
h1, h2, h3, h4 {
&:first-child {
margin-top: 0;
}
}
}
}
// Form view
.tb-device-install-form {
display: flex;
flex-direction: column;
gap: 16px;
gap: 8px;
mat-form-field {
width: 100%;
}
.tb-form-field-help {
color: rgba(0, 0, 0, 0.54);
font-size: 12px;
line-height: 16px;
margin-top: 4px;
margin-top: -4px;
margin-bottom: 8px;
}
.tb-form-field-help-image {
max-width: 100%;
margin-top: 8px;
margin-top: 4px;
margin-bottom: 8px;
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.12);
}
}
// Progress view
.tb-device-install-progress {
display: flex;
flex-direction: column;
gap: 12px;
}
.tb-progress-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
.tb-progress-icon {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
.tb-progress-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 8px 0;
}
.tb-progress-pending {
color: rgba(0, 0, 0, 0.38);
}
.tb-progress-icon {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
.tb-progress-success {
color: #2e7d32;
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
.tb-progress-error {
color: #c62828;
}
}
.tb-progress-pending {
color: rgba(0, 0, 0, 0.38);
}
.tb-progress-label {
flex: 1;
font-size: 14px;
line-height: 20px;
color: rgba(0, 0, 0, 0.87);
}
.tb-progress-success {
color: #2e7d32;
}
.tb-progress-error-msg {
font-size: 12px;
color: #c62828;
margin-top: 4px;
}
.tb-progress-error {
color: #c62828;
}
}
.tb-device-install-done {
.tb-progress-info {
display: flex;
flex-direction: column;
gap: 16px;
gap: 4px;
min-height: 24px;
justify-content: center;
}
.tb-done-message {
color: rgba(0, 0, 0, 0.87);
font-size: 14px;
line-height: 20px;
}
.tb-progress-label {
font-size: 14px;
line-height: 20px;
color: rgba(0, 0, 0, 0.87);
}
.tb-device-install-error {
max-height: 200px;
overflow-y: auto;
padding: 12px;
background: #f5f5f5;
border-radius: 4px;
font-family: monospace;
.tb-progress-error-msg {
font-size: 12px;
color: rgba(0, 0, 0, 0.7);
color: #c62828;
font-family: monospace;
white-space: pre-wrap;
word-break: break-word;
}
@ -203,8 +231,3 @@
mat-dialog-actions {
padding: 8px 24px 16px;
}
.tb-iot-hub-inline-spinner {
display: inline-block;
margin-right: 8px;
}

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

@ -14,9 +14,10 @@
/// limitations under the License.
///
import { Component, Inject, OnInit } from '@angular/core';
import { Component, Inject, OnInit, ViewChild } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms';
import { MatStepper } from '@angular/material/stepper';
import { firstValueFrom } from 'rxjs';
import { PageLink } from '@shared/models/page/page-link';
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models';
@ -38,14 +39,31 @@ import {
stepTypeAliasMap
} from '@shared/models/iot-hub/device-package.models';
export type DeviceInstallView = 'connectivity' | 'instruction' | 'form' | 'progress' | 'done';
export interface DeviceInstallDialogData {
item: MpItemVersionView;
zipData: ArrayBuffer;
iotHubApiService: IotHubApiService;
}
export type WizardStepType = 'instruction' | 'form' | 'progress';
export interface WizardStep {
type: WizardStepType;
label: string;
rawSteps: DeviceInstallStep[];
// Instruction
markdown?: string;
// Form
formFields?: FormFieldDefinition[];
formGroup?: UntypedFormGroup;
// Progress
entitySteps?: EntityStepProgress[];
progressError?: string;
progressDone?: boolean;
}
const ENTITY_STEP_MIN_DELAY = 2000;
@Component({
selector: 'tb-device-install-dialog',
standalone: false,
@ -54,29 +72,28 @@ export interface DeviceInstallDialogData {
})
export class TbDeviceInstallDialogComponent implements OnInit {
@ViewChild('installStepper', {static: false}) stepper: MatStepper;
loading = true;
packageInfo: DevicePackageInfo;
zipFiles = new Map<string, string>();
// Connectivity
showConnectivitySelector = false;
availableConnectivityTypes: string[] = [];
selectedConnectivity: string | null = null;
connectivityLabels = connectivityTypeTranslations;
currentView: DeviceInstallView = 'connectivity';
steps: DeviceInstallStep[] = [];
currentStepIndex = 0;
currentMarkdown = '';
currentFormFields: FormFieldDefinition[] = [];
currentFormGroup: UntypedFormGroup | null = null;
// Wizard
wizardSteps: WizardStep[] = [];
wizardStarted = false;
passwordVisible: Record<string, boolean> = {};
// Variable resolution state
formValues: Record<string, any> = {};
entityOutputs = new Map<string, EntityStepOutput>();
positionalOutputs = new Map<number, EntityStepOutput>();
entitySteps: EntityStepProgress[] = [];
progressError: string | null = null;
constructor(
@Inject(MAT_DIALOG_DATA) public data: DeviceInstallDialogData,
private dialogRef: MatDialogRef<TbDeviceInstallDialogComponent>,
@ -102,9 +119,10 @@ export class TbDeviceInstallDialogComponent implements OnInit {
);
if (this.availableConnectivityTypes.length === 1) {
this.selectedConnectivity = this.availableConnectivityTypes[0];
this.steps = this.packageInfo.installSteps[this.selectedConnectivity] || [];
this.currentStepIndex = 0;
this.advanceToCurrentStep();
this.showConnectivitySelector = false;
this.startWizard();
} else {
this.showConnectivitySelector = true;
}
} catch (e) {
console.error('Failed to parse device package ZIP', e);
@ -112,85 +130,50 @@ export class TbDeviceInstallDialogComponent implements OnInit {
this.loading = false;
}
getConnectivityLabel(ct: string): string {
return connectivityTypeTranslations.get(ct) || ct;
}
// --- Connectivity ---
selectConnectivity(ct: string): void {
this.selectedConnectivity = ct;
}
next(): void {
if (this.currentView === 'connectivity') {
if (!this.selectedConnectivity) {
return;
}
this.steps = this.packageInfo.installSteps[this.selectedConnectivity] || [];
this.currentStepIndex = 0;
this.formValues = {};
this.entityOutputs.clear();
this.positionalOutputs.clear();
this.advanceToCurrentStep();
confirmConnectivity(): void {
if (!this.selectedConnectivity) {
return;
}
this.startWizard();
}
if (this.currentView === 'form') {
if (this.currentFormGroup && this.currentFormGroup.invalid) {
this.currentFormGroup.markAllAsTouched();
return;
}
if (this.currentFormGroup) {
Object.assign(this.formValues, this.currentFormGroup.value);
}
}
// --- Wizard ---
if (this.currentView === 'instruction' && this.isLastStep()) {
this.done();
return;
}
get isLastWizardStep(): boolean {
return this.stepper && this.stepper.selectedIndex === this.wizardSteps.length - 1;
}
this.currentStepIndex++;
this.advanceToCurrentStep();
get currentWizardStep(): WizardStep | null {
if (!this.stepper || !this.wizardSteps.length) {
return null;
}
return this.wizardSteps[this.stepper.selectedIndex] ?? null;
}
advanceToCurrentStep(): void {
if (this.currentStepIndex >= this.steps.length) {
this.currentView = 'done';
this.currentMarkdown = '';
nextStep(): void {
const step = this.currentWizardStep;
if (!step) {
return;
}
const step = this.steps[this.currentStepIndex];
switch (step.type) {
case InstallStepType.SHOW_INSTRUCTION: {
this.currentView = 'instruction';
const raw = this.zipFiles.get(step.file) || '';
this.currentMarkdown = this.resolveVariables(raw);
break;
}
case InstallStepType.SHOW_FORM: {
this.currentView = 'form';
const formJson = this.zipFiles.get(step.file) || '[]';
this.currentFormFields = JSON.parse(formJson) as FormFieldDefinition[];
this.buildFormGroup();
break;
}
default: {
if (ENTITY_STEP_TYPES.has(step.type)) {
this.collectAndRunEntitySteps();
} else {
// Skip unsupported step types (CONVERTER, INTEGRATION)
this.currentStepIndex++;
this.advanceToCurrentStep();
}
break;
if (step.type === 'form') {
if (step.formGroup?.invalid) {
step.formGroup.markAllAsTouched();
return;
}
Object.assign(this.formValues, step.formGroup.getRawValue());
}
}
isLastStep(): boolean {
return this.currentStepIndex >= this.steps.length - 1;
if (this.isLastWizardStep) {
this.done();
return;
}
this.stepper.next();
this.onStepActivated();
}
done(): void {
@ -201,18 +184,17 @@ export class TbDeviceInstallDialogComponent implements OnInit {
this.dialogRef.close(false);
}
retryEntitySteps(): void {
this.progressError = null;
this.runEntitySteps();
retryEntitySteps(step: WizardStep): void {
step.progressError = null;
this.runEntitySteps(step);
}
getPatternErrorMessage(field: FormFieldDefinition): string {
if (field.validators?.length > 0) {
return field.validators[0].message || 'Invalid format';
}
return 'Invalid format';
return field.validators?.[0]?.message || 'Invalid format';
}
// --- Variable Resolution ---
resolveVariables(content: string): string {
return content.replace(/\$\{([^}]+)\}/g, (_match, key) => {
if (key in this.formValues) {
@ -224,13 +206,13 @@ export class TbDeviceInstallDialogComponent implements OnInit {
const prop = key.substring(dotIdx + 1);
const output = this.entityOutputs.get(alias);
if (output && prop in output) {
return String(output[prop]);
return String((output as any)[prop]);
}
const m = alias.match(/^step(\d+)$/);
if (m) {
const pos = this.positionalOutputs.get(parseInt(m[1], 10));
if (pos && prop in pos) {
return String(pos[prop]);
return String((pos as any)[prop]);
}
}
}
@ -238,10 +220,70 @@ export class TbDeviceInstallDialogComponent implements OnInit {
});
}
private buildFormGroup(): void {
const controls: Record<string, UntypedFormControl> = {};
// --- Private ---
private startWizard(): void {
this.formValues = {};
this.entityOutputs.clear();
this.positionalOutputs.clear();
this.passwordVisible = {};
for (const field of this.currentFormFields) {
this.buildWizardSteps();
this.wizardStarted = true;
// Activate the first step after the stepper renders
setTimeout(() => this.onStepActivated(), 0);
}
private buildWizardSteps(): void {
const rawSteps = this.packageInfo.installSteps[this.selectedConnectivity] || [];
this.wizardSteps = [];
let i = 0;
while (i < rawSteps.length) {
const step = rawSteps[i];
if (step.type === InstallStepType.SHOW_INSTRUCTION) {
this.wizardSteps.push({
type: 'instruction',
label: step.name,
rawSteps: [step]
});
i++;
} else if (step.type === InstallStepType.SHOW_FORM) {
this.wizardSteps.push({
type: 'form',
label: step.name,
rawSteps: [step]
});
i++;
} else if (ENTITY_STEP_TYPES.has(step.type)) {
// Group consecutive entity steps
const group: DeviceInstallStep[] = [];
while (i < rawSteps.length && ENTITY_STEP_TYPES.has(rawSteps[i].type)) {
group.push(rawSteps[i]);
i++;
}
this.wizardSteps.push({
type: 'progress',
label: 'Provisioning',
rawSteps: group
});
} else {
// Skip unsupported steps (CONVERTER, INTEGRATION)
i++;
}
}
// Initialize form groups
for (const ws of this.wizardSteps) {
if (ws.type === 'form') {
this.initFormStep(ws);
}
}
}
private initFormStep(ws: WizardStep): void {
const formJson = this.zipFiles.get(ws.rawSteps[0].file) || '[]';
ws.formFields = JSON.parse(formJson) as FormFieldDefinition[];
const controls: Record<string, UntypedFormControl> = {};
for (const field of ws.formFields) {
const validators = [];
if (field.required) {
validators.push(Validators.required);
@ -249,42 +291,54 @@ export class TbDeviceInstallDialogComponent implements OnInit {
if (field.validators?.length > 0) {
validators.push(Validators.pattern(field.validators[0].pattern));
}
const initialValue = field.key in this.formValues
? this.formValues[field.key]
: (field.defaultValue ?? (field.type === FormFieldType.BOOLEAN ? false : ''));
const initialValue = field.defaultValue ?? (field.type === FormFieldType.BOOLEAN ? false : '');
controls[field.key] = new UntypedFormControl(initialValue, validators);
if (field.type === FormFieldType.PASSWORD) {
this.passwordVisible[field.key] = false;
}
}
this.currentFormGroup = new UntypedFormGroup(controls);
ws.formGroup = new UntypedFormGroup(controls);
}
private collectAndRunEntitySteps(): void {
this.entitySteps = [];
let i = this.currentStepIndex;
while (i < this.steps.length && ENTITY_STEP_TYPES.has(this.steps[i].type)) {
this.entitySteps.push({
step: this.steps[i],
status: 'pending',
resolvedName: this.resolveVariables(this.steps[i].name)
});
i++;
private onStepActivated(): void {
const step = this.currentWizardStep;
if (!step) {
return;
}
if (step.type === 'instruction') {
const raw = this.zipFiles.get(step.rawSteps[0].file) || '';
step.markdown = this.resolveVariables(raw);
} else if (step.type === 'progress' && !step.progressDone) {
this.initAndRunEntitySteps(step);
}
this.currentView = 'progress';
this.progressError = null;
this.runEntitySteps();
}
private async runEntitySteps(): Promise<void> {
for (const ep of this.entitySteps) {
private initAndRunEntitySteps(ws: WizardStep): void {
ws.entitySteps = ws.rawSteps.map(s => ({
step: s,
status: 'pending' as const,
resolvedName: this.resolveVariables(s.name)
}));
ws.progressError = null;
ws.progressDone = false;
this.runEntitySteps(ws);
}
private async runEntitySteps(ws: WizardStep): Promise<void> {
for (const ep of ws.entitySteps) {
if (ep.status === 'success') {
continue;
}
ep.status = 'running';
ep.errorMessage = null;
try {
const startTime = Date.now();
const output = await this.createEntity(ep.step);
// Ensure minimum visible time per step
const elapsed = Date.now() - startTime;
if (elapsed < ENTITY_STEP_MIN_DELAY) {
await this.delay(ENTITY_STEP_MIN_DELAY - elapsed);
}
ep.entityOutput = output;
ep.status = 'success';
@ -292,13 +346,14 @@ export class TbDeviceInstallDialogComponent implements OnInit {
if (alias) {
this.entityOutputs.set(alias, output);
}
const stepIdx = this.steps.indexOf(ep.step);
const rawSteps = this.packageInfo.installSteps[this.selectedConnectivity] || [];
const stepIdx = rawSteps.indexOf(ep.step);
if (stepIdx >= 0) {
this.positionalOutputs.set(stepIdx + 1, output);
}
// Re-resolve names of remaining pending steps
for (const remaining of this.entitySteps) {
for (const remaining of ws.entitySteps) {
if (remaining.status === 'pending') {
remaining.resolvedName = this.resolveVariables(remaining.step.name);
}
@ -306,26 +361,27 @@ export class TbDeviceInstallDialogComponent implements OnInit {
} catch (err: any) {
ep.status = 'error';
ep.errorMessage = err?.error?.message || err?.message || 'Unknown error';
this.progressError = ep.errorMessage;
ws.progressError = ep.errorMessage;
return;
}
}
// All entity steps succeeded — advance past them
const lastEntityIdx = this.currentStepIndex + this.entitySteps.length - 1;
this.currentStepIndex = lastEntityIdx + 1;
// Report install
// All done — report install
ws.progressDone = true;
try {
await firstValueFrom(
this.data.iotHubApiService.reportVersionInstalled(this.data.item.id as string, { ignoreLoading: true })
);
} catch (_e) {
// Non-critical — best effort
// Non-critical
}
// Advance to next step (instruction after entities, or done)
this.advanceToCurrentStep();
// Auto-advance to next step after a short pause
if (!this.isLastWizardStep) {
await this.delay(500);
this.stepper.next();
this.onStepActivated();
}
}
private async createEntity(step: DeviceInstallStep): Promise<EntityStepOutput> {
@ -384,4 +440,8 @@ export class TbDeviceInstallDialogComponent implements OnInit {
const match = page.data.find(rc => rc.name === name);
return match ? { id: match.id.id, name: match.name } : null;
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

Loading…
Cancel
Save