diff --git a/frontend/src/app/components/dashboard/db-table-widgets/db-table-widgets.component.ts b/frontend/src/app/components/dashboard/db-table-widgets/db-table-widgets.component.ts index 4e63aef7e..879a4777f 100644 --- a/frontend/src/app/components/dashboard/db-table-widgets/db-table-widgets.component.ts +++ b/frontend/src/app/components/dashboard/db-table-widgets/db-table-widgets.component.ts @@ -171,12 +171,22 @@ export class DbTableWidgetsComponent implements OnInit { "phone_validation": true } `, + Country: `// No settings required`, Foreign_key: `// Provide settings for foreign key widget { "column_name": "", // copy the name of the column you selected "referenced_column_name": "", "referenced_table_name": "" } +`, + Money: `// Configure money widget settings +// example: +{ + "default_currency": "USD", + "show_currency_selector": false, + "decimal_places": 2, + "allow_negative": true +} `, } diff --git a/frontend/src/app/components/ui-components/filter-fields/country/country.component.ts b/frontend/src/app/components/ui-components/filter-fields/country/country.component.ts index 6cea24064..5a82a28d2 100644 --- a/frontend/src/app/components/ui-components/filter-fields/country/country.component.ts +++ b/frontend/src/app/components/ui-components/filter-fields/country/country.component.ts @@ -23,7 +23,7 @@ export class CountryFilterComponent extends BaseFilterFieldComponent { @Input() value: string; public countries: {value: string | null, label: string, flag: string}[] = []; - public countryControl = new FormControl(''); + public countryControl = new FormControl<{value: string | null, label: string, flag: string} | string>(''); public filteredCountries: Observable<{value: string | null, label: string, flag: string}[]>; originalOrder = () => { return 0; } @@ -40,7 +40,7 @@ export class CountryFilterComponent extends BaseFilterFieldComponent { private setupAutocomplete(): void { this.filteredCountries = this.countryControl.valueChanges.pipe( startWith(''), - map(value => this._filter(value || '')) + map(value => this._filter(typeof value === 'string' ? value : (value?.label || ''))) ); } @@ -48,7 +48,7 @@ export class CountryFilterComponent extends BaseFilterFieldComponent { if (this.value) { const country = this.countries.find(c => c.value === this.value); if (country) { - this.countryControl.setValue(country.label); + this.countryControl.setValue(country); } } } diff --git a/frontend/src/app/components/ui-components/row-fields/country/country.component.ts b/frontend/src/app/components/ui-components/row-fields/country/country.component.ts index df0432acc..f9cc611f2 100644 --- a/frontend/src/app/components/ui-components/row-fields/country/country.component.ts +++ b/frontend/src/app/components/ui-components/row-fields/country/country.component.ts @@ -23,7 +23,7 @@ export class CountryRowComponent extends BaseRowFieldComponent { @Input() value: string; public countries: {value: string | null, label: string, flag: string}[] = []; - public countryControl = new FormControl(''); + public countryControl = new FormControl<{value: string | null, label: string, flag: string} | string>(''); public filteredCountries: Observable<{value: string | null, label: string, flag: string}[]>; originalOrder = () => { return 0; } @@ -40,7 +40,7 @@ export class CountryRowComponent extends BaseRowFieldComponent { private setupAutocomplete(): void { this.filteredCountries = this.countryControl.valueChanges.pipe( startWith(''), - map(value => this._filter(value || '')) + map(value => this._filter(typeof value === 'string' ? value : (value?.label || ''))) ); } @@ -48,7 +48,7 @@ export class CountryRowComponent extends BaseRowFieldComponent { if (this.value) { const country = this.countries.find(c => c.value === this.value); if (country) { - this.countryControl.setValue(country.label); + this.countryControl.setValue(country); } } } diff --git a/frontend/src/app/components/ui-components/row-fields/money/money.component.css b/frontend/src/app/components/ui-components/row-fields/money/money.component.css new file mode 100644 index 000000000..fec1e2422 --- /dev/null +++ b/frontend/src/app/components/ui-components/row-fields/money/money.component.css @@ -0,0 +1,76 @@ +.money-widget { + width: 100%; + margin-left: 0; + margin-right: 0; +} + +.money-input-container { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.money-selector { + min-width: 180px; + flex-shrink: 0; + margin-left: 16px; +} + +.money-selector .mat-mdc-form-field { + margin-left: 0; +} + +.amount-input { + flex: 1; + min-width: 120px; +} + + + +/* Responsive design */ +@media (max-width: 768px) { + .money-input-container { + flex-direction: column; + gap: 8px; + } + + .money-selector { + width: 100%; + min-width: unset; + margin-left: 16px; + } +} + +/* Ensure proper spacing with Material Design */ +.mat-mdc-form-field { + margin-bottom: 0; + margin-left: 0; + margin-right: 0; +} + +.mat-mdc-form-field + .mat-mdc-form-field { + margin-left: 0; +} + +/* Prefix/suffix styling */ +.mat-mdc-form-field-prefix, +.mat-mdc-form-field-suffix { + font-weight: 500; + color: rgba(0, 0, 0, 0.87); +} + +[data-theme="dark"] .mat-mdc-form-field-prefix, +[data-theme="dark"] .mat-mdc-form-field-suffix { + color: rgba(255, 255, 255, 0.87); +} + +/* Specific styling for text prefix */ +.mat-mdc-form-field-text-prefix { + font-weight: 500; + color: rgba(0, 0, 0, 0.87); + margin-right: 4px; +} + +[data-theme="dark"] .mat-mdc-form-field-text-prefix { + color: rgba(255, 255, 255, 0.87); +} \ No newline at end of file diff --git a/frontend/src/app/components/ui-components/row-fields/money/money.component.html b/frontend/src/app/components/ui-components/row-fields/money/money.component.html new file mode 100644 index 000000000..20116f2e0 --- /dev/null +++ b/frontend/src/app/components/ui-components/row-fields/money/money.component.html @@ -0,0 +1,43 @@ +
+
+ + + Currency + + + {{ displayCurrencyFn(currency) }} + + + + + + + {{normalizedLabel}} + {{selectedCurrencyData.symbol}} + + +
+ +
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/row-fields/money/money.component.spec.ts b/frontend/src/app/components/ui-components/row-fields/money/money.component.spec.ts new file mode 100644 index 000000000..90949aa65 --- /dev/null +++ b/frontend/src/app/components/ui-components/row-fields/money/money.component.spec.ts @@ -0,0 +1,222 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { CommonModule } from '@angular/common'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +import { MoneyRowComponent } from './money.component'; + +describe('MoneyRowComponent', () => { + let component: MoneyRowComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + MoneyRowComponent, + CommonModule, + FormsModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + BrowserAnimationsModule + ] + }).compileComponents(); + + fixture = TestBed.createComponent(MoneyRowComponent); + component = fixture.componentInstance; + + // Set required properties from base component + component.label = 'Test Money'; + component.required = false; + component.disabled = false; + component.readonly = false; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize with default currency USD and currency selector disabled', () => { + expect(component.selectedCurrency).toBe('USD'); + expect(component.defaultCurrency).toBe('USD'); + expect(component.showCurrencySelector).toBe(false); + }); + + it('should parse string value correctly', () => { + component.value = '100.50 EUR'; + component.ngOnInit(); + expect(component.selectedCurrency).toBe('EUR'); + expect(component.amount).toBe(100.5); + }); + + it('should parse object value correctly', () => { + component.value = { amount: 250.75, currency: 'GBP' }; + component.ngOnInit(); + expect(component.selectedCurrency).toBe('GBP'); + expect(component.amount).toBe(250.75); + }); + + it('should parse numeric value correctly when currency selector is disabled', () => { + component.value = 150.25; + component.ngOnInit(); + expect(component.selectedCurrency).toBe('USD'); + expect(component.amount).toBe(150.25); + expect(component.displayAmount).toBe('150.25'); + }); + + it('should handle empty value', () => { + component.value = ''; + component.ngOnInit(); + expect(component.selectedCurrency).toBe('USD'); + expect(component.amount).toBe(''); + }); + + it('should format amount with correct decimal places', () => { + component.decimalPlaces = 2; + const formatted = component['formatAmount'](123.456); + expect(formatted).toBe('123.46'); + }); + + it('should handle currency change when selector is enabled', () => { + component.showCurrencySelector = true; + component.selectedCurrency = 'EUR'; + component.amount = 100; + spyOn(component.onFieldChange, 'emit'); + + component.onCurrencyChange(); + + expect(component.onFieldChange.emit).toHaveBeenCalledWith({ + amount: 100, + currency: 'EUR' + }); + }); + + it('should handle amount change with currency selector disabled (default)', () => { + component.displayAmount = '123.45'; + component.selectedCurrency = 'USD'; + spyOn(component.onFieldChange, 'emit'); + + component.onAmountChange(); + + expect(component.amount).toBe(123.45); + expect(component.onFieldChange.emit).toHaveBeenCalledWith(123.45); + }); + + it('should handle amount change with currency selector enabled', () => { + component.showCurrencySelector = true; + component.displayAmount = '123.45'; + component.selectedCurrency = 'USD'; + spyOn(component.onFieldChange, 'emit'); + + component.onAmountChange(); + + expect(component.amount).toBe(123.45); + expect(component.onFieldChange.emit).toHaveBeenCalledWith({ + amount: 123.45, + currency: 'USD' + }); + }); + + it('should handle invalid amount input with letters', () => { + component.amount = 100; + component.displayAmount = 'abc123def'; // Contains letters which get stripped + + component.onAmountChange(); + component.onAmountBlur(); + + expect(component.amount).toBe(123); + expect(component.displayAmount).toBe('123.00'); + }); + + it('should handle completely invalid input', () => { + component.amount = 100; + component.displayAmount = 'invalid'; // All letters, becomes empty after strip + + component.onAmountChange(); + + // @ts-ignore + expect(component.amount).toBe(''); + expect(component.displayAmount).toBe(''); + }); + + it('should handle invalid amount input when amount is empty', () => { + component.amount = ''; + component.displayAmount = 'invalid'; + + component.onAmountChange(); + + expect(component.displayAmount).toBe(''); + }); + + it('should respect allow_negative configuration', () => { + component.allowNegative = false; + component.displayAmount = '-123.45'; + + component.onAmountChange(); + + expect(component.amount).toBe(123.45); + }); + + it('should configure from widget params', () => { + component.widgetStructure = { + field_name: 'test_field', + widget_type: 'Money', + name: 'Test Widget', + description: 'Test Description', + widget_params: { + default_currency: 'EUR', + show_currency_selector: true, + decimal_places: 3, + allow_negative: false + } + }; + + component.configureFromWidgetParams(); + + expect(component.defaultCurrency).toBe('EUR'); + expect(component.showCurrencySelector).toBe(true); + expect(component.decimalPlaces).toBe(3); + expect(component.allowNegative).toBe(false); + }); + + it('should return correct display value', () => { + component.amount = 123.45; + component.selectedCurrency = 'USD'; + + const displayValue = component.displayValue; + + expect(displayValue).toBe('$123.45'); + }); + + it('should return correct placeholder', () => { + component.selectedCurrency = 'EUR'; + + const placeholder = component.placeholder; + + expect(placeholder).toBe('Enter amount in Euro'); + }); + + it('should find selected currency data', () => { + component.selectedCurrency = 'GBP'; + + const currencyData = component.selectedCurrencyData; + + expect(currencyData.code).toBe('GBP'); + expect(currencyData.name).toBe('British Pound'); + expect(currencyData.symbol).toBe('£'); + }); + + it('should emit empty value when amount is cleared', () => { + component.amount = ''; + spyOn(component.onFieldChange, 'emit'); + + component['updateValue'](); + + expect(component.onFieldChange.emit).toHaveBeenCalledWith(''); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/components/ui-components/row-fields/money/money.component.ts b/frontend/src/app/components/ui-components/row-fields/money/money.component.ts new file mode 100644 index 000000000..04931eca6 --- /dev/null +++ b/frontend/src/app/components/ui-components/row-fields/money/money.component.ts @@ -0,0 +1,249 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { BaseRowFieldComponent } from '../base-row-field/base-row-field.component'; +import { FormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { CommonModule } from '@angular/common'; + +interface Money { + code: string; + name: string; + symbol: string; + flag?: string; +} + +interface MoneyValue { + amount: number | string; + currency: string; +} + +@Component({ + selector: 'app-row-money', + templateUrl: './money.component.html', + styleUrls: ['./money.component.css'], + imports: [CommonModule, MatFormFieldModule, MatInputModule, MatSelectModule, FormsModule] +}) +export class MoneyRowComponent extends BaseRowFieldComponent implements OnInit { + @Input() value: string | number | MoneyValue = ''; + + static type = 'money'; + + defaultCurrency: string = 'USD'; + showCurrencySelector: boolean = false; + decimalPlaces: number = 2; + allowNegative: boolean = true; + + selectedCurrency: string = 'USD'; + amount: number | string = ''; + displayAmount: string = ''; + + currencies: Money[] = [ + { code: 'USD', name: 'US Dollar', symbol: '$', flag: '🇺🇸' }, + { code: 'EUR', name: 'Euro', symbol: '€', flag: '🇪🇺' }, + { code: 'GBP', name: 'British Pound', symbol: '£', flag: '🇬🇧' }, + { code: 'JPY', name: 'Japanese Yen', symbol: '¥', flag: '🇯🇵' }, + { code: 'CHF', name: 'Swiss Franc', symbol: 'CHF', flag: '🇨🇭' }, + { code: 'CAD', name: 'Canadian Dollar', symbol: 'C$', flag: '🇨🇦' }, + { code: 'AUD', name: 'Australian Dollar', symbol: 'A$', flag: '🇦🇺' }, + { code: 'CNY', name: 'Chinese Yuan', symbol: '¥', flag: '🇨🇳' }, + { code: 'INR', name: 'Indian Rupee', symbol: '₹', flag: '🇮🇳' }, + { code: 'KRW', name: 'South Korean Won', symbol: '₩', flag: '🇰🇷' }, + { code: 'SGD', name: 'Singapore Dollar', symbol: 'S$', flag: '🇸🇬' }, + { code: 'HKD', name: 'Hong Kong Dollar', symbol: 'HK$', flag: '🇭🇰' }, + { code: 'NOK', name: 'Norwegian Krone', symbol: 'kr', flag: '🇳🇴' }, + { code: 'SEK', name: 'Swedish Krona', symbol: 'kr', flag: '🇸🇪' }, + { code: 'DKK', name: 'Danish Krone', symbol: 'kr', flag: '🇩🇰' }, + { code: 'PLN', name: 'Polish Zloty', symbol: 'zł', flag: '🇵🇱' }, + { code: 'CZK', name: 'Czech Koruna', symbol: 'Kč', flag: '🇨🇿' }, + { code: 'HUF', name: 'Hungarian Forint', symbol: 'Ft', flag: '🇭🇺' }, + { code: 'RUB', name: 'Russian Ruble', symbol: '₽', flag: '🇷🇺' }, + { code: 'BRL', name: 'Brazilian Real', symbol: 'R$', flag: '🇧🇷' }, + { code: 'MXN', name: 'Mexican Peso', symbol: '$', flag: '🇲🇽' }, + { code: 'ZAR', name: 'South African Rand', symbol: 'R', flag: '🇿🇦' }, + { code: 'TRY', name: 'Turkish Lira', symbol: '₺', flag: '🇹🇷' }, + { code: 'AED', name: 'UAE Dirham', symbol: 'د.إ', flag: '🇦🇪' }, + { code: 'SAR', name: 'Saudi Riyal', symbol: '﷼', flag: '🇸🇦' }, + { code: 'ILS', name: 'Israeli Shekel', symbol: '₪', flag: '🇮🇱' }, + { code: 'EGP', name: 'Egyptian Pound', symbol: '£', flag: '🇪🇬' }, + { code: 'THB', name: 'Thai Baht', symbol: '฿', flag: '🇹🇭' }, + { code: 'MYR', name: 'Malaysian Ringgit', symbol: 'RM', flag: '🇲🇾' }, + { code: 'IDR', name: 'Indonesian Rupiah', symbol: 'Rp', flag: '🇮🇩' }, + { code: 'PHP', name: 'Philippine Peso', symbol: '₱', flag: '🇵🇭' }, + { code: 'VND', name: 'Vietnamese Dong', symbol: '₫', flag: '🇻🇳' }, + { code: 'UAH', name: 'Ukrainian Hryvnia', symbol: '₴', flag: '🇺🇦' } + ]; + + ngOnInit(): void { + super.ngOnInit(); + this.configureFromWidgetParams(); + this.initializeMoneyValue(); + } + + configureFromWidgetParams(): void { + if (this.widgetStructure && this.widgetStructure.widget_params) { + const params = this.widgetStructure.widget_params; + + if (typeof params.default_currency === 'string') { + this.defaultCurrency = params.default_currency; + } + + if (typeof params.show_currency_selector === 'boolean') { + this.showCurrencySelector = params.show_currency_selector; + } + + if (typeof params.decimal_places === 'number') { + this.decimalPlaces = Math.max(0, Math.min(10, params.decimal_places)); + } + + if (typeof params.allow_negative === 'boolean') { + this.allowNegative = params.allow_negative; + } + } + } + + private initializeMoneyValue(): void { + if (this.value) { + if (typeof this.value === 'string') { + this.parseStringValue(this.value); + } else if (typeof this.value === 'object' && this.value.amount !== undefined && this.value.currency) { + this.amount = this.value.amount; + this.selectedCurrency = this.value.currency; + this.displayAmount = this.formatAmount(this.amount); + } else if (typeof this.value === 'number') { + // Handle numeric values when currency selector is disabled + this.amount = this.value; + this.selectedCurrency = this.defaultCurrency; + this.displayAmount = this.formatAmount(this.amount); + } + } else { + this.selectedCurrency = this.defaultCurrency; + this.amount = ''; + this.displayAmount = ''; + } + } + + private parseStringValue(stringValue: string): void { + // Try to parse formats like "100.50 USD", "USD 100.50", "$100.50", "€100,50" + const currencyMatch = stringValue.match(/([A-Z]{3})/); + const numberMatch = stringValue.match(/([\d,.-]+)/); + + if (currencyMatch) { + this.selectedCurrency = currencyMatch[1]; + } else { + // Try to detect currency by symbol + const currency = this.currencies.find(c => stringValue.includes(c.symbol)); + if (currency) { + this.selectedCurrency = currency.code; + } else { + this.selectedCurrency = this.defaultCurrency; + } + } + + if (numberMatch) { + const cleanNumber = numberMatch[1].replace(/,/g, ''); + this.amount = parseFloat(cleanNumber) || ''; + this.displayAmount = this.formatAmount(this.amount); + } else { + this.amount = ''; + this.displayAmount = ''; + } + } + + onCurrencyChange(): void { + this.updateValue(); + } + + onAmountChange(): void { + // Clean and validate the input + let cleanValue = this.displayAmount.replace(/[^\d.-]/g, ''); + + // Handle negative values + if (!this.allowNegative) { + cleanValue = cleanValue.replace(/-/g, ''); + } + + // Parse the number + const numericValue = parseFloat(cleanValue); + + if (!isNaN(numericValue)) { + this.amount = numericValue; + // Don't reformat while user is typing to preserve focus + if (this.displayAmount !== cleanValue) { + this.displayAmount = cleanValue; + } + } else if (cleanValue === '' || cleanValue === '-') { + this.amount = ''; + this.displayAmount = cleanValue; + } else { + // Invalid input, revert to previous value + this.displayAmount = this.formatAmount(this.amount); + } + + this.updateValue(); + } + + onAmountBlur(): void { + // Format the amount when user leaves the field + if (this.amount !== '' && this.amount !== null && this.amount !== undefined) { + this.displayAmount = this.formatAmount(this.amount); + } + } + + private formatAmount(amount: number | string): string { + if (amount === '' || amount === null || amount === undefined) { + return ''; + } + + const numericAmount = typeof amount === 'string' ? parseFloat(amount) : amount; + + if (isNaN(numericAmount)) { + return ''; + } + + return numericAmount.toFixed(this.decimalPlaces); + } + + private updateValue(): void { + if (this.amount === '' || this.amount === null || this.amount === undefined) { + this.value = ''; + } else { + if (this.showCurrencySelector) { + // Store as object with amount and currency when selector is enabled + this.value = { + amount: this.amount, + currency: this.selectedCurrency + }; + } else { + // Store only the numeric amount when currency selector is disabled + this.value = typeof this.amount === 'string' ? parseFloat(this.amount) || 0 : this.amount; + } + } + + this.onFieldChange.emit(this.value); + } + + get selectedCurrencyData(): Money { + return this.currencies.find(c => c.code === this.selectedCurrency) || this.currencies[0]; + } + + get placeholder(): string { + const currency = this.selectedCurrencyData; + return `Enter amount in ${currency.name}`; + } + + get displayValue(): string { + if (!this.amount && this.amount !== 0) { + return ''; + } + + const currency = this.selectedCurrencyData; + const formattedAmount = this.formatAmount(this.amount); + + return `${currency.symbol}${formattedAmount}`; + } + + displayCurrencyFn(currency: Money): string { + return currency ? `${currency.flag || ''} ${currency.code} - ${currency.name}` : ''; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/ui-components/row-fields/phone/phone.component.spec.ts b/frontend/src/app/components/ui-components/row-fields/phone/phone.component.spec.ts new file mode 100644 index 000000000..4c4d4d893 --- /dev/null +++ b/frontend/src/app/components/ui-components/row-fields/phone/phone.component.spec.ts @@ -0,0 +1,314 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule, FormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { CommonModule } from '@angular/common'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { PhoneRowComponent } from './phone.component'; + +describe('PhoneRowComponent', () => { + let component: PhoneRowComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + PhoneRowComponent, + CommonModule, + ReactiveFormsModule, + FormsModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatAutocompleteModule, + NoopAnimationsModule + ] + }).compileComponents(); + + fixture = TestBed.createComponent(PhoneRowComponent); + component = fixture.componentInstance; + + // Set basic required properties + component.label = 'Phone'; + component.key = 'phone'; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('US Phone Number Formatting', () => { + beforeEach(() => { + // Set US as selected country + const usCountry = component.countries.find(c => c.code === 'US'); + component.selectedCountry = usCountry!; + component.countryControl.setValue(usCountry!); + component.initializeFormatter(); + }); + + it('should format US phone number in E164 format when user enters local number', () => { + const localNumber = '(202) 456-1111'; + component.displayPhoneNumber = localNumber; + + component.onPhoneNumberChange(); + + expect(component.value).toBe('+12024561111'); + }); + + it('should format US phone number in E164 format when user enters raw digits', () => { + const rawDigits = '2024561111'; + component.displayPhoneNumber = rawDigits; + + component.onPhoneNumberChange(); + + expect(component.value).toBe('+12024561111'); + }); + + it('should handle US phone number with different formatting', () => { + const formattedNumber = '202.456.1111'; + component.displayPhoneNumber = formattedNumber; + + component.onPhoneNumberChange(); + + expect(component.value).toBe('+12024561111'); + }); + + it('should handle US phone number with country code already included', () => { + const withCountryCode = '+1 202 456 1111'; + component.displayPhoneNumber = withCountryCode; + + component.onPhoneNumberChange(); + + expect(component.value).toBe('+12024561111'); + }); + + it('should not format invalid US phone number', () => { + const invalidNumber = '123'; + component.displayPhoneNumber = invalidNumber; + + component.onPhoneNumberChange(); + + // Should either be empty or the cleaned input, but not a malformed international number + expect(component.value).not.toMatch(/^\+1123$/); + }); + }); + + describe('International Phone Number Formatting', () => { + it('should format UK phone number in E164 format', () => { + const ukCountry = component.countries.find(c => c.code === 'GB'); + component.selectedCountry = ukCountry!; + component.countryControl.setValue(ukCountry!); + component.initializeFormatter(); + + const localNumber = '020 7946 0958'; + component.displayPhoneNumber = localNumber; + + component.onPhoneNumberChange(); + + expect(component.value).toBe('+442079460958'); + }); + + it('should format German phone number in E164 format', () => { + const deCountry = component.countries.find(c => c.code === 'DE'); + component.selectedCountry = deCountry!; + component.countryControl.setValue(deCountry!); + component.initializeFormatter(); + + const localNumber = '030 12345678'; + component.displayPhoneNumber = localNumber; + + component.onPhoneNumberChange(); + + expect(component.value).toBe('+493012345678'); + }); + }); + + describe('Phone Number Validation', () => { + beforeEach(() => { + component.phoneValidation = true; + }); + + it('should validate US phone number as valid', () => { + const usCountry = component.countries.find(c => c.code === 'US'); + component.selectedCountry = usCountry!; + component.displayPhoneNumber = '(202) 456-1111'; + + const isValid = component.isValidPhoneNumber(); + + expect(isValid).toBe(true); + }); + + it('should validate international phone number as valid', () => { + component.displayPhoneNumber = '+442079460958'; + + const isValid = component.isValidPhoneNumber(); + + expect(isValid).toBe(true); + }); + + it('should validate invalid phone number as invalid', () => { + const usCountry = component.countries.find(c => c.code === 'US'); + component.selectedCountry = usCountry!; + component.displayPhoneNumber = '123'; + + const isValid = component.isValidPhoneNumber(); + + expect(isValid).toBe(false); + }); + + it('should treat empty phone number as valid when validation is enabled', () => { + component.displayPhoneNumber = ''; + + const isValid = component.isValidPhoneNumber(); + + expect(isValid).toBe(true); // Empty is valid, let required validation handle it + }); + }); + + describe('Country Detection', () => { + it('should detect country from international number', () => { + const internationalNumber = '+442079460958'; + component.displayPhoneNumber = internationalNumber; + + component.onPhoneNumberChange(); + + expect(component.selectedCountry.code).toBe('GB'); + expect(component.countryControl.value?.code).toBe('GB'); + }); + + it('should detect US from +1 number', () => { + const usInternationalNumber = '+12024561111'; + component.displayPhoneNumber = usInternationalNumber; + + component.onPhoneNumberChange(); + + expect(component.selectedCountry.code).toBe('US'); + expect(component.countryControl.value?.code).toBe('US'); + }); + }); + + describe('Autocomplete Functionality', () => { + it('should filter countries by name', () => { + const filtered = component._filterCountries('United'); + + expect(filtered.length).toBeGreaterThan(0); + expect(filtered.some(country => country.name.includes('United'))).toBe(true); + }); + + it('should filter countries by code', () => { + const filtered = component._filterCountries('US'); + + expect(filtered.length).toBeGreaterThan(0); + expect(filtered.some(country => country.code === 'US')).toBe(true); + }); + + it('should filter countries by dial code', () => { + const filtered = component._filterCountries('+1'); + + expect(filtered.length).toBeGreaterThan(0); + expect(filtered.some(country => country.dialCode === '+1')).toBe(true); + }); + + it('should display country with flag, name and dial code', () => { + const usCountry = component.countries.find(c => c.code === 'US')!; + + const displayText = component.displayCountryFn(usCountry); + + expect(displayText).toContain('🇺🇸'); + expect(displayText).toContain('United States'); + expect(displayText).toContain('+1'); + }); + + it('should handle country selection', () => { + const ukCountry = component.countries.find(c => c.code === 'GB')!; + + component.onCountrySelected(ukCountry); + + expect(component.selectedCountry).toBe(ukCountry); + expect(component.formatter).toBeDefined(); + }); + }); + + describe('Example Phone Numbers', () => { + it('should return correct example for US', () => { + const usCountry = component.countries.find(c => c.code === 'US')!; + component.selectedCountry = usCountry; + + const example = component.getExamplePhoneNumber(); + + expect(example).toBe('(202) 456-1111'); + }); + + it('should return correct example for UK', () => { + const ukCountry = component.countries.find(c => c.code === 'GB')!; + component.selectedCountry = ukCountry; + + const example = component.getExamplePhoneNumber(); + + expect(example).toBe('020 7946 0958'); + }); + + it('should return fallback example for unknown country', () => { + component.selectedCountry = { code: 'XX', name: 'Unknown', dialCode: '+999', flag: '🏳️' }; + + const example = component.getExamplePhoneNumber(); + + expect(example).toBe('+999 123 4567'); + }); + }); + + describe('Edge Cases and Error Handling', () => { + it('should handle malformed phone numbers gracefully', () => { + component.displayPhoneNumber = 'not-a-phone-number'; + + expect(() => component.onPhoneNumberChange()).not.toThrow(); + }); + + it('should handle empty selected country', () => { + component.selectedCountry = null as any; + component.displayPhoneNumber = '5551234567'; + + expect(() => component.onPhoneNumberChange()).not.toThrow(); + }); + + it('should emit field change events', () => { + spyOn(component.onFieldChange, 'emit'); + + const usCountry = component.countries.find(c => c.code === 'US'); + component.selectedCountry = usCountry!; + component.displayPhoneNumber = '5551234567'; + + component.onPhoneNumberChange(); + + expect(component.onFieldChange.emit).toHaveBeenCalledWith(component.value); + }); + }); + + describe('Widget Configuration', () => { + it('should configure from widget params', () => { + component.widgetStructure = { + widget_params: { + preferred_countries: ['CA', 'GB'], + enable_placeholder: false, + phone_validation: false + } + } as any; + + component.configureFromWidgetParams(); + + expect(component.preferredCountries).toEqual(['CA', 'GB']); + expect(component.enablePlaceholder).toBe(false); + expect(component.phoneValidation).toBe(false); + }); + + it('should handle missing widget params', () => { + component.widgetStructure = {} as any; + + expect(() => component.configureFromWidgetParams()).not.toThrow(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/consts/field-types.ts b/frontend/src/app/consts/field-types.ts index 6a9664778..9b8e767d3 100644 --- a/frontend/src/app/consts/field-types.ts +++ b/frontend/src/app/consts/field-types.ts @@ -20,6 +20,7 @@ import { ImageRowComponent } from '../components/ui-components/row-fields/image/ import { UrlRowComponent } from '../components/ui-components/row-fields/url/url.component'; import { CountryRowComponent } from '../components/ui-components/row-fields/country/country.component'; import { PhoneRowComponent } from '../components/ui-components/row-fields/phone/phone.component'; +import { MoneyRowComponent } from '../components/ui-components/row-fields/money/money.component'; export const timestampTypes = ['timestamp without time zone', 'timestamp with time zone', 'timestamp', 'date', 'time without time zone', 'time with time zone' , 'time', 'datetime', 'date time', 'datetime2', 'datetimeoffset', 'curdate', 'curtime', 'now', 'localtime', 'localtimestamp']; export const defaultTimestampValues = { @@ -46,6 +47,7 @@ export const UIwidgets = { URL: UrlRowComponent, Country: CountryRowComponent, Phone: PhoneRowComponent, + Money: MoneyRowComponent, Foreign_key: ForeignKeyRowComponent, } @@ -95,7 +97,7 @@ export const fieldTypes = { bytea: FileRowComponent, //etc - money: TextRowComponent, + money: MoneyRowComponent, //mess (math) point: PointRowComponent, @@ -224,8 +226,8 @@ export const fieldTypes = { image: FileRowComponent, // etc - money: TextRowComponent, - smallmoney: TextRowComponent, + money: MoneyRowComponent, + smallmoney: MoneyRowComponent, "foreign key": ForeignKeyRowComponent },