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 @@
+
\ 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
},