diff --git a/projects/ngx-mask-lib/src/lib/ngx-mask-fault-detection.service.ts b/projects/ngx-mask-lib/src/lib/ngx-mask-fault-detection.service.ts new file mode 100644 index 00000000..add0e13c --- /dev/null +++ b/projects/ngx-mask-lib/src/lib/ngx-mask-fault-detection.service.ts @@ -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; + } +} \ No newline at end of file diff --git a/projects/ngx-mask-lib/src/lib/ngx-mask.directive.ts b/projects/ngx-mask-lib/src/lib/ngx-mask.directive.ts index b22ebba6..9dc06f7f 100644 --- a/projects/ngx-mask-lib/src/lib/ngx-mask.directive.ts +++ b/projects/ngx-mask-lib/src/lib/ngx-mask.directive.ts @@ -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]', @@ -30,6 +31,7 @@ import { MaskExpression } from './ngx-mask-expression.enum'; multi: true, }, NgxMaskService, + NgxMaskFaultDetectionService, ], exportAs: 'mask,ngxMask', }) @@ -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(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 @@ -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') { @@ -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; } diff --git a/projects/ngx-mask-lib/src/lib/ngx-mask.providers.ts b/projects/ngx-mask-lib/src/lib/ngx-mask.providers.ts index 30f4c7d5..d852b3d1 100644 --- a/projects/ngx-mask-lib/src/lib/ngx-mask.providers.ts +++ b/projects/ngx-mask-lib/src/lib/ngx-mask.providers.ts @@ -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 @@ -32,6 +33,7 @@ export function provideNgxMask(configValue?: NgxMaskOptions | (() => NgxMaskOpti useFactory: _configFactory, }, NgxMaskService, + NgxMaskFaultDetectionService ]; } diff --git a/projects/ngx-mask-lib/src/test/formcontrol-setvalue.spec.ts b/projects/ngx-mask-lib/src/test/formcontrol-setvalue.spec.ts new file mode 100644 index 00000000..e165aa74 --- /dev/null +++ b/projects/ngx-mask-lib/src/test/formcontrol-setvalue.spec.ts @@ -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; + 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 + } + } +}); diff --git a/projects/ngx-mask-lib/src/test/mask-fault-detection.spec.ts b/projects/ngx-mask-lib/src/test/mask-fault-detection.spec.ts new file mode 100644 index 00000000..e54a0d1f --- /dev/null +++ b/projects/ngx-mask-lib/src/test/mask-fault-detection.spec.ts @@ -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; + + 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); + }); +});