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.
-->
<!-- 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) {
<div class="tb-device-install-loading">
<mat-spinner diameter="40"></mat-spinner>
@ -56,182 +212,54 @@
{{ 'action.next' | translate }}
</button>
</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 {
<!-- Stepper wizard -->
<!-- Install mode: stepper -->
<div class="tb-device-install-stepper-container">
<mat-horizontal-stepper #installStepper
[linear]="!reviewMode"
labelPosition="bottom"
(selectionChange)="onStepChanged()">
[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]="reviewMode || ws.type === 'form'"
<mat-step [editable]="ws.type === 'form'"
[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>
<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-container *ngTemplateOutlet="stepContent; context: { $implicit: ws }"></ng-container>
</mat-step>
}
</mat-horizontal-stepper>
</div>
<mat-dialog-actions align="end">
@if (reviewMode) {
<button mat-flat-button color="primary" (click)="done()">{{ 'action.close' | translate }}</button>
} @else if (currentWizardStep; as step) {
@if (currentWizardStep; as step) {
@switch (step.type) {
@case ('instruction') {
@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
.tb-device-install-stepper-container {
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();
}
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 {
if (!this.selectedConnectivity) {
return;
@ -477,8 +488,20 @@ export class TbDeviceInstallDialogComponent extends DialogComponent<TbDeviceInst
this.buildWizardSteps();
this.wizardStarted = true;
// Activate the first step after the stepper renders
setTimeout(() => this.onStepActivated(), 0);
if (this.reviewMode) {
// 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 {

Loading…
Cancel
Save