CheckboxModule,
ComboBoxModule,
DatePickerModule,
+ FileUploaderModule,
GridModule,
IconModule,
IconService,
NumberModule,
ModalModule,
DatePickerModule,
+ FileUploaderModule,
ComboBoxModule,
TabsModule,
TagModule,
class="form-item">
<h1
class="cds--type-heading-compact-01"
- i18n>Add hosts</h1>
+ i18n>Add host manually</h1>
<label class="cds--label"
for="hostname">
<span i18n>Host name</span>
</button>
</div>
</div>
+ <div cdsRow
+ class="form-item">
+ <h1
+ class="cds--type-heading-compact-01"
+ i18n>Upload CSV file</h1>
+ <p
+ class="cds--type-body-01 cds-mb-3 cd-nvmeof-subsystem-step-two-added-hosts-text"
+ i18n>Upload a CSV file containing a list of host names.</p>
+ <div class="cd-nvmeof-subsystem-step-two-csv-container">
+ <cds-file-uploader
+ [drop]="true"
+ [dropText]="csvDropText"
+ [accept]="['csv']"
+ [multiple]="false"
+ size="md"
+ class="cd-nvmeof-subsystem-step-two-csv-input"
+ (filesChange)="onCsvUpload($event)"
+ (removeFile)="onCsvUploadRemove()"></cds-file-uploader>
+ @if(csvUploadError) {
+ <cd-alert-panel
+ type="danger"
+ [showTitle]="false"
+ size="slim"
+ class="cds-mt-3 cd-nvmeof-subsystem-step-two-csv-error-panel">{{ csvUploadError }}</cd-alert-panel>
+ }
+ </div>
+ </div>
}
</div>
</div>
i18n>Added hosts ({{addedHostsLength}})</h1>
<p
i18n
- class="cds--type-body-01 cd-nvmeof-subsystem-step-two-added-hosts-text">No hosts added yet.</p>
+ 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}})"
+@use '@carbon/layout';
+
.cd-nvmeof-subsystem-step-two {
// Full width of the tearsheet content column; min-inline-size allows nested
// Carbon grid rows to shrink instead of bleeding into the right influencer.
min-inline-size: 0;
inline-size: 100%;
+ // Aligns with exactly 10 columns of the standard 16-column Carbon grid layout (10/16).
+ --host-input-width: #{percentage(10 / 16)};
+
&-manual-hosts {
display: flex;
align-items: flex-start;
}
&-manual-hosts-input {
- flex: 0.5;
+ flex: 0 0 var(--host-input-width);
margin-right: var(--cds-spacing-05);
}
max-inline-size: 17rem;
}
+ &-csv-container {
+ inline-size: var(--host-input-width);
+ }
+
+ &-csv-input {
+ .cds--file__drop-container {
+ min-block-size: layout.$spacing-12 !important;
+ }
+ }
+
+ &-csv-error-panel {
+ display: block;
+ width: 100%;
+
+ .cds--inline-notification,
+ .cds--actionable-notification {
+ width: 100%;
+ }
+ }
+
&-influencer {
.cds--contained-list-item__content {
max-inline-size: 18rem !important;
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';
+import {
+ FileUploaderModule,
+ GridModule,
+ InputModule,
+ RadioModule,
+ TagModule
+} from 'carbon-components-angular';
describe('NvmeofSubsystemsStepTwoComponent', () => {
let component: NvmeofSubsystemsStepTwoComponent;
ReactiveFormsModule,
RouterTestingModule,
SharedModule,
+ FileUploaderModule,
InputModule,
GridModule,
RadioModule,
expect(component.addedHostsLength).toBe(0);
});
});
+
+ describe('processCsvContent', () => {
+ it('should import valid host NQNs from CSV', () => {
+ const csvContent = `
+host_nqn
+nqn.2023-01.com.example:host1
+nqn.2023-01.com.example:host2
+`;
+
+ component.processCsvContent(csvContent);
+
+ expect(form.get('addedHosts')?.value).toEqual([
+ 'nqn.2023-01.com.example:host1',
+ 'nqn.2023-01.com.example:host2'
+ ]);
+ expect(component.addedHostsLength).toBe(2);
+ expect(component.csvUploadError).toBe('');
+ });
+
+ it('should skip duplicates and invalid values from CSV', () => {
+ form.get('addedHosts')?.setValue(['nqn.2023-01.com.example:existing']);
+ component.existingHosts = ['nqn.2023-01.com.example:already-there'];
+
+ const csvContent = `
+host_nqn
+nqn.2023-01.com.example:existing
+nqn.2023-01.com.example:new-host
+invalid-host
+nqn.2023-01.com.example:already-there
+nqn.2023-01.com.example:new-host
+`;
+
+ component.processCsvContent(csvContent);
+
+ expect(form.get('addedHosts')?.value).toEqual([
+ 'nqn.2023-01.com.example:existing',
+ 'nqn.2023-01.com.example:new-host'
+ ]);
+ expect(component.addedHostsLength).toBe(2);
+ expect(component.csvUploadError).toBe('');
+ });
+
+ it('should show error when csv has no valid hosts', () => {
+ const csvContent = `
+host_nqn
+invalid-host
+another-invalid-host
+`;
+
+ component.processCsvContent(csvContent);
+
+ expect(form.get('addedHosts')?.value).toEqual([]);
+ expect(component.csvUploadError).toBe('No valid hosts found in the CSV file.');
+ });
+
+ it('should not show invalid-csv error when all uploaded hosts are duplicates', () => {
+ form
+ .get('addedHosts')
+ ?.setValue(['nqn.2023-01.com.example:host1', 'nqn.2023-01.com.example:host2']);
+ component.addedHostsLength = 2;
+
+ const csvContent = `
+host_nqn
+nqn.2023-01.com.example:host1
+nqn.2023-01.com.example:host2
+`;
+
+ component.processCsvContent(csvContent);
+
+ expect(form.get('addedHosts')?.value).toEqual([
+ 'nqn.2023-01.com.example:host1',
+ 'nqn.2023-01.com.example:host2'
+ ]);
+ expect(component.csvUploadError).toBe(
+ 'No new hosts were found. Provided hosts are already in place.'
+ );
+ });
+ });
});
import { FormControl, UntypedFormControl } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { FileUploaderService } from '~/app/shared/services/file-uploader.service';
+
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';
};
HOST_TYPE = HOST_TYPE;
addedHostsLength: number = 0;
+ csvUploadError = '';
+ csvDropText: string = $localize`Drag and drop files here or click to upload`;
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 = '*';
+ uploadedHosts = new Set<string>();
- constructor(public actionLabels: ActionLabelsI18n, public activeModal: NgbActiveModal) {}
+ constructor(
+ public actionLabels: ActionLabelsI18n,
+ public activeModal: NgbActiveModal,
+ private fileUploaderService: FileUploaderService
+ ) {}
ngOnInit() {
this.createForm();
}
}
+ onCsvUpload(files: Set<Object>) {
+ this.fileUploaderService.readAsText(files, { accept: ['.csv'] }).subscribe({
+ next: ({ content }) => this.processCsvContent(content),
+ error: (err: Error) => (this.csvUploadError = err.message)
+ });
+ }
+
+ onCsvUploadRemove() {
+ this.csvUploadError = '';
+ if (this.uploadedHosts.size > 0) {
+ const currentAddedHosts = this.formGroup.get('addedHosts')?.value ?? [];
+ const newHostList = currentAddedHosts.filter((host: string) => !this.uploadedHosts.has(host));
+ this.addedHostsLength = newHostList.length;
+ this.formGroup.patchValue({ addedHosts: newHostList });
+ this.formGroup.get('hostname')?.updateValueAndValidity();
+ this.uploadedHosts.clear();
+ }
+ }
+
+ processCsvContent(csvContent: string) {
+ const lines = csvContent.split(/\r?\n/);
+ const currentAddedHosts = this.formGroup.get('addedHosts').value || [];
+ const currentHostsSet = new Set<string>([...currentAddedHosts, ...this.existingHosts]);
+ const importedHosts = new Set<string>();
+ let validHostRows = 0;
+
+ for (const line of lines) {
+ const host = this.extractHostFromCsvLine(line);
+ if (!host) {
+ continue;
+ }
+ if (!this.isHostNqnValid(host)) {
+ continue;
+ }
+ validHostRows++;
+ if (currentHostsSet.has(host) || importedHosts.has(host)) {
+ continue;
+ }
+ importedHosts.add(host);
+ this.uploadedHosts.add(host);
+ }
+
+ if (!importedHosts.size) {
+ this.csvUploadError =
+ validHostRows > 0
+ ? $localize`No new hosts were found. Provided hosts are already in place.`
+ : $localize`No valid hosts found in the CSV file.`;
+ this.formGroup.get('hostname').updateValueAndValidity();
+ return;
+ }
+
+ const newHostList = [...currentAddedHosts, ...importedHosts];
+ this.addedHostsLength = newHostList.length;
+ this.formGroup.patchValue({ addedHosts: newHostList });
+ this.formGroup.get('hostname').updateValueAndValidity();
+ this.csvUploadError = '';
+ }
+
+ private extractHostFromCsvLine(line: string): string {
+ const trimmedLine = line?.trim();
+ if (!trimmedLine) {
+ return '';
+ }
+
+ // Support both "host_nqn" header and "host_nqn,dhchap_key" style rows.
+ const host = trimmedLine.split(',')[0]?.trim().replace(/^"|"$/g, '');
+ if (!host || host.toLowerCase() === 'host_nqn') {
+ return '';
+ }
+ return host;
+ }
+
+ private isHostNqnValid(host: string): boolean {
+ return this.NQN_REGEX.test(host) || this.NQN_REGEX_UUID.test(host);
+ }
+
removeHost(removedHost: string) {
const currentAddedHosts = this.formGroup.get('addedHosts')?.value ?? [];
const newHostList = currentAddedHosts.filter((currentHost) => currentHost !== removedHost);
import { ActivatedRoute } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
-
import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
import { SharedModule } from '~/app/shared/shared.module';
import { NvmeofSubsystemsStepOneComponent } from './nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component';
import {
ComboBoxModule,
+ FileUploaderModule,
GridModule,
InputModule,
RadioModule,
GridModule,
RadioModule,
TagModule,
+ FileUploaderModule,
ComboBoxModule
]
}).compileComponents();
--- /dev/null
+import { Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+
+export interface FileReadOptions {
+ accept?: string[];
+ maxBytes?: number;
+}
+
+export interface FileReadResult {
+ content: string;
+ file: File;
+}
+
+@Injectable({ providedIn: 'root' })
+export class FileUploaderService {
+ readAsText(files: Set<any>, options: FileReadOptions = {}): Observable<FileReadResult> {
+ const { accept = [], maxBytes = 1_000_000 } = options;
+ return new Observable((observer) => {
+ const file: File = files?.values()?.next()?.value?.file;
+ if (!file) {
+ observer.error(new Error($localize`No file selected.`));
+ return undefined;
+ }
+
+ if (maxBytes && file.size > maxBytes) {
+ observer.error(new Error($localize`File exceeds the maximum size of ${maxBytes} bytes.`));
+ return undefined;
+ }
+
+ if (accept.length) {
+ const name = file.name.toLowerCase();
+ const valid = accept.some((ext) =>
+ ext.startsWith('.') ? name.endsWith(ext) : file.type === ext
+ );
+ if (!valid) {
+ observer.error(new Error($localize`Invalid file type. Accepted: ${accept.join(', ')}.`));
+ return undefined;
+ }
+ }
+
+ const reader = new FileReader();
+ reader.onload = (event: ProgressEvent<FileReader>) => {
+ const result = event.target?.result;
+ if (typeof result !== 'string') {
+ observer.error(new Error($localize`Unable to read the file.`));
+ } else {
+ observer.next({ content: result, file });
+ observer.complete();
+ }
+ };
+ reader.onerror = () => observer.error(new Error($localize`Unable to read the file.`));
+ reader.readAsText(file);
+ return () => reader.abort();
+ });
+ }
+}