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 6 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> </div>
} @else { } @else {
<div class="tb-device-install-header"> <div class="tb-device-install-header">
<h2 class="tb-device-install-title">{{ packageInfo?.name || data.item.name }}</h2> <h2 class="tb-device-install-title">{{ 'iot-hub.device-install-title' | translate:{ name: packageInfo?.name || data.item.name } }}</h2>
@if (selectedConnectivity) { @if (selectedConnectivity && wizardStarted) {
<span class="tb-device-install-badge">{{ getConnectivityLabel(selectedConnectivity) }}</span> <span class="tb-device-install-badge">{{ connectivityLabels.get(selectedConnectivity) }}</span>
} }
<button mat-icon-button (click)="cancel()" tabindex="-1"> <button mat-icon-button (click)="cancel()" tabindex="-1">
<mat-icon>close</mat-icon> <mat-icon>close</mat-icon>
</button> </button>
</div> </div>
<mat-divider></mat-divider> <mat-divider></mat-divider>
<div class="tb-device-install-body">
@switch (currentView) { @if (!wizardStarted) {
@case ('connectivity') { <!-- Connectivity selector -->
<div class="tb-device-install-connectivity"> <div class="tb-device-install-body">
<p>{{ 'iot-hub.device-install-select-connectivity' | translate }}</p> <div class="tb-device-install-connectivity">
<div class="tb-connectivity-options"> <p>{{ 'iot-hub.device-install-select-connectivity' | translate }}</p>
@for (ct of availableConnectivityTypes; track ct) { <div class="tb-connectivity-options">
<button mat-stroked-button @for (ct of availableConnectivityTypes; track ct) {
class="tb-connectivity-button" <button mat-stroked-button
[class.selected]="selectedConnectivity === ct" class="tb-connectivity-button"
(click)="selectConnectivity(ct)"> [class.selected]="selectedConnectivity === ct"
{{ getConnectivityLabel(ct) }} (click)="selectConnectivity(ct)">
</button> {{ connectivityLabels.get(ct) || ct }}
} </button>
</div> }
</div>
}
@case ('instruction') {
<div class="tb-device-install-instruction">
<tb-markdown [data]="currentMarkdown"
[usePlainMarkdown]="true">
</tb-markdown>
</div> </div>
} </div>
@case ('form') { </div>
<div class="tb-device-install-form"> <mat-dialog-actions align="end">
@if (currentFormGroup) { <button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<form [formGroup]="currentFormGroup"> <button mat-flat-button color="primary"
@for (field of currentFormFields; track field.key) { [disabled]="!selectedConnectivity"
@switch (field.type) { (click)="confirmConnectivity()">
@case ('BOOLEAN') { {{ 'action.next' | translate }}
<mat-checkbox [formControlName]="field.key"> </button>
{{ field.label }} </mat-dialog-actions>
</mat-checkbox> } @else {
} <!-- Stepper wizard -->
@case ('SELECT') { <div class="tb-device-install-stepper-container">
<mat-form-field appearance="outline"> <mat-horizontal-stepper #installStepper
<mat-label>{{ field.label }}</mat-label> [linear]="true"
<mat-select [formControlName]="field.key"> labelPosition="bottom">
@for (opt of field.options; track opt.value) { <ng-template matStepperIcon="edit">
<mat-option [value]="opt.value">{{ opt.label }}</mat-option> <mat-icon>done</mat-icon>
} </ng-template>
</mat-select>
@if (currentFormGroup.controls[field.key]?.hasError('required')) { @for (ws of wizardSteps; track ws.label; let i = $index; let last = $last) {
<mat-error>{{ field.label + ' is required' }}</mat-error> <mat-step [editable]="false"
} [completed]="ws.type === 'progress' ? ws.progressDone : false"
</mat-form-field> [stepControl]="ws.type === 'form' ? ws.formGroup : null">
} <ng-template matStepLabel>{{ ws.label }}</ng-template>
@case ('PASSWORD') {
<mat-form-field appearance="outline"> <div class="tb-wizard-step-content">
<mat-label>{{ field.label }}</mat-label> @switch (ws.type) {
<input matInput [type]="passwordVisible[field.key] ? 'text' : 'password'"
[formControlName]="field.key"> @case ('instruction') {
<button mat-icon-button matSuffix type="button" <div class="tb-device-install-instruction">
(click)="passwordVisible[field.key] = !passwordVisible[field.key]"> <tb-markdown [data]="ws.markdown"
<mat-icon>{{ passwordVisible[field.key] ? 'visibility_off' : 'visibility' }}</mat-icon> [usePlainMarkdown]="true">
</button> </tb-markdown>
@if (currentFormGroup.controls[field.key]?.hasError('required')) { </div>
<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>
}
} }
@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>
} </mat-step>
</div>
@if (progressError) {
<div class="tb-device-install-error">{{ progressError }}</div>
} }
} </mat-horizontal-stepper>
@case ('done') { </div>
<div class="tb-device-install-done">
@if (currentMarkdown) { <mat-dialog-actions align="end">
<tb-markdown [data]="currentMarkdown" @if (currentWizardStep; as step) {
[usePlainMarkdown]="true"> @switch (step.type) {
</tb-markdown> @case ('instruction') {
} @else { <button mat-button (click)="cancel()">{{ 'action.cancel' | translate }}</button>
<p class="tb-done-message">{{ 'iot-hub.install-success-desc' | translate:{ name: packageInfo?.name } }}</p> <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') { </mat-dialog-actions>
<button mat-flat-button color="primary" (click)="done()"> }
{{ 'action.done' | translate }}
</button>
}
}
</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 { :host {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 640px; width: 720px;
max-width: 90vw; max-width: 90vw;
max-height: 80vh; max-height: 85vh;
} }
.tb-device-install-header { .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 { .tb-device-install-loading {
display: flex; display: flex;
align-items: center; align-items: center;
@ -68,6 +61,14 @@
padding: 48px 0; 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 { .tb-device-install-connectivity {
display: flex; display: flex;
flex-direction: column; 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 { .tb-device-install-instruction {
::ng-deep tb-markdown { ::ng-deep tb-markdown {
display: block; display: block;
} }
::ng-deep {
h1, h2, h3, h4 {
&:first-child {
margin-top: 0;
}
}
}
} }
// Form view
.tb-device-install-form { .tb-device-install-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 8px;
mat-form-field {
width: 100%;
}
.tb-form-field-help { .tb-form-field-help {
color: rgba(0, 0, 0, 0.54); color: rgba(0, 0, 0, 0.54);
font-size: 12px; font-size: 12px;
line-height: 16px; line-height: 16px;
margin-top: 4px; margin-top: -4px;
margin-bottom: 8px;
} }
.tb-form-field-help-image { .tb-form-field-help-image {
max-width: 100%; max-width: 100%;
margin-top: 8px; margin-top: 4px;
margin-bottom: 8px;
border-radius: 4px; border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.12); border: 1px solid rgba(0, 0, 0, 0.12);
} }
} }
// Progress view
.tb-device-install-progress { .tb-device-install-progress {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
}
.tb-progress-row { .tb-progress-row {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 12px; gap: 12px;
padding: 8px 0; 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-pending { .tb-progress-icon {
color: rgba(0, 0, 0, 0.38); flex-shrink: 0;
} width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
.tb-progress-success { mat-icon {
color: #2e7d32; font-size: 20px;
} width: 20px;
height: 20px;
}
.tb-progress-error { .tb-progress-pending {
color: #c62828; color: rgba(0, 0, 0, 0.38);
} }
}
.tb-progress-label { .tb-progress-success {
flex: 1; color: #2e7d32;
font-size: 14px; }
line-height: 20px;
color: rgba(0, 0, 0, 0.87);
}
.tb-progress-error-msg { .tb-progress-error {
font-size: 12px; color: #c62828;
color: #c62828;
margin-top: 4px;
}
} }
} }
.tb-device-install-done { .tb-progress-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 4px;
min-height: 24px;
justify-content: center;
}
.tb-done-message { .tb-progress-label {
color: rgba(0, 0, 0, 0.87); font-size: 14px;
font-size: 14px; line-height: 20px;
line-height: 20px; color: rgba(0, 0, 0, 0.87);
}
} }
.tb-device-install-error { .tb-progress-error-msg {
max-height: 200px;
overflow-y: auto;
padding: 12px;
background: #f5f5f5;
border-radius: 4px;
font-family: monospace;
font-size: 12px; font-size: 12px;
color: rgba(0, 0, 0, 0.7); color: #c62828;
font-family: monospace;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
@ -203,8 +231,3 @@
mat-dialog-actions { mat-dialog-actions {
padding: 8px 24px 16px; 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. /// 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 { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; import { UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms';
import { MatStepper } from '@angular/material/stepper';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { PageLink } from '@shared/models/page/page-link'; import { PageLink } from '@shared/models/page/page-link';
import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models'; import { MpItemVersionView } from '@shared/models/iot-hub/iot-hub-version.models';
@ -38,14 +39,31 @@ import {
stepTypeAliasMap stepTypeAliasMap
} from '@shared/models/iot-hub/device-package.models'; } from '@shared/models/iot-hub/device-package.models';
export type DeviceInstallView = 'connectivity' | 'instruction' | 'form' | 'progress' | 'done';
export interface DeviceInstallDialogData { export interface DeviceInstallDialogData {
item: MpItemVersionView; item: MpItemVersionView;
zipData: ArrayBuffer; zipData: ArrayBuffer;
iotHubApiService: IotHubApiService; 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({ @Component({
selector: 'tb-device-install-dialog', selector: 'tb-device-install-dialog',
standalone: false, standalone: false,
@ -54,29 +72,28 @@ export interface DeviceInstallDialogData {
}) })
export class TbDeviceInstallDialogComponent implements OnInit { export class TbDeviceInstallDialogComponent implements OnInit {
@ViewChild('installStepper', {static: false}) stepper: MatStepper;
loading = true; loading = true;
packageInfo: DevicePackageInfo; packageInfo: DevicePackageInfo;
zipFiles = new Map<string, string>(); zipFiles = new Map<string, string>();
// Connectivity
showConnectivitySelector = false;
availableConnectivityTypes: string[] = []; availableConnectivityTypes: string[] = [];
selectedConnectivity: string | null = null; selectedConnectivity: string | null = null;
connectivityLabels = connectivityTypeTranslations;
currentView: DeviceInstallView = 'connectivity'; // Wizard
steps: DeviceInstallStep[] = []; wizardSteps: WizardStep[] = [];
currentStepIndex = 0; wizardStarted = false;
currentMarkdown = '';
currentFormFields: FormFieldDefinition[] = [];
currentFormGroup: UntypedFormGroup | null = null;
passwordVisible: Record<string, boolean> = {}; passwordVisible: Record<string, boolean> = {};
// Variable resolution state
formValues: Record<string, any> = {}; formValues: Record<string, any> = {};
entityOutputs = new Map<string, EntityStepOutput>(); entityOutputs = new Map<string, EntityStepOutput>();
positionalOutputs = new Map<number, EntityStepOutput>(); positionalOutputs = new Map<number, EntityStepOutput>();
entitySteps: EntityStepProgress[] = [];
progressError: string | null = null;
constructor( constructor(
@Inject(MAT_DIALOG_DATA) public data: DeviceInstallDialogData, @Inject(MAT_DIALOG_DATA) public data: DeviceInstallDialogData,
private dialogRef: MatDialogRef<TbDeviceInstallDialogComponent>, private dialogRef: MatDialogRef<TbDeviceInstallDialogComponent>,
@ -102,9 +119,10 @@ export class TbDeviceInstallDialogComponent implements OnInit {
); );
if (this.availableConnectivityTypes.length === 1) { if (this.availableConnectivityTypes.length === 1) {
this.selectedConnectivity = this.availableConnectivityTypes[0]; this.selectedConnectivity = this.availableConnectivityTypes[0];
this.steps = this.packageInfo.installSteps[this.selectedConnectivity] || []; this.showConnectivitySelector = false;
this.currentStepIndex = 0; this.startWizard();
this.advanceToCurrentStep(); } else {
this.showConnectivitySelector = true;
} }
} catch (e) { } catch (e) {
console.error('Failed to parse device package ZIP', e); console.error('Failed to parse device package ZIP', e);
@ -112,85 +130,50 @@ export class TbDeviceInstallDialogComponent implements OnInit {
this.loading = false; this.loading = false;
} }
getConnectivityLabel(ct: string): string { // --- Connectivity ---
return connectivityTypeTranslations.get(ct) || ct;
}
selectConnectivity(ct: string): void { selectConnectivity(ct: string): void {
this.selectedConnectivity = ct; this.selectedConnectivity = ct;
} }
next(): void { confirmConnectivity(): void {
if (this.currentView === 'connectivity') { if (!this.selectedConnectivity) {
if (!this.selectedConnectivity) {
return;
}
this.steps = this.packageInfo.installSteps[this.selectedConnectivity] || [];
this.currentStepIndex = 0;
this.formValues = {};
this.entityOutputs.clear();
this.positionalOutputs.clear();
this.advanceToCurrentStep();
return; return;
} }
this.startWizard();
}
if (this.currentView === 'form') { // --- Wizard ---
if (this.currentFormGroup && this.currentFormGroup.invalid) {
this.currentFormGroup.markAllAsTouched();
return;
}
if (this.currentFormGroup) {
Object.assign(this.formValues, this.currentFormGroup.value);
}
}
if (this.currentView === 'instruction' && this.isLastStep()) { get isLastWizardStep(): boolean {
this.done(); return this.stepper && this.stepper.selectedIndex === this.wizardSteps.length - 1;
return; }
}
this.currentStepIndex++; get currentWizardStep(): WizardStep | null {
this.advanceToCurrentStep(); if (!this.stepper || !this.wizardSteps.length) {
return null;
}
return this.wizardSteps[this.stepper.selectedIndex] ?? null;
} }
advanceToCurrentStep(): void { nextStep(): void {
if (this.currentStepIndex >= this.steps.length) { const step = this.currentWizardStep;
this.currentView = 'done'; if (!step) {
this.currentMarkdown = '';
return; return;
} }
if (step.type === 'form') {
const step = this.steps[this.currentStepIndex]; if (step.formGroup?.invalid) {
step.formGroup.markAllAsTouched();
switch (step.type) { return;
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;
} }
Object.assign(this.formValues, step.formGroup.getRawValue());
} }
} if (this.isLastWizardStep) {
this.done();
isLastStep(): boolean { return;
return this.currentStepIndex >= this.steps.length - 1; }
this.stepper.next();
this.onStepActivated();
} }
done(): void { done(): void {
@ -201,18 +184,17 @@ export class TbDeviceInstallDialogComponent implements OnInit {
this.dialogRef.close(false); this.dialogRef.close(false);
} }
retryEntitySteps(): void { retryEntitySteps(step: WizardStep): void {
this.progressError = null; step.progressError = null;
this.runEntitySteps(); this.runEntitySteps(step);
} }
getPatternErrorMessage(field: FormFieldDefinition): string { getPatternErrorMessage(field: FormFieldDefinition): string {
if (field.validators?.length > 0) { return field.validators?.[0]?.message || 'Invalid format';
return field.validators[0].message || 'Invalid format';
}
return 'Invalid format';
} }
// --- Variable Resolution ---
resolveVariables(content: string): string { resolveVariables(content: string): string {
return content.replace(/\$\{([^}]+)\}/g, (_match, key) => { return content.replace(/\$\{([^}]+)\}/g, (_match, key) => {
if (key in this.formValues) { if (key in this.formValues) {
@ -224,13 +206,13 @@ export class TbDeviceInstallDialogComponent implements OnInit {
const prop = key.substring(dotIdx + 1); const prop = key.substring(dotIdx + 1);
const output = this.entityOutputs.get(alias); const output = this.entityOutputs.get(alias);
if (output && prop in output) { if (output && prop in output) {
return String(output[prop]); return String((output as any)[prop]);
} }
const m = alias.match(/^step(\d+)$/); const m = alias.match(/^step(\d+)$/);
if (m) { if (m) {
const pos = this.positionalOutputs.get(parseInt(m[1], 10)); const pos = this.positionalOutputs.get(parseInt(m[1], 10));
if (pos && prop in pos) { 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 { // --- Private ---
const controls: Record<string, UntypedFormControl> = {};
private startWizard(): void {
this.formValues = {};
this.entityOutputs.clear();
this.positionalOutputs.clear();
this.passwordVisible = {}; 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 = []; const validators = [];
if (field.required) { if (field.required) {
validators.push(Validators.required); validators.push(Validators.required);
@ -249,42 +291,54 @@ export class TbDeviceInstallDialogComponent implements OnInit {
if (field.validators?.length > 0) { if (field.validators?.length > 0) {
validators.push(Validators.pattern(field.validators[0].pattern)); validators.push(Validators.pattern(field.validators[0].pattern));
} }
const initialValue = field.key in this.formValues const initialValue = field.defaultValue ?? (field.type === FormFieldType.BOOLEAN ? false : '');
? this.formValues[field.key]
: (field.defaultValue ?? (field.type === FormFieldType.BOOLEAN ? false : ''));
controls[field.key] = new UntypedFormControl(initialValue, validators); controls[field.key] = new UntypedFormControl(initialValue, validators);
if (field.type === FormFieldType.PASSWORD) { if (field.type === FormFieldType.PASSWORD) {
this.passwordVisible[field.key] = false; this.passwordVisible[field.key] = false;
} }
} }
this.currentFormGroup = new UntypedFormGroup(controls); ws.formGroup = new UntypedFormGroup(controls);
} }
private collectAndRunEntitySteps(): void { private onStepActivated(): void {
this.entitySteps = []; const step = this.currentWizardStep;
let i = this.currentStepIndex; if (!step) {
while (i < this.steps.length && ENTITY_STEP_TYPES.has(this.steps[i].type)) { return;
this.entitySteps.push({ }
step: this.steps[i], if (step.type === 'instruction') {
status: 'pending', const raw = this.zipFiles.get(step.rawSteps[0].file) || '';
resolvedName: this.resolveVariables(this.steps[i].name) step.markdown = this.resolveVariables(raw);
}); } else if (step.type === 'progress' && !step.progressDone) {
i++; this.initAndRunEntitySteps(step);
} }
this.currentView = 'progress';
this.progressError = null;
this.runEntitySteps();
} }
private async runEntitySteps(): Promise<void> { private initAndRunEntitySteps(ws: WizardStep): void {
for (const ep of this.entitySteps) { 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') { if (ep.status === 'success') {
continue; continue;
} }
ep.status = 'running'; ep.status = 'running';
ep.errorMessage = null; ep.errorMessage = null;
try { try {
const startTime = Date.now();
const output = await this.createEntity(ep.step); 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.entityOutput = output;
ep.status = 'success'; ep.status = 'success';
@ -292,13 +346,14 @@ export class TbDeviceInstallDialogComponent implements OnInit {
if (alias) { if (alias) {
this.entityOutputs.set(alias, output); 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) { if (stepIdx >= 0) {
this.positionalOutputs.set(stepIdx + 1, output); this.positionalOutputs.set(stepIdx + 1, output);
} }
// Re-resolve names of remaining pending steps // Re-resolve names of remaining pending steps
for (const remaining of this.entitySteps) { for (const remaining of ws.entitySteps) {
if (remaining.status === 'pending') { if (remaining.status === 'pending') {
remaining.resolvedName = this.resolveVariables(remaining.step.name); remaining.resolvedName = this.resolveVariables(remaining.step.name);
} }
@ -306,26 +361,27 @@ export class TbDeviceInstallDialogComponent implements OnInit {
} catch (err: any) { } catch (err: any) {
ep.status = 'error'; ep.status = 'error';
ep.errorMessage = err?.error?.message || err?.message || 'Unknown error'; ep.errorMessage = err?.error?.message || err?.message || 'Unknown error';
this.progressError = ep.errorMessage; ws.progressError = ep.errorMessage;
return; return;
} }
} }
// All entity steps succeeded — advance past them // All done — report install
const lastEntityIdx = this.currentStepIndex + this.entitySteps.length - 1; ws.progressDone = true;
this.currentStepIndex = lastEntityIdx + 1;
// Report install
try { try {
await firstValueFrom( await firstValueFrom(
this.data.iotHubApiService.reportVersionInstalled(this.data.item.id as string, { ignoreLoading: true }) this.data.iotHubApiService.reportVersionInstalled(this.data.item.id as string, { ignoreLoading: true })
); );
} catch (_e) { } catch (_e) {
// Non-critical — best effort // Non-critical
} }
// Advance to next step (instruction after entities, or done) // Auto-advance to next step after a short pause
this.advanceToCurrentStep(); if (!this.isLastWizardStep) {
await this.delay(500);
this.stepper.next();
this.onStepActivated();
}
} }
private async createEntity(step: DeviceInstallStep): Promise<EntityStepOutput> { 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); const match = page.data.find(rc => rc.name === name);
return match ? { id: match.id.id, name: match.name } : null; 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