Browse Source

feat(iot-hub): use tabs instead of stepper for read-only review dialog

Replace mat-stepper with mat-tab-group in review mode for a cleaner
read-only experience. All tabs are freely clickable without linear
progression.

- Extract step content into shared ng-template (used by both stepper
  and tabs to avoid duplication)
- Pre-activate all step content on init in review mode (tabs render
  lazily)
- Add onTabChanged handler for tab selection
- Footer shows Close + Open Dashboard buttons
- Install mode stepper unchanged
pull/15508/head
Andrii Shvaika 6 months ago
parent
commit
4b38c2cd75
  1. 348
      ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.html
  2. 11
      ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.scss
  3. 27
      ui-ngx/src/app/modules/home/components/iot-hub/device-install-dialog/device-install-dialog.component.ts

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

@ -15,6 +15,162 @@
limitations under the License. limitations under the License.
--> -->
<!-- Shared step content template -->
<ng-template #stepContent let-ws>
<div class="tb-wizard-step-content">
@switch (ws.type) {
@case ('instruction') {
<div class="tb-device-install-instruction" #instructionContainer>
<tb-markdown [data]="ws.markdown"
[usePlainMarkdown]="true"
(ready)="onMarkdownReady(instructionContainer)">
</tb-markdown>
</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]="resolveImagePath(field.helpImage)" [alt]="field.label">
}
}
</form>
}
</div>
}
@case ('progress') {
<div class="tb-device-install-progress">
@for (ep of ws.entitySteps; track ep.step.name) {
<div class="tb-progress-row" [class.tb-progress-conflict-row]="ep.status === 'conflict'">
<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>
}
@case ('conflict') {
<mat-icon class="tb-progress-conflict">warning</mat-icon>
}
}
</div>
<div class="tb-progress-info">
<span class="tb-progress-label">{{ ('iot-hub.device-install-step-type-' + ep.step.type | translate) + ' — ' + (ep.resolvedName || ep.step.name) }}</span>
@if (ep.status === 'error' && ep.errorMessage) {
<span class="tb-progress-error-msg">{{ ep.errorMessage }}</span>
}
@if (ep.status === 'conflict') {
<span class="tb-progress-conflict-msg">{{ 'iot-hub.device-install-conflict-exists' | translate:{ type: ('iot-hub.device-install-step-type-' + ep.step.type | translate) } }}</span>
}
</div>
@if (ep.status === 'success' && ep.resolution) {
<span class="tb-progress-resolution">{{ 'iot-hub.device-install-resolution-' + ep.resolution | translate }}</span>
}
@if (ep.status === 'conflict') {
<div class="tb-progress-conflict-actions">
@if (ep.conflictType === 'use-or-overwrite') {
<button mat-stroked-button color="primary" (click)="resolveConflict(ws, ep, 'use-existing')">
{{ 'iot-hub.device-install-use-existing' | translate }}
</button>
<button mat-stroked-button color="warn" (click)="resolveConflict(ws, ep, 'overwrite')">
{{ 'iot-hub.device-install-overwrite' | translate }}
</button>
} @else {
<button mat-stroked-button color="warn" (click)="resolveConflict(ws, ep, 'overwrite')">
{{ 'iot-hub.device-install-overwrite' | translate }}
</button>
<button mat-stroked-button color="primary" (click)="resolveConflict(ws, ep, 'create-copy')">
{{ 'iot-hub.device-install-create-copy' | translate }}
</button>
}
</div>
}
</div>
}
</div>
}
}
</div>
</ng-template>
<!-- Main dialog -->
@if (loading) { @if (loading) {
<div class="tb-device-install-loading"> <div class="tb-device-install-loading">
<mat-spinner diameter="40"></mat-spinner> <mat-spinner diameter="40"></mat-spinner>
@ -56,182 +212,54 @@
{{ 'action.next' | translate }} {{ 'action.next' | translate }}
</button> </button>
</mat-dialog-actions> </mat-dialog-actions>
} @else if (reviewMode) {
<!-- Review mode: tabs -->
<div class="tb-device-install-tabs-container">
<mat-tab-group (selectedIndexChange)="onTabChanged($event)">
@for (ws of wizardSteps; track ws.label) {
<mat-tab [label]="ws.label">
<ng-template matTabContent>
<div class="tb-tab-content">
<ng-container *ngTemplateOutlet="stepContent; context: { $implicit: ws }"></ng-container>
</div>
</ng-template>
</mat-tab>
}
</mat-tab-group>
</div>
<mat-dialog-actions align="end">
<button mat-button (click)="done()">{{ 'action.close' | translate }}</button>
@if (primaryEntityAction; as action) {
<button mat-flat-button color="primary" (click)="openEntity(action.url)">
{{ action.label }}
</button>
}
</mat-dialog-actions>
} @else { } @else {
<!-- Stepper wizard --> <!-- Install mode: stepper -->
<div class="tb-device-install-stepper-container"> <div class="tb-device-install-stepper-container">
<mat-horizontal-stepper #installStepper <mat-horizontal-stepper #installStepper
[linear]="!reviewMode" [linear]="true"
labelPosition="bottom" labelPosition="bottom">
(selectionChange)="onStepChanged()">
<ng-template matStepperIcon="edit"> <ng-template matStepperIcon="edit">
<mat-icon>done</mat-icon> <mat-icon>done</mat-icon>
</ng-template> </ng-template>
@for (ws of wizardSteps; track ws.label; let i = $index; let last = $last) { @for (ws of wizardSteps; track ws.label; let i = $index; let last = $last) {
<mat-step [editable]="reviewMode || ws.type === 'form'" <mat-step [editable]="ws.type === 'form'"
[completed]="ws.completed" [completed]="ws.completed"
[stepControl]="ws.type === 'form' && !reviewMode ? ws.formGroup : null"> [stepControl]="ws.type === 'form' ? ws.formGroup : null">
<ng-template matStepLabel>{{ ws.label }}</ng-template> <ng-template matStepLabel>{{ ws.label }}</ng-template>
<ng-container *ngTemplateOutlet="stepContent; context: { $implicit: ws }"></ng-container>
<div class="tb-wizard-step-content">
@switch (ws.type) {
@case ('instruction') {
<div class="tb-device-install-instruction" #instructionContainer>
<tb-markdown [data]="ws.markdown"
[usePlainMarkdown]="true"
(ready)="onMarkdownReady(instructionContainer)">
</tb-markdown>
</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]="resolveImagePath(field.helpImage)" [alt]="field.label">
}
}
</form>
}
</div>
}
@case ('progress') {
<div class="tb-device-install-progress">
@for (ep of ws.entitySteps; track ep.step.name) {
<div class="tb-progress-row" [class.tb-progress-conflict-row]="ep.status === 'conflict'">
<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>
}
@case ('conflict') {
<mat-icon class="tb-progress-conflict">warning</mat-icon>
}
}
</div>
<div class="tb-progress-info">
<span class="tb-progress-label">{{ ('iot-hub.device-install-step-type-' + ep.step.type | translate) + ' — ' + (ep.resolvedName || ep.step.name) }}</span>
@if (ep.status === 'error' && ep.errorMessage) {
<span class="tb-progress-error-msg">{{ ep.errorMessage }}</span>
}
@if (ep.status === 'conflict') {
<span class="tb-progress-conflict-msg">{{ 'iot-hub.device-install-conflict-exists' | translate:{ type: ('iot-hub.device-install-step-type-' + ep.step.type | translate) } }}</span>
}
</div>
@if (ep.status === 'success' && ep.resolution) {
<span class="tb-progress-resolution">{{ 'iot-hub.device-install-resolution-' + ep.resolution | translate }}</span>
}
@if (ep.status === 'conflict') {
<div class="tb-progress-conflict-actions">
@if (ep.conflictType === 'use-or-overwrite') {
<button mat-stroked-button color="primary" (click)="resolveConflict(ws, ep, 'use-existing')">
{{ 'iot-hub.device-install-use-existing' | translate }}
</button>
<button mat-stroked-button color="warn" (click)="resolveConflict(ws, ep, 'overwrite')">
{{ 'iot-hub.device-install-overwrite' | translate }}
</button>
} @else {
<button mat-stroked-button color="warn" (click)="resolveConflict(ws, ep, 'overwrite')">
{{ 'iot-hub.device-install-overwrite' | translate }}
</button>
<button mat-stroked-button color="primary" (click)="resolveConflict(ws, ep, 'create-copy')">
{{ 'iot-hub.device-install-create-copy' | translate }}
</button>
}
</div>
}
</div>
}
</div>
}
}
</div>
</mat-step> </mat-step>
} }
</mat-horizontal-stepper> </mat-horizontal-stepper>
</div> </div>
<mat-dialog-actions align="end"> <mat-dialog-actions align="end">
@if (reviewMode) { @if (currentWizardStep; as step) {
<button mat-flat-button color="primary" (click)="done()">{{ 'action.close' | translate }}</button>
} @else if (currentWizardStep; as step) {
@switch (step.type) { @switch (step.type) {
@case ('instruction') { @case ('instruction') {
@if (isLastWizardStep) { @if (isLastWizardStep) {

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

@ -97,6 +97,17 @@
} }
} }
// Tabs container (review mode)
.tb-device-install-tabs-container {
flex: 1;
overflow-y: auto;
min-height: 200px;
.tb-tab-content {
padding: 16px 24px;
}
}
// Stepper container // Stepper container
.tb-device-install-stepper-container { .tb-device-install-stepper-container {
flex: 1; flex: 1;

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

@ -190,6 +190,17 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
this.onStepActivated(); this.onStepActivated();
} }
onTabChanged(index: number): void {
const ws = this.wizardSteps[index];
if (!ws) return;
if (ws.type === 'instruction' && !ws.markdown) {
const raw = this.zipFiles.get(ws.rawSteps[0].file) || '';
ws.markdown = this.resolveImages(this.resolveVariables(raw));
} else if (ws.type === 'progress' && !ws.progressDone) {
this.showCompletedEntitySteps(ws);
}
}
confirmConnectivity(): void { confirmConnectivity(): void {
if (!this.selectedConnectivity) { if (!this.selectedConnectivity) {
return; return;
@ -477,8 +488,20 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
this.buildWizardSteps(); this.buildWizardSteps();
this.wizardStarted = true; this.wizardStarted = true;
// Activate the first step after the stepper renders if (this.reviewMode) {
setTimeout(() => this.onStepActivated(), 0); // Pre-activate all steps for tab mode (tabs render lazily on first select)
for (const ws of this.wizardSteps) {
if (ws.type === 'instruction') {
const raw = this.zipFiles.get(ws.rawSteps[0].file) || '';
ws.markdown = this.resolveImages(this.resolveVariables(raw));
} else if (ws.type === 'progress') {
this.showCompletedEntitySteps(ws);
}
}
} else {
// Activate the first step after the stepper renders
setTimeout(() => this.onStepActivated(), 0);
}
} }
private buildWizardSteps(): void { private buildWizardSteps(): void {

Loading…
Cancel
Save