},
{
path: 'osd',
+ component: OsdListComponent,
data: { breadcrumbs: 'Cluster/OSDs' },
children: [
- { path: '', component: OsdListComponent },
{
path: URLVerbs.CREATE,
component: OsdFormComponent,
- data: { breadcrumbs: ActionLabels.CREATE }
+ outlet: 'modal'
}
]
},
(emitDriveGroup)="setDriveGroup($event)"
(emitDeploymentOption)="setDeploymentOptions($event)"
(emitMode)="setDeploymentMode($event)"
- (osdCreated)="onOsdCreated()"></cd-osd-form>
+ (osdCreated)="onOsdCreated()"
+ (cancelled)="onFormCancelled()"></cd-osd-form>
} @else {
<cd-osd-list [showTabs]="false"
+ [inlineCreate]="true"
(createAction)="onCreateAction()"></cd-osd-list>
}
</div>
this.formGroup.patchValue({ skipped: true });
}
+ onFormCancelled() {
+ this.showForm = false;
+ }
+
onCreateAction() {
this.showForm = true;
}
[steps]="steps"
[title]="!hideTitle ? (action | titlecase) + ' ' + (resource | upperFirst) : ''"
(submitRequested)="submit()"
+ (closeRequested)="onCancel()"
(stepChanged)="populateReviewData()"
[isSubmitLoading]="isSubmitLoading"
[submitButtonLabel]="simpleDeployment ? createOsdsLabel : actionLabels.PREVIEW"
- type="full">
+ type="wide">
<cd-tearsheet-step>
<div class="osd-tearsheet-content">
cdsStack="vertical"
[gap]="6">
- @if (availDevices?.length === 0) {
+ @if (!hasEligibleDevices) {
<cd-alert-panel
type="warning"
class="osd-alert-block"
formControlName="deploymentMode"
orientation="vertical">
- <cds-radio value="automatic">
+ <cds-radio value="automatic"
+ [disabled]="!hasEligibleDevices ? true : null">
<div class="osd-radio-label-wrapper">
<span
class="cds--type-body-short-01"
<cds-radio
[value]="optionName"
[class.cds-mb-5]="!isLast"
- [disabled]="!deploymentOptions?.options[optionName].available ? true : null">
+ [disabled]="!hasEligibleDevices || !deploymentOptions?.options[optionName].available ? true : null">
<div class="osd-radio-label-wrapper">
<span class="cds--type-body-short-01">
</div>
}
- <cds-radio value="manual">
+ <cds-radio value="manual"
+ [disabled]="!hasEligibleDevices ? true : null">
<div class="osd-radio-label-wrapper">
<span
class="cds--type-body-short-01"
</div>
</div>
+ <div formGroupName="features">
+ <cds-text-label
+ class="cds--type-heading-compact-02 cds-mb-5"
+ i18n>
+ Features
+ </cds-text-label>
+
+ @for (feature of featureList; track feature.key) {
+ <cds-checkbox
+ [name]="feature.key"
+ formControlName="{{ feature.key }}"
+ (checkedChange)="emitDeploymentSelection()">
+ {{ feature.desc }}
+ </cds-checkbox>
+ }
+ </div>
+
</form>
</div>
</cd-tearsheet-step>
</cd-tearsheet-step>
}
- <cd-tearsheet-step>
- <div class="osd-tearsheet-content">
- <form
- [formGroup]="form">
-
- <div formGroupName="features">
- <cds-text-label
- class="cds--type-heading-compact-02 cds-mb-5"
- i18n>
- Features
- </cds-text-label>
-
- @for (feature of featureList; track feature.key) {
- <cds-checkbox
- [name]="feature.key"
- formControlName="{{ feature.key }}"
- (checkedChange)="emitDeploymentSelection()">
- {{ feature.desc }}
- </cds-checkbox>
- }
- </div>
-
- </form>
- </div>
- </cd-tearsheet-step>
<cd-tearsheet-step>
<div class="osd-tearsheet-content">
OsdDeploymentOptions.THROUGHPUT,
OsdDeploymentOptions.IOPS
]);
- expect(component.steps).toHaveLength(3);
- expect(component.steps.map((step) => step.label)).toEqual([
- 'Deployment Options',
- 'Features',
- 'Review'
- ]);
+ expect(component.steps).toHaveLength(2);
+ expect(component.steps.map((step) => step.label)).toEqual(['Deployment Options', 'Review']);
});
it('should expand and collapse steps when deployment mode changes', () => {
component.form.get('deploymentMode').setValue('manual');
fixture.detectChanges();
- expect(component.steps).toHaveLength(5);
+ expect(component.steps).toHaveLength(4);
expect(component.steps.map((step) => step.label)).toEqual([
'Deployment Options',
'Select data devices',
'Select DB/WAL devices (optional)',
- 'Features',
'Review'
]);
expect(fixture.nativeElement.textContent).toContain('Select data devices');
component.form.get('deploymentMode').setValue('automatic');
fixture.detectChanges();
- expect(component.steps).toHaveLength(3);
- expect(component.steps.map((step) => step.label)).toEqual([
- 'Deployment Options',
- 'Features',
- 'Review'
- ]);
+ expect(component.steps).toHaveLength(2);
+ expect(component.steps.map((step) => step.label)).toEqual(['Deployment Options', 'Review']);
expect(fixture.nativeElement.textContent).not.toContain('Select data devices');
expect(fixture.nativeElement.textContent).not.toContain('Select DB/WAL devices (optional)');
});
expect(text).toContain('IOPS-optimized');
});
+ it('should mark the first step invalid when no eligible devices are available', () => {
+ expect(component.hasEligibleDevices).toBe(false);
+ expect(component.steps[0].invalid).toBe(true);
+ expect(component.form.get('deploymentMode').disabled).toBe(true);
+ expect(component.form.get('deploymentOption').disabled).toBe(true);
+ });
+
it('should only disable the options that are not available', () => {
expect(deploymentOptions.options['throughput_optimized'].available).toBeFalsy();
expect(deploymentOptions.options['iops_optimized'].available).toBeFalsy();
ViewChild
} from '@angular/core';
import { UntypedFormControl } from '@angular/forms';
+import { Location } from '@angular/common';
import { Router } from '@angular/router';
import _ from 'lodash';
@Output() osdCreated: EventEmitter<void> = new EventEmitter();
+ @Output() cancelled: EventEmitter<void> = new EventEmitter();
+
icons = Icons;
form!: CdFormGroup;
private orchService: OrchestratorService,
private hostService: HostService,
private router: Router,
+ private location: Location,
private formatterService: FormatterService,
private modalService: ModalService,
private osdService: OsdService,
this.createForm();
}
+ get hasEligibleDevices(): boolean {
+ return this.allDevices.length > 0;
+ }
+
private getStepsForMode(mode: string): Array<Step> {
+ const firstStepInvalid = !this.hasEligibleDevices;
+ const subsequentStepDisabled = !this.hasEligibleDevices;
return mode !== 'manual'
? [
- { label: STEP_LABELS.DEPLOYMENT, invalid: false },
- { label: STEP_LABELS.FEATURES, invalid: false },
- { label: STEP_LABELS.REVIEW, invalid: false }
+ { label: STEP_LABELS.DEPLOYMENT, invalid: firstStepInvalid },
+ { label: STEP_LABELS.REVIEW, invalid: false, disabled: subsequentStepDisabled }
]
: [
- { label: STEP_LABELS.DEPLOYMENT, invalid: false },
- { label: STEP_LABELS.DATA, invalid: false },
- { label: STEP_LABELS.DB_WAL, invalid: false },
- { label: STEP_LABELS.FEATURES, invalid: false },
- { label: STEP_LABELS.REVIEW, invalid: false }
+ { label: STEP_LABELS.DEPLOYMENT, invalid: firstStepInvalid },
+ { label: STEP_LABELS.DATA, invalid: false, disabled: subsequentStepDisabled },
+ { label: STEP_LABELS.DB_WAL, invalid: false, disabled: subsequentStepDisabled },
+ { label: STEP_LABELS.REVIEW, invalid: false, disabled: subsequentStepDisabled }
];
}
+ private updateFirstStepInvalid() {
+ const invalid = !this.hasEligibleDevices;
+ const subsequentStepDisabled = !this.hasEligibleDevices;
+ const unchanged =
+ this.steps[0]?.invalid === invalid &&
+ this.steps.slice(1).every((step) => step.disabled === subsequentStepDisabled);
+ if (unchanged) {
+ return;
+ }
+ this.steps = this.steps.map((step, i) => {
+ if (i === 0) {
+ return { ...step, invalid };
+ }
+ return { ...step, disabled: subsequentStepDisabled };
+ });
+ }
+
+ private updateDeploymentControlsAvailability() {
+ const deploymentMode = this.form.get('deploymentMode');
+ const deploymentOption = this.form.get('deploymentOption');
+ if (!this.hasEligibleDevices) {
+ deploymentMode?.disable({ emitEvent: false });
+ deploymentOption?.disable({ emitEvent: false });
+ } else {
+ deploymentMode?.enable({ emitEvent: false });
+ deploymentOption?.enable({ emitEvent: false });
+ }
+ this.updateFirstStepInvalid();
+ }
+
private createEmptyReviewDeviceSelection(): ReviewDeviceSelection {
return {
count: 0,
private updateSteps() {
const mode = this.form?.get('deploymentMode')?.value ?? 'automatic';
this.steps = this.getStepsForMode(mode);
+ this.updateFirstStepInvalid();
}
ngOnInit() {
(devices: InventoryDevice[]) => {
this.allDevices = _.filter(devices, 'available');
this.availDevices = [...this.allDevices];
+ this.updateDeploymentControlsAvailability();
this.loadingReady();
},
() => {
this.allDevices = [];
this.availDevices = [];
+ this.updateDeploymentControlsAvailability();
this.loadingError();
}
);
);
}
+ onCancel() {
+ if (this.hideTitle) {
+ this.cancelled.emit();
+ return;
+ }
+ this.location.back();
+ }
+
private navigateAfterCreate() {
const returnUrl = window.history.state?.returnUrl;
</ng-container>
</ng-container>
</ng-template>
+
+<router-outlet name="modal"></router-outlet>
import {
Component,
- EventEmitter,
Input,
OnInit,
- Output,
TemplateRef,
- ViewChild
+ ViewChild,
+ Output,
+ EventEmitter
} from '@angular/core';
import { UntypedFormControl } from '@angular/forms';
import { Router } from '@angular/router';
import { ModalService } from '~/app/shared/services/modal.service';
import { NotificationService } from '~/app/shared/services/notification.service';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
-import { URLBuilderService } from '~/app/shared/services/url-builder.service';
import { OsdFlagsIndivModalComponent } from '../osd-flags-indiv-modal/osd-flags-indiv-modal.component';
import { OsdFlagsModalComponent } from '../osd-flags-modal/osd-flags-modal.component';
import { OsdPgScrubModalComponent } from '../osd-pg-scrub-modal/osd-pg-scrub-modal.component';
selector: 'cd-osd-list',
templateUrl: './osd-list.component.html',
styleUrls: ['./osd-list.component.scss'],
- providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }],
standalone: false
})
export class OsdListComponent extends ListWithDetails implements OnInit {
@Input() showTabs = true;
+ @Input() inlineCreate = false;
@Output() createAction = new EventEmitter<void>();
@ViewChild('osdUsageTpl', { static: true })
private osdService: OsdService,
private dimlessBinaryPipe: DimlessBinaryPipe,
private modalService: ModalService,
- private urlBuilder: URLBuilderService,
private router: Router,
private taskWrapper: TaskWrapperService,
public actionLabels: ActionLabelsI18n,
permission: 'create',
icon: Icons.add,
click: () => {
- if (this.createAction.observers.length > 0) {
+ if (this.inlineCreate) {
this.createAction.emit();
} else {
- this.router.navigate([this.urlBuilder.getCreate()], {
- state: {
- returnUrl: this.router.url
- }
- });
+ this.router.navigate([BASE_URL, { outlets: { modal: [URLVerbs.CREATE] } }]);
}
},
disable: (selection: CdTableSelection) => this.getDisable('create', selection),
@else {
<button cdsButton="primary"
size="xl"
+ [disabled]="steps[currentStep]?.invalid"
(click)="onNext()"
i18n>Next</button>
}
@else {
<button cdsButton="primary"
size="xl"
+ [disabled]="steps[currentStep]?.invalid"
(click)="onNext()"
i18n>Next</button>
}
tearsheetComponent.onNext();
expect(tearsheetComponent.currentStep).toBe(0);
});
+
+ it('should disable next button when current step is invalid', () => {
+ hostComponent.steps = hostComponent.steps.map((step, i) =>
+ i === 0 ? { ...step, invalid: true } : step
+ );
+ hostFixture.detectChanges();
+ const buttons = hostFixture.debugElement.queryAll(
+ By.css('.tearsheet-footer button[cdsButton="primary"]')
+ );
+ const nextBtn = buttons.find((btn) => btn.nativeElement.textContent.trim() === 'Next');
+ expect(nextBtn).toBeTruthy();
+ expect(nextBtn?.nativeElement.disabled).toBe(true);
+ });
});
});
AfterViewInit,
DestroyRef,
OnDestroy,
+ OnChanges,
+ SimpleChanges,
ChangeDetectionStrategy,
TemplateRef,
ViewEncapsulation
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None
})
-export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy {
+export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy, OnChanges {
@Input() title!: string;
@Input() steps!: Array<Step>;
@Input() description!: string;
this.hasModalOutlet = this.route.outlet === 'modal';
}
+ ngOnChanges(changes: SimpleChanges) {
+ if (changes['steps']) {
+ this.lastStep = this.steps.length - 1;
+ if (this.currentStep > this.lastStep) {
+ this.currentStep = this.lastStep;
+ }
+ this.cdr.markForCheck();
+ }
+ }
+
private _updateStepInvalid(index: number, invalid: boolean) {
this.steps = this.steps.map((step, i) => (i === index ? { ...step, invalid } : step));
}
onStepSelect(event: { step: Step; index: number }) {
+ if (this.isStepNavBlocked(event.index)) {
+ return;
+ }
this.currentStep = event.index;
this.stepChanged.emit({ current: this.currentStep });
+ this.cdr.markForCheck();
+ }
+
+ private isStepNavBlocked(index: number): boolean {
+ if (this.steps[index]?.disabled) {
+ return true;
+ }
+ if (index > this.currentStep && this.steps[this.currentStep]?.invalid) {
+ return true;
+ }
+ for (let i = 0; i < index; i++) {
+ if (this.steps[i]?.invalid || this.steps[i]?.disabled) {
+ return true;
+ }
+ }
+ return false;
}
closeTearsheet() {
closeWideTearsheet() {
this.closeRequested.emit();
this.isOpen = false;
+ if (this.closeRequested.observers.length > 0) {
+ return;
+ }
if (this.hasModalOutlet) {
this.location.back();
} else {