Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ed80188
NAS-137252: NAS-137185: Add FC MPIO implementation specification
Dec 11, 2025
28e48ed
NAS-137252: NAS-137185 / Phase 1: Add FC MPIO service layer foundation
Dec 11, 2025
c4f9a02
NAS-137252: NAS-137185: Phase 2 - Update FC port display components f…
Dec 11, 2025
bf9480a
NAS-137252: NAS-137185: Phase 3 - Create FC port item controls component
Dec 11, 2025
edc58e2
NAS-137252: NAS-137185: Phase 4 - Integrate FC ports FormArray into t…
Dec 11, 2025
7b3577d
NAS-137252: NAS-137185: Improve test quality with harness-based patterns
Dec 11, 2025
fa9d4bf
NAS-137252: NAS-137185: Fix validator accumulation bug in fc-port-ite…
Dec 11, 2025
a818802
NAS-137252: NAS-137185: Remove FC MPIO spec from git tracking
Dec 11, 2025
e819358
NAS-137252: NAS-137185: Add MPIO support to wizard and remove unused …
Dec 11, 2025
a5acbe5
NAS-137252: NAS-137185: Add missing translation markers to mode options
Dec 11, 2025
dbeb3aa
NAS-137252: Fix wizard FC MPIO validation and add comprehensive tests
Dec 11, 2025
66f0b93
NAS-137252: Fix FC MPIO validation to detect duplicate HBAs in mixed …
Dec 12, 2025
4b4e432
NAS-137252: Fix FC MPIO terminology from HBA to physical port
Dec 17, 2025
8cc421c
NAS-137252: Implement FC port dropdown filtering with reactivity
Dec 18, 2025
15a729d
NAS-137252: Add comprehensive tests for FC port filtering behavior
Dec 18, 2025
c30fca2
NAS-137252: Add harness-based integration tests for FC port filtering
Dec 18, 2025
cac4045
Revert "NAS-137252: Add harness-based integration tests for FC port f…
Dec 18, 2025
c13ea9e
NAS-137252: Refactor FC port dropdown orchestration for improved main…
Dec 18, 2025
cd39f40
Merge branch 'master' into NAS-137252
Dec 18, 2025
ab5df5a
NAS-137252: Add MPIO info banner with conditional display for FC ports
Dec 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,4 @@ src/assets/scripts/ie-support/ie-polyfills.min.js

# Sentry Config File
.sentryclirc
src/app/testing-configs/*
258 changes: 258 additions & 0 deletions src/app/helpers/form-array-snapshot.helper.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import { DestroyRef } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { FormBuilder } from '@ngneat/reactive-forms';
import { createFormArraySnapshot } from './form-array-snapshot.helper';

describe('createFormArraySnapshot', () => {
let fb: FormBuilder;
let destroyRef: DestroyRef;

beforeEach(() => {
fb = new FormBuilder();
destroyRef = TestBed.inject(DestroyRef);
});

it('initializes with form array current value', () => {
const formArray = fb.array([
fb.control('item1'),
fb.control('item2'),
]);

const snapshot = createFormArraySnapshot(formArray, destroyRef);

expect(snapshot()).toEqual(['item1', 'item2']);
});

it('initializes with custom initial value when provided', () => {
const formArray = fb.array([
fb.control('item1'),
]);

const snapshot = createFormArraySnapshot(formArray, destroyRef, ['custom']);

expect(snapshot()).toEqual(['custom']);
});

it('updates snapshot when form array value changes', () => {
const formArray = fb.array([
fb.control('item1'),
]);

const snapshot = createFormArraySnapshot(formArray, destroyRef);

expect(snapshot()).toEqual(['item1']);

// Add item
formArray.push(fb.control('item2'));
expect(snapshot()).toEqual(['item1', 'item2']);

// Update item
formArray.at(0).setValue('updated');
expect(snapshot()).toEqual(['updated', 'item2']);

// Remove item
formArray.removeAt(1);
expect(snapshot()).toEqual(['updated']);
});

it('handles form array with objects', () => {
interface Item {
name: string;
value: number;
}

const formArray = fb.array([
fb.group({ name: fb.control('test'), value: fb.control(1) }),
]);

const snapshot = createFormArraySnapshot<Item>(formArray, destroyRef);

expect(snapshot()).toEqual([{ name: 'test', value: 1 }]);

formArray.push(fb.group({ name: fb.control('test2'), value: fb.control(2) }));
expect(snapshot()).toEqual([
{ name: 'test', value: 1 },
{ name: 'test2', value: 2 },
]);
});

it('handles empty form array', () => {
const formArray = fb.array<string>([]);

const snapshot = createFormArraySnapshot<string>(formArray, destroyRef);

expect(snapshot()).toEqual([]);

formArray.push(fb.control('new'));
expect(snapshot()).toEqual(['new']);
});

it('captures disabled control values with getRawValue', () => {
const formArray = fb.array([
fb.control({ value: 'enabled', disabled: false }),
fb.control({ value: 'disabled', disabled: true }),
]);

const snapshot = createFormArraySnapshot(formArray, destroyRef);

// getRawValue includes disabled controls
expect(snapshot()).toEqual(['enabled', 'disabled']);
});

it('updates snapshot when form array is cleared', () => {
const formArray = fb.array([
fb.control('item1'),
fb.control('item2'),
fb.control('item3'),
]);

const snapshot = createFormArraySnapshot(formArray, destroyRef);

expect(snapshot()).toEqual(['item1', 'item2', 'item3']);

formArray.clear();
expect(snapshot()).toEqual([]);
});

it('updates snapshot when form array values are patched', () => {
const formArray = fb.array([
fb.group({ name: fb.control('test1'), value: fb.control(1) }),
fb.group({ name: fb.control('test2'), value: fb.control(2) }),
]);

const snapshot = createFormArraySnapshot(formArray, destroyRef);

expect(snapshot()).toEqual([
{ name: 'test1', value: 1 },
{ name: 'test2', value: 2 },
]);

// Patch individual item
formArray.at(0).patchValue({ name: 'updated' });
expect(snapshot()).toEqual([
{ name: 'updated', value: 1 },
{ name: 'test2', value: 2 },
]);
});

it('updates snapshot when form array is reset', () => {
const formArray = fb.array([
fb.control('item1'),
fb.control('item2'),
]);

const snapshot = createFormArraySnapshot(formArray, destroyRef);

// Make changes
formArray.at(0).setValue('changed');
expect(snapshot()).toEqual(['changed', 'item2']);

// Reset
formArray.reset();
expect(snapshot()).toEqual([null, null]);
});

it('handles multiple rapid changes correctly', () => {
const formArray = fb.array([fb.control('initial')]);
const snapshot = createFormArraySnapshot(formArray, destroyRef);

// Multiple rapid changes
formArray.push(fb.control('second'));
formArray.push(fb.control('third'));
formArray.removeAt(0);
formArray.at(0).setValue('modified');

// Should capture the final state
expect(snapshot()).toEqual(['modified', 'third']);
});

it('handles nested form groups with complex structures', () => {
interface ComplexItem {
port: string | null;
host_id: number | null;
}

const formArray = fb.array([
fb.group({
port: fb.control<string | null>('fc0'),
host_id: fb.control<number | null>(null),
}),
fb.group({
port: fb.control<string | null>(null),
host_id: fb.control<number | null>(123),
}),
]);

const snapshot = createFormArraySnapshot<ComplexItem>(formArray, destroyRef);

expect(snapshot()).toEqual([
{ port: 'fc0', host_id: null },
{ port: null, host_id: 123 },
]);

// Modify nested values
formArray.at(0).patchValue({ port: 'fc1' });
expect(snapshot()).toEqual([
{ port: 'fc1', host_id: null },
{ port: null, host_id: 123 },
]);
});

it('works correctly with mixed disabled/enabled controls', () => {
const formArray = fb.array([
fb.group({
port: fb.control({ value: 'fc0', disabled: false }),
host_id: fb.control({ value: null, disabled: true }),
}),
fb.group({
port: fb.control({ value: null, disabled: true }),
host_id: fb.control({ value: 456, disabled: false }),
}),
]);

const snapshot = createFormArraySnapshot(formArray, destroyRef);

// getRawValue should include all values regardless of disabled state
expect(snapshot()).toEqual([
{ port: 'fc0', host_id: null },
{ port: null, host_id: 456 },
]);

// Enable a disabled control and change value
formArray.at(0).controls.host_id.enable();
formArray.at(0).controls.host_id.setValue(789);

expect(snapshot()).toEqual([
{ port: 'fc0', host_id: 789 },
{ port: null, host_id: 456 },
]);
});

it('maintains separate snapshots for different form arrays', () => {
const formArray1 = fb.array([fb.control('array1-item')]);
const formArray2 = fb.array([fb.control('array2-item')]);

const snapshot1 = createFormArraySnapshot(formArray1, destroyRef);
const snapshot2 = createFormArraySnapshot(formArray2, destroyRef);

expect(snapshot1()).toEqual(['array1-item']);
expect(snapshot2()).toEqual(['array2-item']);

// Modify one, other should be unchanged
formArray1.push(fb.control('array1-new'));
expect(snapshot1()).toEqual(['array1-item', 'array1-new']);
expect(snapshot2()).toEqual(['array2-item']); // Unchanged
});

it('handles form arrays with null and undefined values', () => {
const formArray = fb.array([
fb.control(null),
fb.control(),
fb.control('valid'),
]);

const snapshot = createFormArraySnapshot(formArray, destroyRef);

expect(snapshot()).toEqual([null, null, 'valid']); // undefined becomes null in forms
});
});
56 changes: 56 additions & 0 deletions src/app/helpers/form-array-snapshot.helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { DestroyRef, signal, WritableSignal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormArray } from '@ngneat/reactive-forms';

/**
* Creates a signal that tracks form array changes to prevent
* infinite change detection loops when using signals with reactive forms.
*
* This helper solves a common problem when mixing signals and reactive forms:
* - Reading form array values directly in a computed signal causes circular reactivity
* - Changes trigger re-computation, which can cause infinite loops
* - The snapshot pattern breaks the cycle by updating only on valueChanges emissions
*
* @param formArray The FormArray to track
* @param destroyRef DestroyRef for automatic cleanup
* @param initialValue Optional initial value (defaults to formArray.getRawValue())
* @returns A signal that updates whenever the form array changes
*
* @example
* ```typescript
* class MyComponent {
* private destroyRef = inject(DestroyRef);
*
* form = this.fb.group({
* items: this.fb.array([])
* });
*
* // Create snapshot that tracks form changes
* itemsSnapshot = createFormArraySnapshot(
* this.form.controls.items,
* this.destroyRef
* );
*
* // Use in computed signals safely
* processedItems = computed(() => {
* return this.itemsSnapshot().map(item => transform(item));
* });
* }
* ```
*/
export function createFormArraySnapshot<T>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formArray: FormArray<any>,
destroyRef: DestroyRef,
initialValue?: T[],
): WritableSignal<T[]> {
const snapshot = signal<T[]>(initialValue ?? formArray.getRawValue() as T[]);

formArray.valueChanges.pipe(
takeUntilDestroyed(destroyRef),
).subscribe(() => {
snapshot.set(formArray.getRawValue() as T[]);
});

return snapshot;
}
5 changes: 5 additions & 0 deletions src/app/interfaces/fibre-channel.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,8 @@ export interface FibreChannelHost {
wwpn_b: string;
npiv: number;
}

export interface FcPortFormValue {
port: string | null;
host_id: number | null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<div class="mpio-info-banner">
<div class="info-box" role="status">
<div class="info-header">
<ix-icon name="mdi-information-outline"></ix-icon>
<span>{{ 'MPIO Configuration' | translate }}</span>
</div>
<p class="info-message">
{{ 'MPIO is supported. Each Fibre Channel port must use a unique physical port.' | translate }}
</p>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
.mpio-info-banner {
margin-top: 16px;
margin-bottom: 16px;
}

.info-box {
background-color: var(--alt-bg1);
border: 1px solid var(--lines);
border-left: 4px solid var(--primary);
border-radius: 4px;
padding: 16px;
}

.info-header {
align-items: center;
color: var(--primary);
display: flex;
font-size: 16px;
font-weight: 600;
gap: 8px;
margin-bottom: 8px;

ix-icon {
font-size: 20px;
}
}

.info-message {
color: var(--fg2);
line-height: 1.5;
margin: 0;
}
Loading
Loading