@empty_response
@handle_nvmeof_error
@CreatePermission
- def add(self, subsystem_nqn: str, gw_group: str, host_nqn: str = ""):
+ def add(self, subsystem_nqn: str, host_nqn: str, dhchap_key: Optional[str] = None,
+ psk: Optional[str] = None, gw_group: Optional[str] = None,
+ server_address: Optional[str] = None
+ ):
response = None
- all_host_nqns = host_nqn.split(',')
- for nqn in all_host_nqns:
- response = NVMeoFClient(gw_group=gw_group).stub.add_host(
- NVMeoFClient.pb2.add_host_req(subsystem_nqn=subsystem_nqn, host_nqn=nqn)
+ if host_nqn != '*':
+ all_host_nqns = host_nqn.split(',')
+ for nqn in all_host_nqns:
+ response = NVMeoFClient(
+ gw_group=gw_group,
+ server_address=server_address
+ ).stub.add_host(
+ NVMeoFClient.pb2.add_host_req(
+ subsystem_nqn=subsystem_nqn,
+ host_nqn=nqn,
+ dhchap_key=dhchap_key,
+ psk=psk)
+ )
+ if response.status != 0:
+ return response
+ else:
+ response = NVMeoFClient(
+ gw_group=gw_group,
+ server_address=server_address
+ ).stub.add_host(
+ NVMeoFClient.pb2.add_host_req(
+ subsystem_nqn=subsystem_nqn,
+ host_nqn=host_nqn,
+ dhchap_key=dhchap_key,
+ psk=psk)
)
if response.status != 0:
return response
import { NvmeofGatewayGroupComponent } from './nvmeof-gateway-group/nvmeof-gateway-group.component';
import { NvmeofSubsystemsStepOneComponent } from './nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component';
import { NvmeofSubsystemsStepThreeComponent } from './nvmeof-subsystems-form/nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component';
+import { NvmeofSubsystemsStepTwoComponent } from './nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component';
import { NvmeofGatewayNodeComponent } from './nvmeof-gateway-node/nvmeof-gateway-node.component';
import { NvmeofGroupFormComponent } from './nvmeof-group-form /nvmeof-group-form.component';
TreeviewModule,
TabsModule,
TagModule,
+ LayoutModule,
+ ContainedListModule,
LayerModule
} from 'carbon-components-angular';
TabsModule,
TagModule,
GridModule,
- LayerModule
+ LayerModule,
+ LayoutModule,
+ ContainedListModule
],
declarations: [
RbdListComponent,
NvmeofGatewayNodeComponent,
NvmeofGroupFormComponent,
NvmeofSubsystemsStepOneComponent,
+ NvmeofSubsystemsStepTwoComponent,
NvmeofSubsystemsStepThreeComponent
],
exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
--- /dev/null
+
+<form [formGroup]="formGroup"
+ novalidate>
+ <div cdsGrid
+ [useCssGrid]="true"
+ [narrow]="true"
+ [fullWidth]="true">
+ <div cdsCol
+ [columnNumbers]="{lg: 10}"
+ class="cd-nvmeof-subsystem-step-two">
+ <div cdsRow
+ class="form-item cds-mb-0">
+ <h3 class="cds--type-heading-03"
+ i18n>Host access (Initiators)</h3>
+ <p
+ class="cds--type-label-02"
+ i18n>Select hosts that can initiate connections to this subsystem.</p>
+ </div>
+ <div cdsRow
+ class="form-item">
+ <cds-radio-group
+ formControlName="hostType"
+ orientation="vertical">
+ <cds-radio
+ [value]="HOST_TYPE.ALL"
+ i18n>Allow all hosts</cds-radio>
+ <span class="cds--form__helper-text cds-mb-3"
+ i18n>Any host can connect to this subsystem without verification.</span>
+ @if(formGroup.get('hostType').value === HOST_TYPE.ALL) {
+ <cd-alert-panel
+ title="Caution"
+ type="warning"
+ i18n
+ i18n-title>
+ Allowing all hosts grants access to every initiator on the network. Authentication is not supported in this mode, which may expose the subsystem to unauthorized access.
+ </cd-alert-panel>
+ }
+ <cds-radio [value]="HOST_TYPE.SPECIFIC">
+ <span i18n>Restrict to specific hosts</span>
+ <cds-tag
+ type="blue"
+ size="sm"
+ class="cd-nvmeof-subsystem-step-two-specific-hosts-tag"
+ i18n>
+ Recommended for secure environments
+ </cds-tag>
+ </cds-radio>
+ <span class="cds--form__helper-text"
+ i18n>Add the specific hosts permitted to connect.</span>
+ </cds-radio-group>
+ </div>
+ @if(formGroup.get('hostType').value === HOST_TYPE.SPECIFIC) {
+ <div cdsRow
+ class="form-item">
+ <h1
+ class="cds--type-heading-compact-01"
+ i18n>Add host manually</h1>
+ <label class="cds--label"
+ for="hostname">
+ <span i18n>Host name</span>
+ </label>
+ <div class="cd-nvmeof-subsystem-step-two-manual-hosts">
+ <cds-text-label
+ [invalid]="host.isInvalid"
+ [invalidText]="hostNameInvalidTemplate"
+ class="cd-nvmeof-subsystem-step-two-manual-hosts-input">
+ <input
+ cdsText
+ cdValidate
+ id="hostname"
+ #host="cdValidate"
+ formControlName="hostname"
+ [invalid]="host.isInvalid"
+ placeholder="Enter host NQN"
+ i18n-placeholder/>
+ </cds-text-label>
+ <button
+ cdsButton="tertiary"
+ [disabled]="host.isInvalid"
+ size="md"
+ class="cds-mt-3"
+ (click)="addHost()">
+ <span i18n>Add</span>
+ <cd-icon type="add"></cd-icon>
+ </button>
+ </div>
+ </div>
+ }
+ </div>
+ </div>
+</form>
+
+<ng-template #removeAllTemplate>
+ <button
+ i18n
+ cdsButton="ghost"
+ (click)="removeAll($event)">
+ Remove all
+ </button>
+</ng-template>
+
+<ng-template
+ #removeHostTemplate
+ let-host>
+ <cds-icon-button
+ type="button"
+ kind="ghost"
+ description="Remove host"
+ i18n-description
+ (click)="removeHost(host)">
+ <cd-icon type="destroy"></cd-icon>
+</cds-icon-button>
+</ng-template>
+
+<ng-template #rightInfluencer>
+ @if(addedHostsLength === 0) {
+ <h1
+ class="cds--type-heading-compact-01"
+ i18n>Added hosts ({{addedHostsLength}})</h1>
+ <p
+ i18n
+ class="cds--type-body-01 cd-nvmeof-subsystem-step-two-added-hosts-text">No hosts added yet. Add hosts manually or upload a CSV file.</p>
+ } @else {
+ <cds-contained-list
+ label="Added hosts ({{addedHostsLength}})"
+ [action]="removeAllTemplate"
+ class="cd-nvmeof-subsystem-step-two-influencer">
+ @for (host of formGroup.get('addedHosts')?.value ; track host) {
+ <cds-contained-list-item
+ [action]="removeHostTemplate"
+ [actionData]="host"
+ class="cd-nvmeof-subsystem-step-two-host-list-item-container">
+ <span
+ class="cds--text-truncate--end"
+ [title]="host">{{host}}</span>
+ </cds-contained-list-item>
+ }
+ </cds-contained-list>
+ }
+</ng-template>
+
+<ng-template #hostNameInvalidTemplate>
+@for (err of formGroup.get('hostname').errors | keyvalue; track err.key) {
+<span class="invalid-feedback">{{ INVALID_TEXTS[err.key] }}</span>
+}
+</ng-template>
--- /dev/null
+.cd-nvmeof-subsystem-step-two {
+ &-manual-hosts {
+ display: flex;
+ align-items: flex-start;
+ }
+
+ &-manual-hosts-input {
+ flex: 0.5;
+ margin-right: var(--cds-spacing-05);
+ }
+
+ &-added-hosts-text {
+ color: var(--cds-text-secondary);
+ }
+
+ &-specific-hosts-tag {
+ max-inline-size: 17rem;
+ margin: 0 var(--cds-spacing-01);
+ }
+
+ &-influencer {
+ .cds--contained-list-item__content {
+ max-inline-size: 18rem !important;
+ padding-left: 0 !important;
+ }
+
+ .cds--contained-list__header {
+ padding: 0 !important;
+ }
+ }
+}
--- /dev/null
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { SharedModule } from '~/app/shared/shared.module';
+import { NvmeofSubsystemsStepTwoComponent } from './nvmeof-subsystem-step-2.component';
+import { GridModule, InputModule, RadioModule, TagModule } from 'carbon-components-angular';
+
+describe('NvmeofSubsystemsStepTwoComponent', () => {
+ let component: NvmeofSubsystemsStepTwoComponent;
+ let fixture: ComponentFixture<NvmeofSubsystemsStepTwoComponent>;
+ let form: CdFormGroup;
+ const mockGroupName = 'default';
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [NvmeofSubsystemsStepTwoComponent],
+ providers: [NgbActiveModal],
+ imports: [
+ HttpClientTestingModule,
+ NgbTypeaheadModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ SharedModule,
+ InputModule,
+ GridModule,
+ RadioModule,
+ TagModule,
+ ToastrModule.forRoot()
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(NvmeofSubsystemsStepTwoComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ form = component.formGroup;
+ component.group = mockGroupName;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('form initialization', () => {
+ it('should initialize form with default values', () => {
+ expect(form).toBeTruthy();
+ expect(form.get('hostType')?.value).toBe(component.HOST_TYPE.SPECIFIC);
+ expect(form.get('hostname')?.value).toBe('');
+ expect(form.get('addedHosts')?.value).toEqual([]);
+ });
+ });
+
+ describe('showRightInfluencer', () => {
+ it('should return true when hostType is SPECIFIC', () => {
+ form.get('hostType')?.setValue(component.HOST_TYPE.SPECIFIC);
+ expect(component.showRightInfluencer()).toBeTruthy();
+ });
+
+ it('should return false when hostType is ALL', () => {
+ form.get('hostType')!.setValue(component.HOST_TYPE.ALL);
+
+ expect(form.get('hostType')!.value).toBe(component.HOST_TYPE.ALL);
+ expect(component.showRightInfluencer()).toBeFalsy();
+ });
+ });
+
+ describe('hostname validation', () => {
+ it('should not require hostname when hostType is ALL', () => {
+ form.get('hostType')?.setValue(component.HOST_TYPE.ALL);
+ form.get('hostname')?.setValue('');
+
+ expect(form.get('hostname')?.hasError('required')).toBeFalsy();
+ });
+ });
+
+ describe('custom NQN validator', () => {
+ it('should mark invalid NQN format', () => {
+ form.get('hostname')?.setValue('invalid-nqn');
+
+ expect(form.get('hostname')?.hasError('pattern')).toBeTruthy();
+ });
+
+ it('should accept valid NQN format', () => {
+ const validNqn = 'nqn.2023-01.com.example:host1';
+ form.get('hostname')?.setValue(validNqn);
+
+ expect(form.get('hostname')?.valid).toBeTruthy();
+ });
+ });
+
+ describe('addHost', () => {
+ it('should add hostname to addedHosts list', () => {
+ const hostname = 'nqn.2023-01.com.example:host1';
+ form.get('hostname')?.setValue(hostname);
+
+ component.addHost();
+
+ expect(form.get('addedHosts')?.value).toEqual([hostname]);
+ expect(component.addedHostsLength).toBe(1);
+ });
+
+ it('should not add empty hostname', () => {
+ form.get('hostname')?.setValue('');
+
+ component.addHost();
+
+ expect(form.get('addedHosts')?.value).toEqual([]);
+ expect(component.addedHostsLength).toBe(0);
+ });
+ });
+});
--- /dev/null
+import { Component, Input, OnInit, TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core';
+import { FormControl, UntypedFormControl } from '@angular/forms';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { HOST_TYPE } from '~/app/shared/models/nvmeof';
+import { TearsheetStep } from '~/app/shared/models/tearsheet-step';
+
+@Component({
+ selector: 'cd-nvmeof-subsystem-step-two',
+ templateUrl: './nvmeof-subsystem-step-2.component.html',
+ styleUrls: ['./nvmeof-subsystem-step-2.component.scss'],
+ standalone: false,
+ encapsulation: ViewEncapsulation.None
+})
+export class NvmeofSubsystemsStepTwoComponent implements OnInit, TearsheetStep {
+ @Input() group!: string;
+ @ViewChild('rightInfluencer', { static: true })
+ rightInfluencer?: TemplateRef<any>;
+ formGroup: CdFormGroup;
+ action: string;
+ pageURL: string;
+ INVALID_TEXTS = {
+ pattern: $localize`Expected NQN format: "nqn.$year-$month.$reverseDomainName:$utf8-string" or "nqn.2014-08.org.nvmexpress:uuid:$UUID-string"`,
+ customRequired: $localize`This field is required`,
+ duplicate: $localize`Duplicate entry detected. Enter a unique value.`
+ };
+ HOST_TYPE = HOST_TYPE;
+ addedHostsLength: number = 0;
+ NQN_REGEX = /^nqn\.(19|20)\d\d-(0[1-9]|1[0-2])\.\D{2,3}(\.[A-Za-z0-9-]+)+(:[A-Za-z0-9-\.]+(:[A-Za-z0-9-\.]+)*)$/;
+ NQN_REGEX_UUID = /^nqn\.2014-08\.org\.nvmexpress:uuid:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
+ ALLOW_ALL_HOST = '*';
+
+ constructor(public actionLabels: ActionLabelsI18n, public activeModal: NgbActiveModal) {}
+
+ ngOnInit() {
+ this.createForm();
+ }
+
+ isValidNQN = CdValidators.custom(
+ 'pattern',
+ (input: string) => !!input && !(this.NQN_REGEX.test(input) || this.NQN_REGEX_UUID.test(input))
+ );
+
+ isDuplicate = CdValidators.custom(
+ 'duplicate',
+ (input: string) => !!input && this.formGroup?.get('addedHosts')?.value.includes(input)
+ );
+
+ isRequired = CdValidators.custom(
+ 'customRequired',
+ (input: string) =>
+ !input &&
+ this.addedHostsLength === 0 &&
+ this.formGroup?.get('hostType')?.value === this.HOST_TYPE.SPECIFIC
+ );
+
+ showRightInfluencer(): boolean {
+ return this.formGroup.get('hostType')?.value === this.HOST_TYPE.SPECIFIC;
+ }
+
+ createForm() {
+ this.formGroup = new CdFormGroup({
+ hostType: new UntypedFormControl(this.HOST_TYPE.SPECIFIC),
+ hostname: new FormControl<string>('', {
+ validators: [this.isValidNQN, this.isDuplicate, this.isRequired]
+ }),
+ addedHosts: new FormControl<string[]>([])
+ });
+ }
+
+ addHost() {
+ const hostnameCtrl = this.formGroup.get('hostname');
+ hostnameCtrl.markAsTouched();
+ hostnameCtrl.updateValueAndValidity();
+ if (hostnameCtrl.value && hostnameCtrl.valid) {
+ const addedHosts = this.formGroup.get('addedHosts').value;
+ const newHostList = [...addedHosts, hostnameCtrl.value];
+ this.addedHostsLength = newHostList.length;
+ this.formGroup.patchValue({
+ addedHosts: newHostList,
+ hostname: ''
+ });
+ }
+ }
+
+ removeHost(removedHost: string) {
+ const currentAddedHosts = this.formGroup.get('addedHosts').value;
+ const newHostList = currentAddedHosts.filter((currentHost) => currentHost !== removedHost);
+ this.addedHostsLength = newHostList.length;
+ this.formGroup.patchValue({
+ addedHosts: newHostList
+ });
+ this.formGroup.get('hostname').updateValueAndValidity();
+ }
+
+ removeAll() {
+ this.addedHostsLength = 0;
+ this.formGroup.patchValue({
+ addedHosts: []
+ });
+ this.formGroup.get('hostname').updateValueAndValidity();
+ }
+}
[group]="group"></cd-nvmeof-subsystem-step-one>
</cd-tearsheet-step>
<cd-tearsheet-step>
- <ng-template
- #tearsheetStep></ng-template>
+ <cd-nvmeof-subsystem-step-two
+ #tearsheetStep
+ [group]="group"></cd-nvmeof-subsystem-step-two>
</cd-tearsheet-step>
<cd-tearsheet-step>
<cd-nvmeof-subsystem-step-three
import { NvmeofSubsystemsStepOneComponent } from './nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component';
import { GridModule, InputModule, RadioModule, TagModule } from 'carbon-components-angular';
import { NvmeofSubsystemsStepThreeComponent } from './nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component';
+import { HOST_TYPE } from '~/app/shared/models/nvmeof';
+import { NvmeofSubsystemsStepTwoComponent } from './nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component';
+import { of } from 'rxjs';
describe('NvmeofSubsystemsFormComponent', () => {
let component: NvmeofSubsystemsFormComponent;
const mockPayload: SubsystemPayload = {
nqn: '',
gw_group: mockGroupName,
- subsystemDchapKey: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY='
+ subsystemDchapKey: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY=',
+ addedHosts: [],
+ hostType: HOST_TYPE.ALL
};
beforeEach(async () => {
declarations: [
NvmeofSubsystemsFormComponent,
NvmeofSubsystemsStepOneComponent,
- NvmeofSubsystemsStepThreeComponent
+ NvmeofSubsystemsStepThreeComponent,
+ NvmeofSubsystemsStepTwoComponent
],
providers: [NgbActiveModal],
imports: [
describe('should test form', () => {
beforeEach(() => {
nvmeofService = TestBed.inject(NvmeofService);
- spyOn(nvmeofService, 'createSubsystem').and.stub();
+ spyOn(nvmeofService, 'createSubsystem').and.returnValue(of({}));
+ spyOn(nvmeofService, 'addInitiators').and.returnValue(of({}));
});
it('should be creating request correctly', () => {
dhchap_key: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY='
});
});
+
+ it('should add initiators with wildcard when hostType is ALL', () => {
+ const payload: SubsystemPayload = {
+ nqn: 'test-nqn',
+ gw_group: mockGroupName,
+ addedHosts: [],
+ hostType: HOST_TYPE.ALL,
+ subsystemDchapKey: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY='
+ };
+
+ component.group = mockGroupName;
+ component.onSubmit(payload);
+
+ expect(nvmeofService.addInitiators).toHaveBeenCalledWith('test-nqn.default', {
+ host_nqn: '*',
+ gw_group: mockGroupName
+ });
+ });
});
});
-import { Component, DestroyRef, OnInit, ViewChild } from '@angular/core';
+import { Component, DestroyRef, OnInit, SecurityContext, ViewChild } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
-import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
import { ActivatedRoute, Router } from '@angular/router';
import { Step } from 'carbon-components-angular';
-import { FinishedTask } from '~/app/shared/models/finished-task';
-import { NvmeofService } from '~/app/shared/api/nvmeof.service';
-import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { InitiatorRequest, NvmeofService } from '~/app/shared/api/nvmeof.service';
import { TearsheetComponent } from '~/app/shared/components/tearsheet/tearsheet.component';
+import { HOST_TYPE } from '~/app/shared/models/nvmeof';
+import { from, Observable, of } from 'rxjs';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { catchError, concatMap, map, tap } from 'rxjs/operators';
+import { DomSanitizer } from '@angular/platform-browser';
export type SubsystemPayload = {
nqn: string;
gw_group: string;
subsystemDchapKey: string;
+ addedHosts: string[];
+ hostType: string;
};
+type StepResult = { step: string; success: boolean; error?: string };
+
+const PAGE_URL = 'block/nvmeof/subsystems';
+
@Component({
selector: 'cd-nvmeof-subsystems-form',
templateUrl: './nvmeof-subsystems-form.component.html',
},
{
label: $localize`Authentication`,
- invalid: false
- },
- {
- label: $localize`Advanced options`,
- complete: false,
- secondaryLabel: $localize`Advanced`
+ complete: false
}
];
title: string = $localize`Create Subsystem`;
private route: ActivatedRoute,
private destroyRef: DestroyRef,
private nvmeofService: NvmeofService,
- private taskWrapperService: TaskWrapperService,
- private router: Router
+ private notificationService: NotificationService,
+ private router: Router,
+ private sanitizer: DomSanitizer
) {}
ngOnInit() {
this.group = params?.['group'];
});
}
-
onSubmit(payload: SubsystemPayload) {
- const component = this;
- const pageURL = 'block/nvmeof/subsystems';
- let taskUrl = `nvmeof/subsystem/${URLVerbs.CREATE}`;
this.isSubmitLoading = true;
- this.taskWrapperService
- .wrapTaskAroundCall({
- task: new FinishedTask(taskUrl, {
- nqn: payload.nqn
- }),
- call: this.nvmeofService.createSubsystem({
- nqn: payload.nqn,
- gw_group: this.group,
- dhchap_key: payload.subsystemDchapKey,
- enable_ha: true
- })
+ const stepResults: StepResult[] = [];
+ const initiatorRequest: InitiatorRequest = {
+ host_nqn: payload.hostType === HOST_TYPE.ALL ? '*' : payload.addedHosts.join(','),
+ gw_group: this.group
+ };
+
+ this.nvmeofService
+ .createSubsystem({
+ nqn: payload.nqn,
+ gw_group: this.group,
+ enable_ha: true,
+ dhchap_key: payload.subsystemDchapKey
})
.subscribe({
- error() {
- component.isSubmitLoading = false;
+ next: () => {
+ stepResults.push({ step: this.steps[0].label, success: true });
+ this.runSequentialSteps(
+ [
+ {
+ step: this.steps[1].label,
+ call: () =>
+ this.nvmeofService.addInitiators(`${payload.nqn}.${this.group}`, initiatorRequest)
+ }
+ ],
+ stepResults
+ ).subscribe({
+ complete: () => this.showFinalNotification(stepResults)
+ });
},
- complete: () => {
- component.isSubmitLoading = false;
- this.router.navigate([pageURL, { outlets: { modal: null } }]);
+ error: (err) => {
+ err.preventDefault();
+ const errorMsg = err?.error?.detail || $localize`Subsystem creation failed`;
+ this.notificationService.show(
+ NotificationType.error,
+ $localize`Subsystem creation failed`,
+ errorMsg
+ );
+ this.isSubmitLoading = false;
+ this.router.navigate([PAGE_URL, { outlets: { modal: null } }]);
}
});
}
+
+ private runSequentialSteps(
+ steps: { step: string; call: () => Observable<any> }[],
+ stepResults: StepResult[]
+ ): Observable<void> {
+ return from(steps).pipe(
+ concatMap((step) =>
+ step.call().pipe(
+ tap(() => stepResults.push({ step: step.step, success: true })),
+ catchError((err) => {
+ err.preventDefault();
+ const errorMsg = err?.error?.detail || '';
+ stepResults.push({ step: step.step, success: false, error: errorMsg });
+ return of(null);
+ })
+ )
+ ),
+ map(() => void 0)
+ );
+ }
+
+ private showFinalNotification(stepResults: StepResult[]) {
+ this.isSubmitLoading = false;
+
+ const messageLines = stepResults.map((stepResult) =>
+ stepResult.success
+ ? $localize`<div>${stepResult.step} step created successfully</div><br/>`
+ : $localize`<div>${stepResult.step} step failed: <code>${stepResult.error}</code></div><br/>`
+ );
+
+ const rawHtml = messageLines.join('<br/>');
+ const sanitizedHtml = this.sanitizer.sanitize(SecurityContext.HTML, rawHtml) ?? '';
+
+ const hasFailure = stepResults.some((r) => !r.success);
+ const type = hasFailure ? NotificationType.error : NotificationType.success;
+ const title = hasFailure
+ ? $localize`Subsystem created (with errors)`
+ : $localize`Subsystem created`;
+
+ this.notificationService.show(type, title, sanitizedHtml);
+ this.router.navigate([PAGE_URL, { outlets: { modal: null } }]);
+ }
}
import InformationFilledIcon from '@carbon/icons/es/information--filled/16';
import WarningFilledIcon from '@carbon/icons/es/warning--filled/16';
import NotificationFilledIcon from '@carbon/icons/es/notification--filled/16';
-import CloseIcon from '@carbon/icons/es/close/16';
+import { Close16 } from '@carbon/icons';
import { TearsheetStepComponent } from './tearsheet-step/tearsheet-step.component';
import { ProductiveCardComponent } from './productive-card/productive-card.component';
InformationFilledIcon,
WarningFilledIcon,
NotificationFilledIcon,
- CloseIcon
+ Close16
]);
}
}
@ContentChild('tearsheetStep')
stepComponent!: TearsheetStep;
+
+ get rightInfluencer(): TemplateRef<any> | null {
+ return this.stepComponent?.rightInfluencer ?? null;
+ }
+
+ get showRightInfluencer(): boolean {
+ return this.stepComponent?.showRightInfluencer
+ ? this.stepComponent.showRightInfluencer()
+ : false;
+ }
}
<!-- Tearsheet Influencer-->
<div cdsCol
[columnNumbers]="{'lg': 3, 'md': 3, 'sm': 3}"
- class="tearsheet-influencer">
+ class="tearsheet-left-influencer">
<cds-progress-indicator
orientation="vertical"
[steps]="steps"
</header>
<section cdsGrid
class="tearsheet-body"
+ [condensed]="true"
[useCssGrid]="true"
[fullWidth]="true">
- <!-- Tearsheet Influencer-->
+ <!-- Tearsheet Left Influencer-->
<div cdsCol
- [columnNumbers]="{'lg': 3, 'md': 3, 'sm': 3}"
- class="tearsheet-influencer">
+ [columnNumbers]="{lg: 3, md: 3, sm: 3}"
+ class="tearsheet-left-influencer">
<cds-progress-indicator
orientation="vertical"
[steps]="steps"
</cds-progress-indicator>
</div>
<div cdsCol
- [columnNumbers]="{'lg': 13, 'md': 13, 'sm': 13}"
+ [columnNumbers]="{lg: 13, md: 13, sm: 13}"
class="tearsheet-main">
- <!-- Tearsheet Content Area -->
+ @if (showRightInfluencer) {
+ <!-- Tearsheet content with right influencer -->
+ <div
+ cdsGrid
+ [condensed]="true"
+ [useCssGrid]="true"
+ [fullWidth]="true">
+ <div
+ cdsCol
+ class="tearsheet-content"
+ [columnNumbers]="{ lg: 10 }">
+ <ng-container *ngTemplateOutlet="activeStepTemplate"></ng-container>
+ </div>
+ <aside
+ cdsCol
+ [columnNumbers]="{ lg: 3 }"
+ class="tearsheet-right-influencer">
+ <ng-container *ngTemplateOutlet="rightInfluencerTemplate"></ng-container>
+ </aside>
+ </div>
+ }
+ @else {
+ <!-- Tearsheet content without right influencer -->
<div class="tearsheet-content">
- <ng-container
- *ngTemplateOutlet="activeStepTemplate">
- </ng-container>
+ <ng-container *ngTemplateOutlet="activeStepTemplate"></ng-container>
</div>
+ }
<!-- Tearsheet Footer -->
<cds-modal-footer class="tearsheet-footer">
<button cdsButton="ghost"
- class="tearsheet-footer-cancel"
(click)="closeTearsheet()"
size="xl"
i18n>Cancel</button>
[overlay]="false"
size="sm">
</cds-loading>
- {{submitButtonLoadingLabel}}...
+ {{submitButtonLoadingLabel}}...
}
@else {
{{submitButtonLabel}}
// HEADER
.tearsheet-header {
fill: var(--cds-icon-primary);
- background-color: var(--cds-layer-01);
+ background-color: var(--cds-background);
padding: var(--cds-spacing-06) var(--cds-spacing-07);
border-block-end: 1px solid var(--cds-border-subtle-01);
padding: 0;
margin: 0;
height: 100%;
+}
+
+.tearsheet-left-influencer {
+ background-color: var(--cds-background);
+ padding: var(--cds-spacing-06) var(--cds-spacing-07);
+ overflow-block: auto;
+ overflow-y: auto;
+ border-inline-end: 1px solid var(--cds-border-subtle-01);
+ margin: 0;
+}
- .tearsheet-influencer {
- background-color: var(--cds-layer-01);
- padding: var(--cds-spacing-06) var(--cds-spacing-07);
- overflow-block: auto;
- overflow-y: auto;
- border-inline-end: 1px solid var(--cds-border-subtle-01);
- margin: 0;
+.tearsheet-right-influencer {
+ background-color: var(--cds-background);
+ padding: var(--cds-spacing-05) var(--cds-spacing-05);
+}
+
+.tearsheet-main {
+ margin: 0;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+
+ > div {
+ flex: 1;
}
+}
- .tearsheet-main {
- margin: 0;
- display: flex;
- flex-direction: column;
- height: 100%;
-
- .tearsheet-content {
- background-color: var(--cds-background);
- margin: 0;
- padding: var(--cds-spacing-06) var(--cds-spacing-07);
- flex: 1;
- overflow-y: auto;
-
- &--full {
- padding-left: 0;
- }
- }
+.tearsheet-content {
+ background-color: var(--cds-layer-01);
+ margin: 0;
+ padding: var(--cds-spacing-06) var(--cds-spacing-07);
+ overflow-y: auto;
+
+ &--full {
+ padding-left: 0;
}
}
//FOOTER
.tearsheet-footer {
border-top: 1px solid var(--cds-border-subtle);
- background: var(--cds-background);
-
- &-cancel {
- margin-left: var(--cds-spacing-05);
- }
+ background-color: var(--cds-layer-01);
&-submit {
display: flex;
QueryList,
AfterViewInit,
DestroyRef,
- OnDestroy
+ OnDestroy,
+ ChangeDetectionStrategy,
+ TemplateRef
} from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { Step } from 'carbon-components-angular';
selector: 'cd-tearsheet',
standalone: false,
templateUrl: './tearsheet.component.html',
- styleUrls: ['./tearsheet.component.scss']
+ styleUrls: ['./tearsheet.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
})
export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy {
@Input() title!: string;
return this.stepContents?.toArray()[this.currentStep]?.template;
}
+ get rightInfluencerTemplate(): TemplateRef<any> | null {
+ return this.stepContents?.toArray()[this.currentStep]?.rightInfluencer ?? null;
+ }
+
+ get showRightInfluencer(): boolean {
+ return this.stepContents?.toArray()[this.currentStep]?.showRightInfluencer;
+ }
+
currentStep: number = 0;
lastStep: number = null;
isOpen: boolean = true;
}
onNext() {
+ const formEl = document.querySelector('form');
+ formEl?.dispatchEvent(new Event('submit', { bubbles: true }));
if (this.currentStep !== this.lastStep && !this.steps[this.currentStep].invalid) {
this.currentStep = this.currentStep + 1;
}
notificationOff: 'notification--off',
notificationNew: 'notification--new',
success: 'success',
- warning: 'warning'
+ warning: 'warning',
+ add: 'add'
} as const;
Unidirectional = 'unidirectional',
Bidirectional = 'bidirectional'
}
+
+export const HOST_TYPE = {
+ ALL: 'all',
+ SPECIFIC: 'specific'
+};
+import { TemplateRef } from '@angular/core';
import { FormGroup } from '@angular/forms';
export interface TearsheetStep {
formGroup: FormGroup;
+ rightInfluencer?: TemplateRef<any>;
+ showRightInfluencer?: () => boolean;
}
@use '@carbon/layout';
+// PADDINGS
.cds-p-0 {
padding: 0;
}
padding-top: layout.$spacing-03;
}
-.cds-ml-3 {
- margin-left: layout.$spacing-03;
-}
-
-.cds-ml-5 {
- margin-left: layout.$spacing-05;
-}
-
-.cds-mr-3 {
- margin-right: layout.$spacing-03;
+// MARGINS
+.cds-m-0 {
+ margin: 0;
}
-.cds-mr-5 {
- margin-right: layout.$spacing-05;
+.cds-mb-0 {
+ margin-bottom: 0;
}
.cds-mb-1 {
.cds-mt-6 {
margin-top: layout.$spacing-06;
}
+
+.cds-ml-3 {
+ margin-left: layout.$spacing-03;
+}
+
+.cds-ml-5 {
+ margin-left: layout.$spacing-05;
+}
+
+.cds-mr-3 {
+ margin-right: layout.$spacing-03;
+}
+
+.cds-mr-5 {
+ margin-right: layout.$spacing-05;
+}