Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -254,4 +254,70 @@ describe('DynamicFormComponent', () => {
expect(wrapper?.clientHeight).toEqual(hint.offsetHeight);
});
});

describe('select multiple with "None of the above" option', () => {
const multiSelectIndex = '5';

beforeEach(() => {
component.createProfileForm([
...PROFILE_FORM,
{
question: 'Which statements are true?',
type: FormControlType.SELECT_MULTIPLE,
options: ['Option 1', 'Option 2', 'None of the above'],
validation: {
required: true,
},
},
]);
fixture.detectChanges();
});

it('should unselect "None of the above" when another option is selected after it', () => {
const multiSelect = component.getFormGroup(multiSelectIndex);

multiSelect.get('2')?.setValue(true);
expect(multiSelect.value).toEqual({ 0: false, 1: false, 2: true });

multiSelect.get('1')?.setValue(true);
expect(multiSelect.value).toEqual({ 0: false, 1: true, 2: false });
});

it('should unselect other options when "None of the above" is selected, and unselect "None of the above" when another option is selected again', () => {
const multiSelect = component.getFormGroup(multiSelectIndex);

multiSelect.get('0')?.setValue(true);
multiSelect.get('1')?.setValue(true);
expect(multiSelect.value).toEqual({ 0: true, 1: true, 2: false });

multiSelect.get('2')?.setValue(true);
expect(multiSelect.value).toEqual({ 0: false, 1: false, 2: true });

multiSelect.get('1')?.setValue(true);
expect(multiSelect.value).toEqual({ 0: false, 1: true, 2: false });
});

it('should unselect "None of the above" when another option is selected after form reset', () => {
const multiSelect = component.getFormGroup(multiSelectIndex);

component.formGroup.reset();
multiSelect.get('2')?.setValue(true);
multiSelect.get('1')?.setValue(true);

expect(multiSelect.get('1')?.value).toBeTrue();
expect(multiSelect.get('2')?.value).toBeFalse();
});

it('should handle non-string option values without throwing an error', () => {
expect(() =>
component.createProfileForm([
{
question: 'Object options question',
type: FormControlType.SELECT_MULTIPLE,
options: [{ label: 'Option 1' }, { label: 'None of the above' }],
},
])
).not.toThrow();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,6 @@ import { ProfileValidators } from '../../pages/risk-assessment/profile-form/prof
import { DomSanitizer } from '@angular/platform-browser';
import { Subject } from 'rxjs/internal/Subject';
import { takeUntil } from 'rxjs/internal/operators/takeUntil';
import { startWith } from 'rxjs/internal/operators/startWith';
import { pairwise } from 'rxjs/internal/operators/pairwise';
@Component({
selector: 'app-dynamic-form',

Expand Down Expand Up @@ -141,7 +139,7 @@ export class DynamicFormComponent implements OnInit, OnDestroy {
const multiSelect = this.getMultiSelectGroup(question);
this.formGroup.addControl(index.toString(), multiSelect);
const noneKey = this.getNoneOptionIndex(question.options);
if (noneKey) {
if (noneKey !== null) {
this.applyNoneLogic(multiSelect, noneKey.toString());
}
} else {
Expand Down Expand Up @@ -224,38 +222,38 @@ export class DynamicFormComponent implements OnInit, OnDestroy {
});
}

private applyNoneLogic(control: AbstractControl, noneKey: string) {
control.valueChanges
.pipe(takeUntil(this.destroy$), startWith(control.value), pairwise())
.subscribe(([prev, curr]) => {
const changedKey = Object.keys(curr).find(
key => curr[key] === true && prev[key] === false
);
private applyNoneLogic(control: FormGroup, noneKey: string) {
let prevValue = { ...control.value };

if (!changedKey) {
return;
}
control.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(currValue => {
const changedKey = Object.keys(currValue).find(
key => currValue[key] === true && !prevValue[key]
);

if (changedKey == noneKey) {
const newValue = { ...curr };
if (changedKey === noneKey) {
const newValue = { ...currValue };

Object.keys(newValue).forEach(key => {
if (key !== noneKey) {
newValue[key] = false;
}
});
Object.keys(newValue).forEach(key => {
if (key !== noneKey) {
newValue[key] = false;
}
});

control.setValue(newValue, { emitEvent: false });
} else if (curr[noneKey] === true) {
control.patchValue({ [noneKey]: false }, { emitEvent: false });
}
});
control.setValue(newValue, { emitEvent: false });
} else if (changedKey && currValue[noneKey] === true) {
control.patchValue({ [noneKey]: false }, { emitEvent: false });
}

prevValue = { ...control.value };
});
}
Comment on lines +225 to 249

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Subscribing to each individual control's valueChanges in a loop creates multiple subscriptions and is less efficient. It can also lead to memory leaks if the form controls are recreated or updated. A cleaner and more idiomatic Angular/RxJS approach is to subscribe to the parent FormGroup's valueChanges once, and keep track of the previous value using a local variable to detect changes.

  private applyNoneLogic(control: FormGroup, noneKey: string) {
    let previousValue = { ...control.value };
    control.valueChanges
      .pipe(takeUntil(this.destroy$))
      .subscribe(currentValue => {
        const changedKey = Object.keys(currentValue).find(
          key => currentValue[key] === true && !previousValue[key]
        );

        previousValue = { ...currentValue };

        if (!changedKey) {
          return;
        }

        if (changedKey === noneKey) {
          const newValue = { ...currentValue };
          Object.keys(newValue).forEach(key => {
            if (key !== noneKey) {
              newValue[key] = false;
            }
          });
          control.setValue(newValue, { emitEvent: false });
          previousValue = newValue;
        } else if (currentValue[noneKey] === true) {
          control.patchValue({ [noneKey]: false }, { emitEvent: false });
          previousValue = { ...currentValue, [noneKey]: false };
        }
      });
  }


private getNoneOptionIndex(options: OptionType[] | undefined): number | null {
if (!options) return null;
return options.findIndex(option =>
this.getOptionValue(option)?.toLowerCase().includes('none')
);
const index = options.findIndex(option => {
const value = this.getOptionValue(option);
return typeof value === 'string' && value.toLowerCase().includes('none');
});
return index !== -1 ? index : null;
}
Comment on lines 251 to 258

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The getOptionValue(option) method can return a non-string value (e.g., an object or undefined). Calling .toLowerCase() directly on a non-string value will cause a runtime TypeError. We should ensure the value is a string before calling .toLowerCase().

  private getNoneOptionIndex(options: OptionType[] | undefined): number | null {
    if (!options) return null;
    const index = options.findIndex(option => {
      const value = this.getOptionValue(option);
      return typeof value === 'string' && value.toLowerCase().includes('none');
    });
    return index !== -1 ? index : null;
  }

}
Loading