Skip to content

Fix/value changes #1408

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 19 commits into
base: develop
Choose a base branch
from
Open
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
100 changes: 100 additions & 0 deletions projects/ngx-mask-lib/src/lib/ngx-mask-fault-detection.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Injectable } from "@angular/core";
import { MaskExpression } from "./ngx-mask-expression.enum";
import { NgxMaskService } from "./ngx-mask.service";

@Injectable()
export class NgxMaskFaultDetectionService {

constructor(private _maskService: NgxMaskService){ }

/**
* Attempts to remove the mask from the maskedValue and compare it with inputValue. Accounts for irreversible changes, such as loss of precision or dropped special characters.
* @param maskedValue the input value after being masked (doesn't call applyMask() itself because doing so causes undesired side effects).
* @param inputValue the value that would be sent to the native element.
* @returns whether the mask can be removed without any unexpected loss of characters.
*/
public maskApplicationFault(maskedValue: string, inputValue: string): boolean {
const unmaskedValue = this._maskService.removeMask(maskedValue);
if (this._maskService.dropSpecialCharacters) {
inputValue = this.removeSpecialCharactersFrom(inputValue);
}

if (this._maskService.hiddenInput) {
inputValue = this.removeHiddenCharacters(unmaskedValue, inputValue);
}

if (unmaskedValue === inputValue) {
return false;
}

// They may still not match due to lost precision
const hasPrecision = this._maskService.maskExpression.indexOf(MaskExpression.SEPARATOR + ".");
const mayPossiblyLosePrecision = hasPrecision >= 0;
if (mayPossiblyLosePrecision) {
const maskExpressionPrecision = Number(this._maskService.maskExpression.split(MaskExpression.SEPARATOR + ".")[1]);
const decimalMarkers = Array.isArray(this._maskService.decimalMarker) ? this._maskService.decimalMarker : [ this._maskService.decimalMarker ];
const unmaskedPrecisionLossDueToMask = decimalMarkers.some((dm) => {
const split = unmaskedValue.split(dm);
const unmaskedValuePrecision = split[split.length - 1]?.length;
const unmaskedPrecisionLossDueToMask = unmaskedValuePrecision === maskExpressionPrecision;
return unmaskedPrecisionLossDueToMask;
});
if (unmaskedPrecisionLossDueToMask) {
return false;
}

const scientificNotation = inputValue.indexOf("e") > 0;
if (scientificNotation) {
const power = inputValue.split("e")[1];
if (power && unmaskedValue.endsWith(power)) {
return false;
}
}
}

// removeMask() might not be removing the thousandth separator
const unmaskedWithoutThousandth = this.replaceEachCharacterWith(unmaskedValue, this._maskService.thousandSeparator, MaskExpression.EMPTY_STRING);
if (unmaskedWithoutThousandth === inputValue) {
return false;
}

// Is there any other reason to ignore a diff between unmaskedValue and inputValue?
console.warn(`Unexpected fault applying mask: ${this._maskService.maskExpression} to value: ${inputValue}`);
return true;
}

private removeSpecialCharactersFrom(inputValue: string): string {
const specialCharacters = Array.isArray(this._maskService.dropSpecialCharacters)
? this._maskService.dropSpecialCharacters.concat(this._maskService.specialCharacters)
: this._maskService.specialCharacters;
let result = inputValue;
specialCharacters.forEach(sc => {
result = this.replaceEachCharacterWith(result, sc, MaskExpression.EMPTY_STRING);
});

return result;
}

private removeHiddenCharacters(unmaskedValue: string, inputValue: string): string {
for (let i = 0; i < unmaskedValue.length; i++) {
const charAt = unmaskedValue.charAt(i);
const isHidden = charAt === MaskExpression.SYMBOL_STAR;
if (isHidden) {
const part_1 = inputValue.substring(0, i);
const part_2 = MaskExpression.SYMBOL_STAR;
const part_3 = inputValue.substring(i + 1)
inputValue = `${part_1}${part_2}${part_3}`
}
}

return inputValue;
}

private replaceEachCharacterWith(result: string, replace: string, replaceWith: string): string {
while (result.indexOf(replace) >= 0) {
result = result.replace(replace, replaceWith);
}

return result;
}
}
20 changes: 16 additions & 4 deletions projects/ngx-mask-lib/src/lib/ngx-mask.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { NgxMaskConfig } from './ngx-mask.config';
import { NGX_MASK_CONFIG, timeMasks, withoutValidation } from './ngx-mask.config';
import { NgxMaskService } from './ngx-mask.service';
import { MaskExpression } from './ngx-mask-expression.enum';
import { NgxMaskFaultDetectionService } from './ngx-mask-fault-detection.service';

@Directive({
selector: 'input[mask], textarea[mask]',
Expand All @@ -30,6 +31,7 @@ import { MaskExpression } from './ngx-mask-expression.enum';
multi: true,
},
NgxMaskService,
NgxMaskFaultDetectionService,
],
exportAs: 'mask,ngxMask',
})
Expand Down Expand Up @@ -73,11 +75,11 @@ export class NgxMaskDirective implements ControlValueAccessor, OnChanges, Valida

public _maskService = inject(NgxMaskService, { self: true });

private readonly document = inject(DOCUMENT);
public _maskFaultDetector = inject(NgxMaskFaultDetectionService, { self: true });

protected _config = inject<NgxMaskConfig>(NGX_MASK_CONFIG);

// eslint-disable-next-line @typescript-eslint/no-empty-function
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public onChange = (_: any) => {};

// eslint-disable-next-line @typescript-eslint/no-empty-function
Expand Down Expand Up @@ -980,7 +982,7 @@ export class NgxMaskDirective implements ControlValueAccessor, OnChanges, Valida

this._maskService.formElementProperty = [
'value',
this._maskService.applyMask(inputValue, this._maskService.maskExpression),
this.getNextValue(inputValue),
];
// Let the service know we've finished writing value
if (typeof inputTransformFn !== 'function') {
Expand All @@ -994,11 +996,21 @@ export class NgxMaskDirective implements ControlValueAccessor, OnChanges, Valida
// eslint-disable-next-line no-console
console.warn(
'Ngx-mask writeValue work with string | number, your current value:',
typeof value
typeof controlValue
);
}
}

private getNextValue(inputValue: string): string {
const maskedValue = this._maskService.applyMask(inputValue, this._maskService.maskExpression);
const maskingFault = this._maskFaultDetector.maskApplicationFault(maskedValue, inputValue);
if (!maskingFault) {
return maskedValue;
}

return inputValue;
}

public registerOnChange(fn: typeof this.onChange): void {
this._maskService.onChange = this.onChange = fn;
}
Expand Down
2 changes: 2 additions & 0 deletions projects/ngx-mask-lib/src/lib/ngx-mask.providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { inject, makeEnvironmentProviders } from '@angular/core';
import type { NgxMaskOptions } from './ngx-mask.config';
import { NGX_MASK_CONFIG, INITIAL_CONFIG, initialConfig, NEW_CONFIG } from './ngx-mask.config';
import { NgxMaskService } from './ngx-mask.service';
import { NgxMaskFaultDetectionService } from './ngx-mask-fault-detection.service';

/**
* @internal
Expand Down Expand Up @@ -32,6 +33,7 @@ export function provideNgxMask(configValue?: NgxMaskOptions | (() => NgxMaskOpti
useFactory: _configFactory,
},
NgxMaskService,
NgxMaskFaultDetectionService
];
}

Expand Down
102 changes: 102 additions & 0 deletions projects/ngx-mask-lib/src/test/formcontrol-setvalue.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ReactiveFormsModule, ValidationErrors } from '@angular/forms';
import { TestMaskComponent } from './utils/test-component.component';
import { provideNgxMask } from '../lib/ngx-mask.providers';
import { NgxMaskDirective } from '../lib/ngx-mask.directive';

describe('Directive: Mask (formControl.setValue)', () => {
let fixture: ComponentFixture<TestMaskComponent>;
let component: TestMaskComponent;
const mask = '(000) 000-0000';

beforeEach(() => {
TestBed.configureTestingModule({
declarations: [TestMaskComponent],
imports: [ReactiveFormsModule, NgxMaskDirective],
providers: [provideNgxMask()],
});
fixture = TestBed.createComponent(TestMaskComponent);
component = fixture.componentInstance;
component.mask = mask;
fixture.detectChanges();
});

afterEach(() => {
fixture.destroy();
});

it('(MATCH) should set the native element to the masked value, and set the form control to the unmasked value, which also emits the unmasked value', async () => {
const inputValue = '5555555555';
const expectedFormControlValue = '5555555555';
const expectedEmitedValues = '5555555555';
const expectedNativeElementValue = '(555) 555-5555';
const expectedConsoleWarnings = 0;

const {
actualNativeElementValue,
actualFormControlValue,
actualEmitedValue,
consoleWarningCount,
errors
} = await setValue(inputValue);

expect(actualNativeElementValue).toEqual(expectedNativeElementValue);
expect(actualEmitedValue).toEqual(expectedEmitedValues);
expect(actualFormControlValue).toEqual(expectedFormControlValue);
expect(consoleWarningCount).toEqual(expectedConsoleWarnings);
expect(errors).toBeNull();
});

it('(NOT MATCH) should set the native element to the raw value, and set the form control to the raw value, which also emits the raw value, AND log a warning AND have a validation error', async () => {
const expectedNativeElementValue = 'AAA';
const expectedFormControlValue = 'AAA';
const expectedEmitedValue = 'AAA';
const expectedConsoleWarnings = 1;

const inputValue = 'AAA';
const {
actualNativeElementValue,
actualFormControlValue,
actualEmitedValue,
consoleWarningCount,
errors
} = await setValue(inputValue);

expect(actualNativeElementValue).toEqual(expectedNativeElementValue);
expect(actualEmitedValue).toEqual(expectedEmitedValue);
expect(actualFormControlValue).toEqual(expectedFormControlValue);
expect(consoleWarningCount).toEqual(expectedConsoleWarnings);
expect(errors).not.toBeNull();
});

const setValue = async (value: string | number): Promise<{
actualNativeElementValue: string | number,
actualFormControlValue: string | number,
actualEmitedValue: string | null,
consoleWarningCount: number,
errors: ValidationErrors | null
}> => {
const warnSpy = spyOn(console, 'warn');

let actualEmitedValue = null;
component.form.valueChanges.subscribe(emitedValue => {
actualEmitedValue = emitedValue;
});
component.form.setValue(value);
fixture.detectChanges();
await fixture.whenStable();

const actualNativeElementValue = fixture.debugElement.query(By.css('input')).nativeElement.value;
const actualFormControlValue = component.form.getRawValue();
const consoleWarningCount = warnSpy.calls.count();
const errors = component.form.errors;
return {
actualNativeElementValue,
actualFormControlValue,
actualEmitedValue,
consoleWarningCount,
errors
}
}
});
51 changes: 51 additions & 0 deletions projects/ngx-mask-lib/src/test/mask-fault-detection.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { TestMaskComponent } from './utils/test-component.component';
import { provideNgxMask } from '../lib/ngx-mask.providers';
import { NgxMaskDirective } from '../lib/ngx-mask.directive';
import { NgxMaskFaultDetectionService } from '../lib/ngx-mask-fault-detection.service';
import { NgxMaskService } from '../lib/ngx-mask.service';

describe('MaskFaultDetectionService', () => {
let fixture: ComponentFixture<TestMaskComponent>;

beforeEach(() => {
TestBed.configureTestingModule({
declarations: [TestMaskComponent],
imports: [ReactiveFormsModule, NgxMaskDirective],
providers: [provideNgxMask()],
});
fixture = TestBed.createComponent(TestMaskComponent);
fixture.detectChanges();
});

afterEach(() => {
fixture.destroy();
});

it('should detect no issues if the mask is applied without any unexpected losses', async () => {
const faultSvc = TestBed.inject(NgxMaskFaultDetectionService);
const maskSvc = TestBed.inject(NgxMaskService);

const inputValue = "123";
const maskExpression = maskSvc.maskExpression = "AAA";

const maskedValue = maskSvc.applyMask(inputValue, maskExpression);
const maskingFault = faultSvc.maskApplicationFault(maskedValue, inputValue);

expect(maskingFault).toBe(false);
});

it('should detect an issue if the mask is applied with any unexpected losses', async () => {
const faultSvc = TestBed.inject(NgxMaskFaultDetectionService);
const maskSvc = TestBed.inject(NgxMaskService);

const inputValue = "123";
const maskExpression = maskSvc.maskExpression = "AAS";

const maskedValue = maskSvc.applyMask(inputValue, maskExpression);
const maskingFault = faultSvc.maskApplicationFault(maskedValue, inputValue);

expect(maskingFault).toBe(true);
});
});