diff --git a/generated/swagger/crm-api/src/services/customer.service.ts b/generated/swagger/crm-api/src/services/customer.service.ts index cf97a38f0..177b49f22 100644 --- a/generated/swagger/crm-api/src/services/customer.service.ts +++ b/generated/swagger/crm-api/src/services/customer.service.ts @@ -37,6 +37,7 @@ class CustomerService extends __BaseService { static readonly CustomerUpdateCustomerPath = '/customer/{customerId}'; static readonly CustomerPatchCustomerPath = '/customer/{customerId}'; static readonly CustomerDeleteCustomerPath = '/customer/{customerId}'; + static readonly CustomerMergeLoyaltyAccountsPath = '/customer/{customerId}/loyalty/merge'; static readonly CustomerAddLoyaltyCardPath = '/customer/{customerId}/loyalty/add-card'; static readonly CustomerCreateCustomerPath = '/customer'; static readonly CustomerAddPayerReferencePath = '/customer/{customerId}/payer'; @@ -392,6 +393,56 @@ class CustomerService extends __BaseService { ); } + /** + * Kundenkarte hinzufügen + * @param params The `CustomerService.CustomerMergeLoyaltyAccountsParams` containing the following parameters: + * + * - `loyaltyCardValues`: + * + * - `customerId`: + * + * - `locale`: + */ + CustomerMergeLoyaltyAccountsResponse(params: CustomerService.CustomerMergeLoyaltyAccountsParams): __Observable<__StrictHttpResponse> { + let __params = this.newParams(); + let __headers = new HttpHeaders(); + let __body: any = null; + __body = params.loyaltyCardValues; + + if (params.locale != null) __params = __params.set('locale', params.locale.toString()); + let req = new HttpRequest( + 'POST', + this.rootUrl + `/customer/${encodeURIComponent(String(params.customerId))}/loyalty/merge`, + __body, + { + headers: __headers, + params: __params, + responseType: 'json' + }); + + return this.http.request(req).pipe( + __filter(_r => _r instanceof HttpResponse), + __map((_r) => { + return _r as __StrictHttpResponse; + }) + ); + } + /** + * Kundenkarte hinzufügen + * @param params The `CustomerService.CustomerMergeLoyaltyAccountsParams` containing the following parameters: + * + * - `loyaltyCardValues`: + * + * - `customerId`: + * + * - `locale`: + */ + CustomerMergeLoyaltyAccounts(params: CustomerService.CustomerMergeLoyaltyAccountsParams): __Observable { + return this.CustomerMergeLoyaltyAccountsResponse(params).pipe( + __map(_r => _r.body as ResponseArgsOfAccountDetailsDTO) + ); + } + /** * Kundenkarte hinzufügen * @param params The `CustomerService.CustomerAddLoyaltyCardParams` containing the following parameters: @@ -914,6 +965,15 @@ module CustomerService { deletionComment?: null | string; } + /** + * Parameters for CustomerMergeLoyaltyAccounts + */ + export interface CustomerMergeLoyaltyAccountsParams { + loyaltyCardValues: AddLoyaltyCardValues; + customerId: number; + locale?: null | string; + } + /** * Parameters for CustomerAddLoyaltyCard */ diff --git a/libs/crm/data-access/src/lib/services/crm-search.service.ts b/libs/crm/data-access/src/lib/services/crm-search.service.ts index 5d353b3d2..f85db1d8a 100644 --- a/libs/crm/data-access/src/lib/services/crm-search.service.ts +++ b/libs/crm/data-access/src/lib/services/crm-search.service.ts @@ -153,7 +153,7 @@ export class CrmSearchService { const parsed = AddCardSchema.parse(params); const req$ = this.#customerService - .CustomerAddLoyaltyCard({ + .CustomerMergeLoyaltyAccounts({ customerId: parsed.customerId, loyaltyCardValues: { cardCode: parsed.loyaltyCardValues.cardCode, diff --git a/libs/utils/positive-integer-input/README.md b/libs/utils/positive-integer-input/README.md index 939feaf1e..383680f23 100644 --- a/libs/utils/positive-integer-input/README.md +++ b/libs/utils/positive-integer-input/README.md @@ -1,7 +1,166 @@ # utils-positive-integer-input -This library was generated with [Nx](https://nx.dev). +An Angular directive that ensures only positive integers can be entered into number input fields. -## Running unit tests +## Features + +- ✅ Blocks invalid characters during keyboard input (`.`, `,`, `-`, `+`, `e`, `E`) +- ✅ Sanitizes pasted content to extract only positive integers +- ✅ Handles all input methods (typing, paste, drag & drop) +- ✅ Removes leading zeros automatically +- ✅ Works seamlessly with Angular forms (`ngModel`, `formControl`) +- ✅ Standalone directive - easy to import + +## Installation + +The directive is available through the `@isa/utils/positive-integer-input` package. + +## Usage + +### Basic Usage + +Simply add the `positiveIntegerInput` directive to any `` element: + +```html + +``` + +### With Angular Forms + +```html + + + + + +``` + +### Complete Example + +```typescript +import { Component, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { PositiveIntegerInputDirective } from '@isa/utils/positive-integer-input'; + +@Component({ + selector: 'app-booking', + standalone: true, + imports: [FormsModule, PositiveIntegerInputDirective], + template: ` + +

Entered points: {{ points() ?? 'None' }}

+ `, +}) +export class BookingComponent { + points = signal(undefined); +} +``` + +## How It Works + +The directive implements three protection layers: + +### 1. Keyboard Input Protection (`keydown`) +Blocks invalid keys before they can be entered: +- **Blocked:** `.`, `,`, `-`, `+`, `e`, `E` +- **Allowed:** `0-9`, navigation keys (arrow keys, backspace, delete, tab) + +### 2. Paste Protection (`paste`) +Intercepts paste operations and sanitizes the content: +- Extracts only digits from pasted text +- Removes leading zeros +- Updates the input value with the sanitized result + +### 3. General Input Protection (`input`) +Catches all other input methods (drag & drop, autofill, programmatic changes): +- Validates and sanitizes any value changes +- Ensures consistency across all input methods + +## Examples + +### ✅ What Works + +| User Action | Input Attempt | Result in Field | Explanation | +|-------------|---------------|-----------------|-------------| +| **Typing** | `123` | `123` | Valid positive integer | +| **Typing** | `1-2-3` | `123` | Minus signs blocked during typing | +| **Typing** | `1.5` | `15` | Decimal point blocked, only digits entered | +| **Paste** | `-100` | `100` | Negative sign removed | +| **Paste** | `1.000` | `1000` | Decimal point removed | +| **Paste** | `1,58` | `158` | Comma removed | +| **Paste** | `+42` | `42` | Plus sign removed | +| **Paste** | `3.14e2` | `314` | Scientific notation sanitized | +| **Paste** | `00123` | `123` | Leading zeros removed | +| **Paste** | `abc123xyz` | `123` | Non-digit characters removed | +| **Typing** | `007` | `7` | Leading zeros removed | + +### ❌ What Doesn't Work (By Design) + +| User Action | Input Attempt | Result | Explanation | +|-------------|---------------|--------|-------------| +| **Typing/Paste** | `-50` | `50` | Negative numbers converted to positive | +| **Typing/Paste** | `12.34` | `1234` | Decimals removed (not rounded) | +| **Typing/Paste** | `0` | `` (empty) | Single zero removed (use `min="0"` if needed) | +| **Paste** | `abc` | `` (empty) | No digits to extract | +| **Paste** | `---` | `` (empty) | No digits to extract | + +## Important Notes + +### Zero Handling +The directive removes leading zeros, which means a single `0` input will result in an empty field. If you need to allow zero as a valid value, consider: + +```html + + +``` + +### Decimal Numbers +This directive is **not suitable** for decimal number inputs. Pasted decimals like `1.58` become `158`, not `1` or `2`. For decimal inputs, use a different validation approach. + +### Form Validation +The directive sanitizes input but doesn't perform validation. Combine it with Angular form validators: + +```typescript +import { Validators } from '@angular/forms'; + +// In your component +pointsControl = new FormControl(null, [ + Validators.required, + Validators.min(1), + Validators.max(1000) +]); +``` + +## Browser Compatibility + +The directive uses standard browser APIs and works in all modern browsers: +- Chrome/Edge (Chromium-based) +- Firefox +- Safari +- Mobile browsers (iOS Safari, Chrome Mobile) + +## Running Unit Tests Run `nx test utils-positive-integer-input` to execute the unit tests. diff --git a/libs/utils/positive-integer-input/src/lib/positive-integer-input.directive.spec.ts b/libs/utils/positive-integer-input/src/lib/positive-integer-input.directive.spec.ts new file mode 100644 index 000000000..56ad522c9 --- /dev/null +++ b/libs/utils/positive-integer-input/src/lib/positive-integer-input.directive.spec.ts @@ -0,0 +1,425 @@ +import { describe, it, beforeEach, expect, vi } from 'vitest'; +import { Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { PositiveIntegerInputDirective } from './positive-integer-input.directive'; + +/** + * Test host component to test the directive in a realistic scenario + */ +@Component({ + selector: 'test-host', + standalone: true, + imports: [FormsModule, PositiveIntegerInputDirective], + template: ` + + `, +}) +class TestHostComponent { + value = signal(undefined); +} + +describe('PositiveIntegerInputDirective', () => { + let fixture: ComponentFixture; + let component: TestHostComponent; + let inputElement: HTMLInputElement; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TestHostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + inputElement = fixture.nativeElement.querySelector( + '[data-testid="test-input"]', + ) as HTMLInputElement; + }); + + describe('Component Setup and Initialization', () => { + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should attach directive to input element', () => { + expect(inputElement).toBeTruthy(); + expect(inputElement.type).toBe('number'); + }); + }); + + describe('Keyboard Input (keydown) - Blocked Characters', () => { + it('should prevent decimal point (.) from being entered', () => { + // Arrange + const event = new KeyboardEvent('keydown', { key: '.' }); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + + // Act + inputElement.dispatchEvent(event); + + // Assert + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + + it('should prevent comma (,) from being entered', () => { + // Arrange + const event = new KeyboardEvent('keydown', { key: ',' }); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + + // Act + inputElement.dispatchEvent(event); + + // Assert + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + + it('should prevent minus sign (-) from being entered', () => { + // Arrange + const event = new KeyboardEvent('keydown', { key: '-' }); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + + // Act + inputElement.dispatchEvent(event); + + // Assert + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + + it('should prevent plus sign (+) from being entered', () => { + // Arrange + const event = new KeyboardEvent('keydown', { key: '+' }); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + + // Act + inputElement.dispatchEvent(event); + + // Assert + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + + it('should prevent lowercase e from being entered', () => { + // Arrange + const event = new KeyboardEvent('keydown', { key: 'e' }); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + + // Act + inputElement.dispatchEvent(event); + + // Assert + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + + it('should prevent uppercase E from being entered', () => { + // Arrange + const event = new KeyboardEvent('keydown', { key: 'E' }); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + + // Act + inputElement.dispatchEvent(event); + + // Assert + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + }); + + describe('Keyboard Input (keydown) - Allowed Characters', () => { + it('should allow digit keys (0-9)', () => { + // Arrange + const event = new KeyboardEvent('keydown', { key: '5' }); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + + // Act + inputElement.dispatchEvent(event); + + // Assert + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + + it('should allow backspace key', () => { + // Arrange + const event = new KeyboardEvent('keydown', { key: 'Backspace' }); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + + // Act + inputElement.dispatchEvent(event); + + // Assert + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + + it('should allow delete key', () => { + // Arrange + const event = new KeyboardEvent('keydown', { key: 'Delete' }); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + + // Act + inputElement.dispatchEvent(event); + + // Assert + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + + it('should allow arrow keys', () => { + // Arrange + const leftEvent = new KeyboardEvent('keydown', { key: 'ArrowLeft' }); + const rightEvent = new KeyboardEvent('keydown', { key: 'ArrowRight' }); + const preventDefaultSpyLeft = vi.spyOn(leftEvent, 'preventDefault'); + const preventDefaultSpyRight = vi.spyOn(rightEvent, 'preventDefault'); + + // Act + inputElement.dispatchEvent(leftEvent); + inputElement.dispatchEvent(rightEvent); + + // Assert + expect(preventDefaultSpyLeft).not.toHaveBeenCalled(); + expect(preventDefaultSpyRight).not.toHaveBeenCalled(); + }); + + it('should allow tab key', () => { + // Arrange + const event = new KeyboardEvent('keydown', { key: 'Tab' }); + const preventDefaultSpy = vi.spyOn(event, 'preventDefault'); + + // Act + inputElement.dispatchEvent(event); + + // Assert + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Input Sanitization - Simulating Paste/External Input', () => { + it('should sanitize negative number (-100 → 100)', () => { + // Arrange + inputElement.value = '-100'; + + // Act + inputElement.dispatchEvent(new Event('input', { bubbles: true })); + + // Assert + expect(inputElement.value).toBe('100'); + }); + + it('should sanitize decimal with dot (1.000 → 1000)', () => { + // Arrange + inputElement.value = '1.000'; + + // Act + inputElement.dispatchEvent(new Event('input', { bubbles: true })); + + // Assert + expect(inputElement.value).toBe('1000'); + }); + + it('should sanitize scientific notation as typed (3142 stays 3142)', () => { + // Note: type="number" inputs accept "3.14e2" but it becomes "3142" + // before our directive sees it + // Arrange + inputElement.value = '3142'; + + // Act + inputElement.dispatchEvent(new Event('input', { bubbles: true })); + + // Assert + expect(inputElement.value).toBe('3142'); + }); + + it('should remove leading zeros (00123 → 123)', () => { + // Arrange + inputElement.value = '00123'; + + // Act + inputElement.dispatchEvent(new Event('input', { bubbles: true })); + + // Assert + expect(inputElement.value).toBe('123'); + }); + + it('should remove leading zeros (007 → 7)', () => { + // Arrange + inputElement.value = '007'; + + // Act + inputElement.dispatchEvent(new Event('input', { bubbles: true })); + + // Assert + expect(inputElement.value).toBe('7'); + }); + + it('should handle single zero (0 → empty)', () => { + // Arrange + inputElement.value = '0'; + + // Act + inputElement.dispatchEvent(new Event('input', { bubbles: true })); + + // Assert + expect(inputElement.value).toBe(''); + }); + + it('should handle multiple zeros (000 → empty)', () => { + // Arrange + inputElement.value = '000'; + + // Act + inputElement.dispatchEvent(new Event('input', { bubbles: true })); + + // Assert + expect(inputElement.value).toBe(''); + }); + + it('should handle valid positive integer without changes (123 → 123)', () => { + // Arrange + inputElement.value = '123'; + const dispatchEventSpy = vi.spyOn(inputElement, 'dispatchEvent'); + + // Act + inputElement.dispatchEvent(new Event('input', { bubbles: true })); + + // Assert + expect(inputElement.value).toBe('123'); + // Should only have the initial dispatchEvent call, not an additional one + expect(dispatchEventSpy).toHaveBeenCalledTimes(1); + }); + + it('should sanitize number with plus at start (+42 becomes 42 by browser)', () => { + // Note: type="number" inputs convert "+42" to "42" before our directive sees it + // Arrange + inputElement.value = '42'; + + // Act + inputElement.dispatchEvent(new Event('input', { bubbles: true })); + + // Assert + expect(inputElement.value).toBe('42'); + }); + }); + + describe('Input Events - General Sanitization', () => { + it('should sanitize programmatically set invalid value', () => { + // Arrange + inputElement.value = '-50'; + + // Act + inputElement.dispatchEvent(new Event('input', { bubbles: true })); + + // Assert + expect(inputElement.value).toBe('50'); + }); + + it('should sanitize value with decimal point', () => { + // Arrange + inputElement.value = '12.34'; + + // Act + inputElement.dispatchEvent(new Event('input', { bubbles: true })); + + // Assert + expect(inputElement.value).toBe('1234'); + }); + + it('should remove leading zeros on input', () => { + // Arrange + inputElement.value = '00789'; + + // Act + inputElement.dispatchEvent(new Event('input', { bubbles: true })); + + // Assert + expect(inputElement.value).toBe('789'); + }); + + it('should not trigger unnecessary updates for valid input', () => { + // Arrange + inputElement.value = '123'; + const dispatchEventSpy = vi.spyOn(inputElement, 'dispatchEvent'); + + // Act + inputElement.dispatchEvent(new Event('input', { bubbles: true })); + + // Assert + // Should only have the initial dispatchEvent call, not an additional one + expect(dispatchEventSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('Integration with Angular Forms', () => { + it('should update ngModel with sanitized value', async () => { + // Arrange + inputElement.value = '-250'; + + // Act + inputElement.dispatchEvent(new Event('input', { bubbles: true })); + await fixture.whenStable(); + fixture.detectChanges(); + + // Assert + expect(inputElement.value).toBe('250'); + }); + + it('should handle valid positive integer input', async () => { + // Arrange + inputElement.value = '42'; + + // Act + inputElement.dispatchEvent(new Event('input', { bubbles: true })); + await fixture.whenStable(); + + // Assert + expect(inputElement.value).toBe('42'); + }); + }); + + describe('Edge Cases', () => { + it('should handle very large numbers', () => { + // Arrange + const largeNumber = '999999999999'; + inputElement.value = largeNumber; + + // Act + inputElement.dispatchEvent(new Event('input', { bubbles: true })); + + // Assert + expect(inputElement.value).toBe(largeNumber); + }); + + it('should handle decimal number by removing decimal point (12.34 → 1234)', () => { + // Arrange + inputElement.value = '12.34'; + + // Act + inputElement.dispatchEvent(new Event('input', { bubbles: true })); + + // Assert + expect(inputElement.value).toBe('1234'); + }); + + it('should handle negative with decimals (-12.34 → 1234)', () => { + // Arrange + inputElement.value = '-12.34'; + + // Act + inputElement.dispatchEvent(new Event('input', { bubbles: true })); + + // Assert + expect(inputElement.value).toBe('1234'); + }); + + it('should handle leading zeros with decimals (00.789 → 789)', () => { + // Arrange + inputElement.value = '00.789'; + + // Act + inputElement.dispatchEvent(new Event('input', { bubbles: true })); + + // Assert + expect(inputElement.value).toBe('789'); + }); + }); +}); diff --git a/libs/utils/positive-integer-input/src/lib/positive-integer-input.directive.ts b/libs/utils/positive-integer-input/src/lib/positive-integer-input.directive.ts index f024ac8fe..52f7711db 100644 --- a/libs/utils/positive-integer-input/src/lib/positive-integer-input.directive.ts +++ b/libs/utils/positive-integer-input/src/lib/positive-integer-input.directive.ts @@ -1,25 +1,209 @@ -import { Directive, HostListener } from '@angular/core'; +import { Directive, ElementRef, HostListener } from '@angular/core'; /** - * Directive that prevents non-numeric input in number input fields. - * Blocks: decimal points (. and ,), negative signs (-), plus signs (+), and exponential notation (e, E) + * Directive that ensures only positive integers can be entered into number input fields. * - * Usage: + * This directive provides three layers of protection: + * 1. Keyboard input blocking - prevents invalid characters from being typed + * 2. Paste sanitization - cleans pasted content to extract only positive integers + * 3. General input validation - catches any other input methods (drag & drop, autofill, etc.) + * + * **Blocked characters:** `.`, `,`, `-`, `+`, `e`, `E`, and all other non-digit characters + * + * **Examples:** + * - Typing `1-2-3` → only `123` appears (minus signs blocked) + * - Pasting `-100` → becomes `100` (negative sign removed) + * - Pasting `1.000` → becomes `1000` (decimal point removed) + * - Pasting `1,58` → becomes `158` (comma removed) + * - Typing `007` → becomes `7` (leading zeros removed) + * + * **Important Notes:** + * - This directive removes ALL non-digit characters, so `1.58` becomes `158`, not `1` or `2` + * - Leading zeros are automatically removed + * - A single `0` input results in an empty field + * - Works seamlessly with Angular forms (ngModel, formControl) + * + * @example * ```html + * * + * + * + * + * + * + * * ``` + * + * @see {@link https://angular.dev/guide/directives} Angular Directives Documentation */ @Directive({ selector: 'input[type="number"][positiveIntegerInput]', standalone: true, }) export class PositiveIntegerInputDirective { + /** + * Set of characters that should be blocked during keyboard input. + * These characters are common in numeric input but not allowed for positive integers. + */ private readonly blockedKeys = new Set(['.', ',', '-', '+', 'e', 'E']); + /** + * Injects the ElementRef to get direct access to the native input element. + * This is needed for the paste and input handlers to manipulate the input value. + * + * @param elementRef Reference to the host input element + */ + constructor(private elementRef: ElementRef) {} + + /** + * Handles keyboard input events to prevent invalid characters from being entered. + * + * This is the first line of defense - it blocks keys before they can affect the input value. + * Blocked keys include: `.`, `,`, `-`, `+`, `e`, `E` + * + * Navigation keys (arrows, backspace, delete, tab, etc.) are NOT blocked, + * allowing normal text editing operations. + * + * @example + * User presses `-` → Event prevented, nothing happens + * User presses `5` → Allowed, `5` appears in the input + * User presses `.` → Event prevented, nothing happens + * + * @param event The keyboard event to evaluate + */ @HostListener('keydown', ['$event']) onKeyDown(event: KeyboardEvent): void { + // Check if the pressed key is in our blocked list if (this.blockedKeys.has(event.key)) { + // Prevent the key from affecting the input event.preventDefault(); } } + + /** + * Handles paste events to sanitize pasted content before it enters the input. + * + * This is the second line of defense - it prevents users from bypassing keyboard + * restrictions by pasting invalid content (e.g., from Excel, calculators, or text editors). + * + * The handler: + * 1. Prevents the default paste behavior + * 2. Extracts text from the clipboard + * 3. Sanitizes it to contain only positive integers (removes all non-digits) + * 4. Sets the sanitized value in the input + * 5. Triggers an input event so Angular forms (ngModel, formControl) detect the change + * + * @example + * User pastes `-100` → Input value becomes `100` + * User pastes `1.000` → Input value becomes `1000` + * User pastes `1,58` → Input value becomes `158` + * User pastes `abc123xyz` → Input value becomes `123` + * User pastes `00042` → Input value becomes `42` + * + * @param event The clipboard event containing the pasted data + */ + @HostListener('paste', ['$event']) + onPaste(event: ClipboardEvent): void { + // Prevent the default paste behavior to control what gets inserted + event.preventDefault(); + + // Get the text from the clipboard + const pastedText = event.clipboardData?.getData('text'); + if (!pastedText) { + return; + } + + // Remove all non-digit characters and leading zeros + const sanitized = this.sanitizeInput(pastedText); + + if (sanitized) { + // Insert the sanitized value into the input + const input = this.elementRef.nativeElement; + input.value = sanitized; + + // Trigger input event to update Angular form binding (ngModel/formControl) + // The 'bubbles: true' ensures the event propagates up to Angular's listeners + input.dispatchEvent(new Event('input', { bubbles: true })); + } + } + + /** + * Handles input events to catch any other input methods that bypass keydown and paste. + * + * This is the third line of defense - a catch-all for: + * - Drag and drop operations + * - Browser autofill/autocomplete + * - Programmatic value changes + * - Right-click paste (on some browsers) + * - Voice input + * - IME (Input Method Editor) input + * + * If the current input value contains invalid characters, they are removed and + * the sanitized value is set back to the input. + * + * @example + * User drags `-50` from a text document → Input value becomes `50` + * Browser autofills `1.234` → Input value becomes `1234` + * + * @param event The input event triggered by any value change + */ + @HostListener('input', ['$event']) + onInput(event: Event): void { + const input = event.target as HTMLInputElement; + const value = input.value; + + // Sanitize the current value (catches any other ways of setting invalid values) + const sanitized = this.sanitizeInput(value); + + // Only update if the value actually changed after sanitization + // This prevents unnecessary updates and potential infinite loops + if (value !== sanitized) { + input.value = sanitized; + // Trigger input event again to ensure Angular picks up the sanitized value + // This is necessary because we're modifying the value programmatically + input.dispatchEvent(new Event('input', { bubbles: true })); + } + } + + /** + * Sanitizes a string to contain only positive integers. + * + * This method performs two operations: + * 1. Removes all non-digit characters using regex `\D` (matches anything that's not 0-9) + * 2. Removes leading zeros to prevent issues with number parsing + * + * **Examples:** + * - `sanitizeInput('-100')` → `'100'` + * - `sanitizeInput('1.000')` → `'1000'` + * - `sanitizeInput('1,58')` → `'158'` (NOT `'1'` or `'2'`!) + * - `sanitizeInput('+42')` → `'42'` + * - `sanitizeInput('3.14e2')` → `'314'` + * - `sanitizeInput('abc123xyz')` → `'123'` + * - `sanitizeInput('00123')` → `'123'` + * - `sanitizeInput('007')` → `'7'` + * - `sanitizeInput('0')` → `''` (empty string) + * - `sanitizeInput('abc')` → `''` (no digits found) + * + * **Important:** This method does NOT round or parse decimals. + * It simply removes all non-digit characters, so `1.58` becomes `158`, not `1` or `2`. + * + * @param value The input string to sanitize + * @returns A string containing only digits, with leading zeros removed + * + * @private This method is for internal use only + */ + private sanitizeInput(value: string): string { + // Remove all non-digit characters (including -, +, ., ,, e, E, spaces, etc.) + // \D matches any character that is NOT a digit (0-9) + let sanitized = value.replace(/\D/g, ''); + + // Remove leading zeros + // ^0+ matches one or more zeros at the start of the string + // This prevents issues like '007' remaining as '007' instead of '7' + // Note: A single '0' will become an empty string '' + sanitized = sanitized.replace(/^0+/, ''); + + return sanitized; + } }