diff --git a/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.spec.ts index 245d71db8..5028614d9 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.spec.ts @@ -1,5 +1,4 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; - import { BaseEditFieldComponent } from './base-row-field.component'; describe('BaseEditFieldComponent', () => { @@ -13,10 +12,26 @@ describe('BaseEditFieldComponent', () => { fixture = TestBed.createComponent(BaseEditFieldComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should normalize label on init', () => { + fixture.componentRef.setInput('label', 'user_first_name'); + component.ngOnInit(); + expect(component.normalizedLabel()).toBeTruthy(); + }); + + it('should set normalizedLabel from label input', () => { + fixture.componentRef.setInput('label', 'test_field'); + component.ngOnInit(); + expect(component.normalizedLabel()).toBeDefined(); + expect(typeof component.normalizedLabel()).toBe('string'); + }); + + it('should have onFieldChange event emitter', () => { + expect(component.onFieldChange).toBeDefined(); + }); }); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.ts index 5eb10c9a6..57825999a 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { Component, computed, input, OnInit, output } from '@angular/core'; import { TableField, TableForeignKey, WidgetStructure } from 'src/app/models/table'; import { normalizeFieldName } from '../../../../lib/normalize'; @@ -10,20 +10,18 @@ import { normalizeFieldName } from '../../../../lib/normalize'; imports: [CommonModule], }) export class BaseEditFieldComponent implements OnInit { - @Input() key: string; - @Input() label: string; - @Input() required: boolean; - @Input() readonly: boolean; - @Input() structure: TableField; - @Input() disabled: boolean; - @Input() widgetStructure: WidgetStructure; - @Input() relations: TableForeignKey; + readonly key = input(); + readonly label = input(); + readonly required = input(false); + readonly readonly = input(false); + readonly structure = input(); + readonly disabled = input(false); + readonly widgetStructure = input(); + readonly relations = input(); - @Output() onFieldChange = new EventEmitter(); + readonly onFieldChange = output(); - public normalizedLabel: string; + readonly normalizedLabel = computed(() => normalizeFieldName(this.label() || '')); - ngOnInit(): void { - this.normalizedLabel = normalizeFieldName(this.label); - } + ngOnInit(): void {} } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/binary-data-caption/binary-data-caption.component.html b/frontend/src/app/components/ui-components/record-edit-fields/binary-data-caption/binary-data-caption.component.html index 824ba0f1b..10de68e46 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/binary-data-caption/binary-data-caption.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/binary-data-caption/binary-data-caption.component.html @@ -1,7 +1,7 @@
- {{normalizedLabel}} + {{normalizedLabel()}} + attr.data-testid="record-{{label()}}-binary-data-caption"> binary data
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.html b/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.html index 375b23a88..208cbc33d 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.html @@ -1,26 +1,26 @@ -
- - - Yes - No - -
- - +@if (isRadiogroup) {
- - {{normalizedLabel()}} + + Yes + No + +
+} @else { +
+ + Yes No
-
+} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.spec.ts index bec93cda4..b17d63a5f 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.spec.ts @@ -37,40 +37,40 @@ describe('BooleanEditComponent', () => { }); it('should set value in true when input value contain anything', () => { - component.value = 'anything'; - component.structure = fakeStructure; + fixture.componentRef.setInput('value', 'anything'); + fixture.componentRef.setInput('structure', fakeStructure); component.ngOnInit(); - expect(component.value).toBeTruthy(); + expect(component.value()).toBeTruthy(); }); it('should set value in felse when input value is 0', () => { - component.value = 0; - component.structure = fakeStructure; + fixture.componentRef.setInput('value', 0); + fixture.componentRef.setInput('structure', fakeStructure); component.ngOnInit(); - expect(component.value).toBeFalsy(); + expect(component.value()).toBeFalsy(); }); it('should set value in null when input value is undefined', () => { - component.value = undefined; - component.structure = fakeStructure; + fixture.componentRef.setInput('value', undefined); + fixture.componentRef.setInput('structure', fakeStructure); component.ngOnInit(); - expect(component.value).toEqual(null); + expect(component.value()).toEqual(null); }); it('should set isRadiogroup in false if allow_null is false', () => { - component.value = undefined; - component.structure = fakeStructure; + fixture.componentRef.setInput('value', undefined); + fixture.componentRef.setInput('structure', fakeStructure); component.ngOnInit(); expect(component.isRadiogroup).toEqual(false); }); it('should set isRadiogroup in true if allow_null is true', () => { - component.value = undefined; - component.structure = { + fixture.componentRef.setInput('value', undefined); + fixture.componentRef.setInput('structure', { column_name: 'banned', column_default: '0', data_type: 'tinyint', @@ -79,7 +79,7 @@ describe('BooleanEditComponent', () => { auto_increment: false, allow_null: true, character_maximum_length: 1, - }; + }); component.ngOnInit(); expect(component.isRadiogroup).toEqual(true); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.ts index 178ff0c08..3445003da 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; +import { Component, inject, model } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { DBtype } from 'src/app/models/connection'; @@ -13,48 +13,43 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone imports: [CommonModule, FormsModule, MatButtonToggleModule], }) export class BooleanEditComponent extends BaseEditFieldComponent { - @Input() value: boolean | number | string | null; + readonly value = model(); public isRadiogroup: boolean; connectionType: DBtype; - constructor(private _connections: ConnectionsService) { - super(); - } + private _connections = inject(ConnectionsService); ngOnInit(): void { super.ngOnInit(); this.connectionType = this._connections.currentConnection.type; - if (this.value) { - this.value = true; - } else if (this.value === 0 || this.value === '' || this.value === false) { - this.value = false; + const val = this.value(); + if (val) { + this.value.set(true); + } else if (val === 0 || val === '' || val === false) { + this.value.set(false); } else { - this.value = null; + this.value.set(null); } - this.onFieldChange.emit(this.value); + this.onFieldChange.emit(this.value()); - // Parse widget parameters if available let parsedParams = null; - if (this.widgetStructure?.widget_params) { - parsedParams = - typeof this.widgetStructure.widget_params === 'string' - ? JSON.parse(this.widgetStructure.widget_params) - : this.widgetStructure.widget_params; + const ws = this.widgetStructure(); + if (ws?.widget_params) { + parsedParams = typeof ws.widget_params === 'string' ? JSON.parse(ws.widget_params) : ws.widget_params; } - // Check allow_null from either structure or widget params - this.isRadiogroup = this.structure?.allow_null || !!parsedParams?.allow_null; + this.isRadiogroup = this.structure()?.allow_null || !!parsedParams?.allow_null; } onToggleChange(optionValue: boolean): void { - if (this.value === optionValue) { - this.value = null; + if (this.value() === optionValue) { + this.value.set(null); } else { - this.value = optionValue; + this.value.set(optionValue); } - this.onFieldChange.emit(this.value); + this.onFieldChange.emit(this.value()); } } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.html b/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.html index 11e4bac37..6dce1ee7a 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.html @@ -1,11 +1,11 @@ -{{ normalizedLabel }} {{ required ? '*' : '' }} +{{ normalizedLabel() }} {{ required() ? '*' : '' }}
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.spec.ts index bc6d68e9c..08f2fc367 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.spec.ts @@ -29,13 +29,13 @@ describe('CodeEditComponent', () => { fixture = TestBed.createComponent(CodeEditComponent); component = fixture.componentInstance; - component.widgetStructure = { + fixture.componentRef.setInput('widgetStructure', { widget_params: { language: 'css', }, - } as any; - component.label = 'styles'; - component.value = '.container { display: flex; }'; + } as any); + fixture.componentRef.setInput('label', 'styles'); + fixture.componentRef.setInput('value', '.container { display: flex; }'); fixture.detectChanges(); }); @@ -69,21 +69,21 @@ describe('CodeEditComponent', () => { }); it('should support different languages', () => { - component.widgetStructure = { + fixture.componentRef.setInput('widgetStructure', { widget_params: { language: 'javascript', }, - } as any; - component.label = 'script'; - component.value = 'console.log("hello");'; + } as any); + fixture.componentRef.setInput('label', 'script'); + fixture.componentRef.setInput('value', 'console.log("hello");'); component.ngOnInit(); expect((component.mutableCodeModel as any).language).toBe('javascript'); }); it('should normalize label from base class', () => { - component.label = 'custom_styles'; + fixture.componentRef.setInput('label', 'custom_styles'); component.ngOnInit(); - expect(component.normalizedLabel).toBeDefined(); + expect(component.normalizedLabel()).toBeDefined(); }); }); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.ts index 461a1a600..692293453 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; +import { Component, inject, model } from '@angular/core'; import { CodeEditorModule } from '@ngstack/code-editor'; import { UiSettingsService } from 'src/app/services/ui-settings.service'; import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; @@ -11,7 +11,9 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone imports: [CommonModule, CodeEditorModule], }) export class CodeEditComponent extends BaseEditFieldComponent { - @Input() value; + readonly value = model(); + + private _uiSettings = inject(UiSettingsService); public mutableCodeModel: Object; public codeEditorOptions = { @@ -22,16 +24,12 @@ export class CodeEditComponent extends BaseEditFieldComponent { }; public codeEditorTheme = 'vs-dark'; - constructor(private _uiSettings: UiSettingsService) { - super(); - } - ngOnInit(): void { super.ngOnInit(); this.mutableCodeModel = { - language: `${this.widgetStructure.widget_params.language}`, - uri: `${this.label}.json`, - value: this.value, + language: `${this.widgetStructure().widget_params.language}`, + uri: `${this.label()}.json`, + value: this.value(), }; this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs'; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.html b/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.html index e97f8054e..6f25f812b 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.html @@ -1,16 +1,16 @@
- {{normalizedLabel}} - {{normalizedLabel()}} +
-
diff --git a/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.spec.ts new file mode 100644 index 000000000..282e302f7 --- /dev/null +++ b/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.spec.ts @@ -0,0 +1,120 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ColorEditComponent } from './color.component'; + +describe('ColorEditComponent', () => { + let component: ColorEditComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ColorEditComponent, BrowserAnimationsModule], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ColorEditComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should return false for isValidColor when value is empty', () => { + fixture.componentRef.setInput('value', ''); + expect(component.isValidColor).toBe(false); + }); + + it('should return false for isValidColor when value is null', () => { + fixture.componentRef.setInput('value', null); + expect(component.isValidColor).toBe(false); + }); + + it('should return true for isValidColor when value is a valid hex color', () => { + fixture.componentRef.setInput('value', '#ff0000'); + expect(component.isValidColor).toBe(true); + }); + + it('should return true for isValidColor when value is a hex color without hash', () => { + fixture.componentRef.setInput('value', 'ff0000'); + expect(component.isValidColor).toBe(true); + }); + + it('should return true for isValidColor when value is a valid rgb color', () => { + fixture.componentRef.setInput('value', 'rgb(255, 0, 0)'); + expect(component.isValidColor).toBe(true); + }); + + it('should return false for isValidColor when value is invalid', () => { + fixture.componentRef.setInput('value', 'notacolor'); + expect(component.isValidColor).toBe(false); + }); + + it('should return normalized hex for color picker', () => { + fixture.componentRef.setInput('value', '#ff0000'); + expect(component.normalizedColorForPicker.toLowerCase()).toBe('#ff0000'); + }); + + it('should return #000000 for invalid color in normalizedColorForPicker', () => { + fixture.componentRef.setInput('value', 'invalid'); + expect(component.normalizedColorForPicker).toBe('#000000'); + }); + + it('should return hex_hash format by default in formattedColorValue', () => { + fixture.componentRef.setInput('value', 'rgb(255, 0, 0)'); + const result = component.formattedColorValue; + expect(result).toMatch(/^#[A-Fa-f0-9]{6,8}$/); + }); + + it('should return hex without hash when format is hex', () => { + fixture.componentRef.setInput('value', '#ff0000'); + fixture.componentRef.setInput('widgetStructure', { widget_params: { format: 'hex' } } as any); + const result = component.formattedColorValue; + expect(result).not.toContain('#'); + }); + + it('should return rgb format when format is rgb', () => { + fixture.componentRef.setInput('value', '#ff0000'); + fixture.componentRef.setInput('widgetStructure', { widget_params: { format: 'rgb' } } as any); + const result = component.formattedColorValue; + expect(result).toMatch(/^rgb/); + }); + + it('should handle hsl format and fallback to hex when hsl conversion fails', () => { + fixture.componentRef.setInput('value', '#ff0000'); + fixture.componentRef.setInput('widgetStructure', { widget_params: { format: 'hsl' } } as any); + const result = component.formattedColorValue; + // colorString.get.hsl returns null for hex input, so it falls back to hex + expect(result).toMatch(/^#|^hsl/); + }); + + it('should return original value for formattedColorValue when value is invalid', () => { + fixture.componentRef.setInput('value', 'invalid'); + expect(component.formattedColorValue).toBe('invalid'); + }); + + it('should emit onFieldChange when onTextInputChange is called', () => { + vi.spyOn(component.onFieldChange, 'emit'); + fixture.componentRef.setInput('value', '#ff0000'); + component.onTextInputChange(); + expect(component.onFieldChange.emit).toHaveBeenCalledWith('#ff0000'); + }); + + it('should update value and emit onFieldChange when onColorPickerChange is called', () => { + vi.spyOn(component.onFieldChange, 'emit'); + const event = { target: { value: '#00ff00' } } as any; + component.onColorPickerChange(event); + expect(component.value()).toBeTruthy(); + expect(component.onFieldChange.emit).toHaveBeenCalled(); + }); + + it('should set value directly when picker value cannot be parsed', () => { + vi.spyOn(component.onFieldChange, 'emit'); + const event = { target: { value: '' } } as any; + component.onColorPickerChange(event); + expect(component.value()).toBe(''); + expect(component.onFieldChange.emit).toHaveBeenCalledWith(''); + }); +}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.ts index 2225c2fc3..35700e6de 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.ts @@ -1,12 +1,10 @@ -import { Component, Injectable, Input } from '@angular/core'; +import { Component, model } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import colorString from 'color-string'; import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; -@Injectable() - @Component({ selector: 'app-edit-color', templateUrl: './color.component.html', @@ -14,40 +12,41 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone imports: [MatFormFieldModule, MatInputModule, FormsModule], }) export class ColorEditComponent extends BaseEditFieldComponent { - @Input() value: string; + readonly value = model(); static type = 'color'; get isValidColor(): boolean { - if (!this.value) return false; - return this.parseColor(this.value) !== null; + const val = this.value(); + if (!val) return false; + return this._parseColor(val) !== null; } get normalizedColorForPicker(): string { - const parsed = this.parseColor(this.value); + const parsed = this._parseColor(this.value()); if (parsed) { const [r, g, b] = parsed.value; - return `#${this.toHex(r)}${this.toHex(g)}${this.toHex(b)}`; + return `#${this._toHex(r)}${this._toHex(g)}${this._toHex(b)}`; } return '#000000'; } get formattedColorValue(): string { - const parsed = this.parseColor(this.value); - if (!parsed) return this.value; + const val = this.value(); + const parsed = this._parseColor(val); + if (!parsed) return val; - const format = this.widgetStructure?.widget_params?.format || 'hex_hash'; + const format = this.widgetStructure()?.widget_params?.format || 'hex_hash'; const [r, g, b, a] = parsed.value; switch (format) { case 'hex': - return colorString.to.hex(r, g, b, a).slice(1); // Remove # prefix + return colorString.to.hex(r, g, b, a).slice(1); case 'hex_hash': return colorString.to.hex(r, g, b, a); case 'rgb': return colorString.to.rgb(r, g, b, a); case 'hsl': { - // Convert RGB to HSL using built-in conversion const hex = colorString.to.hex(r, g, b, a); const hslParsed = colorString.get.hsl(hex); if (hslParsed) { @@ -61,70 +60,65 @@ export class ColorEditComponent extends BaseEditFieldComponent { } } - private parseColor(color: string): any { - if (!color) return null; - - // Try parsing with color-string - const parsed = colorString.get(color); - if (parsed) return parsed; - - // Try hex without hash - if (/^[A-Fa-f0-9]{6}$|^[A-Fa-f0-9]{3}$/.test(color)) { - return colorString.get('#' + color); - } - - return null; - } - - private toHex(n: number): string { - const hex = n.toString(16); - return hex.length === 1 ? '0' + hex : hex; - } - onColorPickerChange(event: Event) { const target = event.target as HTMLInputElement; const pickerValue = target.value; - // Convert picker value to desired format - const parsed = this.parseColor(pickerValue); + const parsed = this._parseColor(pickerValue); if (parsed) { - const format = this.widgetStructure?.widget_params?.format || 'hex_hash'; - + const format = this.widgetStructure()?.widget_params?.format || 'hex_hash'; const [r, g, b, a] = parsed.value; switch (format) { case 'hex': - this.value = colorString.to.hex(r, g, b, a).slice(1); + this.value.set(colorString.to.hex(r, g, b, a).slice(1)); break; case 'hex_hash': - this.value = colorString.to.hex(r, g, b, a); + this.value.set(colorString.to.hex(r, g, b, a)); break; case 'rgb': - this.value = colorString.to.rgb(r, g, b, a); + this.value.set(colorString.to.rgb(r, g, b, a)); break; case 'hsl': { - // Convert RGB to HSL using built-in conversion const hex = colorString.to.hex(r, g, b, a); const hslParsed = colorString.get.hsl(hex); if (hslParsed) { const [h, s, l, alpha] = hslParsed; - this.value = colorString.to.hsl(h, s, l, alpha); + this.value.set(colorString.to.hsl(h, s, l, alpha)); } else { - this.value = hex; + this.value.set(hex); } break; } default: - this.value = colorString.to.hex(r, g, b, a); + this.value.set(colorString.to.hex(r, g, b, a)); } } else { - this.value = pickerValue; + this.value.set(pickerValue); } - this.onFieldChange.emit(this.value); + this.onFieldChange.emit(this.value()); } onTextInputChange() { - this.onFieldChange.emit(this.value); + this.onFieldChange.emit(this.value()); + } + + private _parseColor(color: string): any { + if (!color) return null; + + const parsed = colorString.get(color); + if (parsed) return parsed; + + if (/^[A-Fa-f0-9]{6}$|^[A-Fa-f0-9]{3}$/.test(color)) { + return colorString.get('#' + color); + } + + return null; + } + + private _toHex(n: number): string { + const hex = n.toString(16); + return hex.length === 1 ? '0' + hex : hex; } } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.html b/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.html index 229239a2e..596070843 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.html @@ -1,12 +1,12 @@ - {{normalizedLabel}} + {{normalizedLabel()}}
@if (selectedCountryFlag() && showFlag()) { {{selectedCountryFlag()}} } @@ -26,4 +26,4 @@ } - + \ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.spec.ts new file mode 100644 index 000000000..e5d856407 --- /dev/null +++ b/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.spec.ts @@ -0,0 +1,113 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { CountryEditComponent } from './country.component'; + +describe('CountryEditComponent', () => { + let component: CountryEditComponent; + let fixture: ComponentFixture; + + const fakeStructure = { + column_name: 'country', + column_default: null, + data_type: 'varchar', + isExcluded: false, + isSearched: false, + auto_increment: false, + allow_null: false, + character_maximum_length: 2, + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CountryEditComponent, BrowserAnimationsModule], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(CountryEditComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should load countries on init', () => { + fixture.componentRef.setInput('structure', fakeStructure as any); + component.ngOnInit(); + expect(component.countries.length).toBeGreaterThan(0); + }); + + it('should sort countries alphabetically', () => { + fixture.componentRef.setInput('structure', fakeStructure as any); + component.ngOnInit(); + const labels = component.countries.map((c) => c.label); + const sorted = [...labels].sort((a, b) => a.localeCompare(b)); + expect(labels).toEqual(sorted); + }); + + it('should prepend null option when allow_null is true', () => { + fixture.componentRef.setInput('structure', { ...fakeStructure, allow_null: true } as any); + component.ngOnInit(); + expect(component.countries[0].value).toBeNull(); + expect(component.countries[0].label).toBe(''); + }); + + it('should not prepend null option when allow_null is false', () => { + fixture.componentRef.setInput('structure', fakeStructure as any); + component.ngOnInit(); + expect(component.countries[0].value).not.toBeNull(); + }); + + it('should set initial value when value matches a country code', () => { + fixture.componentRef.setInput('value', 'US'); + fixture.componentRef.setInput('structure', fakeStructure as any); + component.ngOnInit(); + const controlValue = component.countryControl.value; + expect(controlValue).toBeTruthy(); + expect(typeof controlValue).toBe('object'); + expect((controlValue as any).value).toBe('US'); + }); + + it('should not set control value when value does not match any country', () => { + fixture.componentRef.setInput('value', 'XX'); + fixture.componentRef.setInput('structure', fakeStructure as any); + component.ngOnInit(); + const controlValue = component.countryControl.value; + expect(controlValue).toBe(''); + }); + + it('should emit onFieldChange when a country is selected', () => { + vi.spyOn(component.onFieldChange, 'emit'); + const country = { value: 'FR', label: 'France', flag: '🇫🇷' }; + component.onCountrySelected(country); + expect(component.value()).toBe('FR'); + expect(component.onFieldChange.emit).toHaveBeenCalledWith('FR'); + }); + + it('should display label for country object in displayFn', () => { + const result = component.displayFn({ value: 'US', label: 'United States', flag: '🇺🇸' }); + expect(result).toBe('United States'); + }); + + it('should return string as-is in displayFn', () => { + const result = component.displayFn('some text'); + expect(result).toBe('some text'); + }); + + it('should return empty string for falsy input in displayFn', () => { + const result = component.displayFn(null); + expect(result).toBe(''); + }); + + it('should filter countries by label', () => { + fixture.componentRef.setInput('structure', fakeStructure as any); + component.ngOnInit(); + component.countryControl.setValue('united'); + const filtered = component.filteredCountries(); + expect(filtered.length).toBeGreaterThan(0); + filtered.forEach((c) => { + expect(c.label.toLowerCase() + c.value?.toLowerCase()).toContain('united'); + }); + }); +}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.ts index 6ba610a7b..1a1b62c53 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, CUSTOM_ELEMENTS_SCHEMA, computed, Input } from '@angular/core'; +import { Component, CUSTOM_ELEMENTS_SCHEMA, computed, model } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; @@ -22,7 +22,7 @@ interface CountryOption { schemas: [CUSTOM_ELEMENTS_SCHEMA], }) export class CountryEditComponent extends BaseEditFieldComponent { - @Input() value: string; + readonly value = model(); public countries: CountryOption[] = []; public countryControl = new FormControl(''); @@ -35,12 +35,13 @@ export class CountryEditComponent extends BaseEditFieldComponent { }); public showFlag = computed(() => { - if (this.widgetStructure?.widget_params) { + const ws = this.widgetStructure(); + if (ws?.widget_params) { try { const params = - typeof this.widgetStructure.widget_params === 'string' - ? JSON.parse(this.widgetStructure.widget_params) - : this.widgetStructure.widget_params; + typeof ws.widget_params === 'string' + ? JSON.parse(ws.widget_params) + : ws.widget_params; if (params.show_flag !== undefined) { return params.show_flag; @@ -72,8 +73,8 @@ export class CountryEditComponent extends BaseEditFieldComponent { } onCountrySelected(selectedCountry: CountryOption): void { - this.value = selectedCountry.value; - this.onFieldChange.emit(this.value); + this.value.set(selectedCountry.value); + this.onFieldChange.emit(this.value()); } displayFn(country: CountryOption | string): string { @@ -82,8 +83,8 @@ export class CountryEditComponent extends BaseEditFieldComponent { } private setInitialValue(): void { - if (this.value) { - const country = this.countries.find((c) => c.value === this.value); + if (this.value()) { + const country = this.countries.find((c) => c.value === this.value()); if (country) { this.countryControl.setValue(country); } @@ -105,7 +106,8 @@ export class CountryEditComponent extends BaseEditFieldComponent { flag: getCountryFlag(country.code), })).toSorted((a, b) => a.label.localeCompare(b.label)); - if (this.widgetStructure?.widget_params?.allow_null || this.structure?.allow_null) { + const ws = this.widgetStructure(); + if (ws?.widget_params?.allow_null || this.structure()?.allow_null) { this.countries = [{ value: null, label: '', flag: '' }, ...this.countries]; } } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.html b/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.html index 7dac7b2ca..f6708d890 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.html @@ -1,17 +1,17 @@
- {{normalizedLabel}} (date) - {{normalizedLabel()}} (date) + - {{normalizedLabel}} (time) - {{normalizedLabel()}} (time) +
diff --git a/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.spec.ts index b0f4a6440..52f4ba963 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.spec.ts @@ -28,7 +28,7 @@ describe('DateTimeEditComponent', () => { }); it('should prepare date and time for date and time inputs', () => { - component.value = '2021-06-26T07:22:00.603'; + fixture.componentRef.setInput('value', '2021-06-26T07:22:00.603'); component.ngOnInit(); expect(component.date).toEqual('2021-06-26'); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.ts index aa13b4e52..39a9da4c2 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; +import { Component, inject, model } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -15,23 +15,22 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule], }) export class DateTimeEditComponent extends BaseEditFieldComponent { - @Input() value: string; + readonly value = model(); static type = 'datetime'; public date: string; public time: string; public connectionType: DBtype; - constructor(private _connections: ConnectionsService) { - super(); - } + private _connections = inject(ConnectionsService); ngOnInit(): void { super.ngOnInit(); this.connectionType = this._connections.currentConnection.type; - if (this.value) { - const datetime = new Date(this.value); + const val = this.value(); + if (val) { + const datetime = new Date(val); this.date = format(datetime, 'yyyy-MM-dd'); this.time = format(datetime, 'HH:mm:ss'); } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.html b/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.html index e672cef64..46faec623 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.html @@ -1,7 +1,7 @@ - {{normalizedLabel}} - {{normalizedLabel()}} + diff --git a/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.spec.ts index 75865a1bf..585ebc891 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.spec.ts @@ -23,14 +23,14 @@ describe('DateEditComponent', () => { }); it('should prepare date for date input', () => { - component.value = '2021-06-26T07:22:00.603Z'; + fixture.componentRef.setInput('value', '2021-06-26T07:22:00.603Z'); component.ngOnInit(); expect(component.date).toEqual('2021-06-26'); }); it('should remain date undefined if there is no value', () => { - component.value = null; + fixture.componentRef.setInput('value', null); component.ngOnInit(); expect(component.date).not.toBeDefined(); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.ts index b9313b9a0..bb9624a60 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; +import { Component, model } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -13,15 +13,16 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone imports: [CommonModule, MatFormFieldModule, MatInputModule, FormsModule], }) export class DateEditComponent extends BaseEditFieldComponent { - @Input() value: string; + readonly value = model(); static type = 'datetime'; public date: string; ngOnInit(): void { super.ngOnInit(); - if (this.value) { - const datetime = new Date(this.value); + const val = this.value(); + if (val) { + const datetime = new Date(val); this.date = format(datetime, 'yyyy-MM-dd'); } } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.html b/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.html index 9463ddb32..1e85a3b34 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.html @@ -1,17 +1,16 @@
- {{ normalizedLabel }} + {{ normalizedLabel() }} -
- {{ initError }} - - {{ widgetStructure.widget_params.type }} - -
- - + @if (widgetStructure() && widgetStructure().widget_params.type) { + @if (initError) { + {{ initError }} + } @else { + {{ widgetStructure().widget_params.type }} + } + } @else { @@ -25,43 +24,55 @@ File - + }
- - - Hex - - Invalid hex. - + @if (!initError) { + @if (fileType === 'hex') { + + Hex + + @if (hexContent.errors?.isInvalidHex) { + Invalid hex. + } + + } - - Base64 - - Invalid base64. - + @if (fileType === 'base64') { + + Base64 + + @if (base64Content.errors?.isInvalidBase64 || isNotSwitcherActive) { + Invalid base64. + } + + } -
- + @if (fileType === 'file') { +
+ - {{ value ? 'File is uploaded.' : 'No file uploaded yet.' }} + {{ value() ? 'File is uploaded.' : 'No file uploaded yet.' }} - + - - Download file - -
- -
+ @if (hexData) { + + Download file + + } +
+ } + } +
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.spec.ts index 7371de04c..2189df298 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.spec.ts @@ -17,10 +17,66 @@ describe('FileEditComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(FileEditComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { + fixture.detectChanges(); expect(component).toBeTruthy(); }); + + it('should default fileType to hex', () => { + expect(component.fileType).toBe('hex'); + }); + + it('should set hexData from value on init when no widgetStructure', () => { + fixture.componentRef.setInput('value', '48656c6c6f' as any); + component.ngOnInit(); + expect(component.hexData).toBe('48656c6c6f'); + }); + + it('should set fileType from widgetStructure on init', () => { + fixture.componentRef.setInput('value', '48656c6c6f' as any); + fixture.componentRef.setInput('widgetStructure', { widget_params: { type: 'base64' } } as any); + component.ngOnInit(); + expect(component.fileType).toBe('base64'); + }); + + it('should convert hex to base64', () => { + component.hexData = '48656c6c6f'; + component.fromHexToBase64(); + expect(component.base64Data).toBe('SGVsbG8='); + }); + + it('should convert base64 to hex', () => { + component.base64Data = 'SGVsbG8='; + component.fromBase64ToHex(); + expect(component.hexData).toBe('48656c6c6f'); + }); + + it('should set isNotSwitcherActive to true on invalid base64', () => { + component.base64Data = '!!!invalid!!!'; + component.fromBase64ToHex(); + expect(component.isNotSwitcherActive).toBe(true); + }); + + it('should emit onFieldChange on hex change', () => { + vi.spyOn(component.onFieldChange, 'emit'); + component.hexData = '48656c6c6f'; + component.onHexChange(); + expect(component.onFieldChange.emit).toHaveBeenCalledWith('48656c6c6f'); + }); + + it('should convert base64 to hex and emit on base64 change', () => { + vi.spyOn(component.onFieldChange, 'emit'); + component.base64Data = 'SGVsbG8='; + component.onBase64Change(); + expect(component.hexData).toBe('48656c6c6f'); + expect(component.onFieldChange.emit).toHaveBeenCalledWith('48656c6c6f'); + }); + + it('should call convertValue for base64 when hexData exists', () => { + component.hexData = '48656c6c6f'; + component.convertValue('base64' as any); + expect(component.base64Data).toBe('SGVsbG8='); + }); }); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.ts index e3d7a30e6..b99033392 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.ts @@ -1,5 +1,5 @@ -import { NgIf } from '@angular/common'; -import { Component, Input } from '@angular/core'; + +import { Component, inject, model } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -28,7 +28,6 @@ enum FileType { templateUrl: './file.component.html', styleUrls: ['./file.component.css'], imports: [ - NgIf, FormsModule, MatFormFieldModule, MatInputModule, @@ -39,7 +38,9 @@ enum FileType { ], }) export class FileEditComponent extends BaseEditFieldComponent { - @Input() value: Blob; + readonly value = model(); + + private sanitazer = inject(DomSanitizer); static type = 'file'; public isNotSwitcherActive; @@ -50,24 +51,21 @@ export class FileEditComponent extends BaseEditFieldComponent { public fileURL: SafeUrl; public initError: string | null = null; - constructor(private sanitazer: DomSanitizer) { - super(); - } - ngOnInit(): void { super.ngOnInit(); - if (this.widgetStructure && this.value) { - this.fileType = this.widgetStructure.widget_params.type; + const ws = this.widgetStructure(); + if (ws && this.value()) { + this.fileType = ws.widget_params.type; if (this.fileType === 'hex') { - this.hexData = this.value; + this.hexData = this.value(); //@ts-expect-error this.initError = hexValidation()({ value: this.hexData }); this.initError = 'Invalid hex format.'; } if (this.fileType === 'base64') { - this.base64Data = this.value; + this.base64Data = this.value(); //@ts-expect-error this.initError = base64Validation()({ value: this.hexData }); this.initError = 'Invalid base64 format.'; @@ -75,13 +73,13 @@ export class FileEditComponent extends BaseEditFieldComponent { if (this.fileType === 'file') { //@ts-expect-error - const blob = new Blob([this.value]); + const blob = new Blob([this.value()]); this.fileURL = this.sanitazer.bypassSecurityTrustUrl(URL.createObjectURL(blob)); } } - if (this.value) { - this.hexData = this.value; + if (this.value()) { + this.hexData = this.value(); } } @@ -155,7 +153,6 @@ export class FileEditComponent extends BaseEditFieldComponent { fromFileToHex(reader: FileReader) { let dataString = reader.result as ArrayBuffer; - // let dataStringArray = new Array(dataString.byteLength); this.hexData = [...new Uint8Array(dataString)].map((b) => b.toString(16).padStart(2, '0')).join(''); } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.html b/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.html index 601574eee..65519179d 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.html @@ -1,13 +1,13 @@ - +
- {{normalizedLabel}} + {{normalizedLabel()}} @if (fetching()) { } @@ -21,10 +21,10 @@ } Improve search performance by configuring Searchable foreign key columns  - here + here - @@ -37,6 +37,6 @@
- - - + + + \ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.spec.ts index c8c106c56..0277a802d 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.spec.ts @@ -136,7 +136,7 @@ describe('ForeignKeyEditComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(ForeignKeyEditComponent); component = fixture.componentInstance; - component.relations = fakeRelations; + fixture.componentRef.setInput('relations', fakeRelations); tablesService = TestBed.inject(TablesService); fixture.detectChanges(); }); @@ -151,7 +151,7 @@ describe('ForeignKeyEditComponent', () => { vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetworkWithIdentityColumn)); component.connectionID = '12345678'; - component.value = ''; + fixture.componentRef.setInput('value', ''); await component.ngOnInit(); fixture.detectChanges(); @@ -184,7 +184,7 @@ describe('ForeignKeyEditComponent', () => { component.connectionID = '12345678'; - component.value = ''; + fixture.componentRef.setInput('value', ''); await component.ngOnInit(); fixture.detectChanges(); @@ -216,15 +216,15 @@ describe('ForeignKeyEditComponent', () => { vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetwork)); component.connectionID = '12345678'; - component.relations = { + fixture.componentRef.setInput('relations', { autocomplete_columns: [], column_name: 'userId', constraint_name: '', referenced_column_name: 'id', referenced_table_name: 'users', column_default: '', - }; - component.value = ''; + }); + fixture.componentRef.setInput('value', ''); await component.ngOnInit(); fixture.detectChanges(); @@ -301,7 +301,7 @@ describe('ForeignKeyEditComponent', () => { vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(searchSuggestionsNetwork)); - component.relations = fakeRelations; + fixture.componentRef.setInput('relations', fakeRelations); component.suggestions.set([ { @@ -393,7 +393,7 @@ describe('ForeignKeyEditComponent', () => { const fakeFetchTable = vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(searchSuggestionsNetwork)); component.connectionID = '12345678'; - component.relations = fakeRelations; + fixture.componentRef.setInput('relations', fakeRelations); component.suggestions.set([ { @@ -417,12 +417,12 @@ describe('ForeignKeyEditComponent', () => { expect(fakeFetchTable).toHaveBeenCalledWith({ connectionID: '12345678', - tableName: component.relations.referenced_table_name, + tableName: component.relations().referenced_table_name, requstedPage: 1, chunkSize: 20, foreignKeyRowName: 'autocomplete', foreignKeyRowValue: component.currentDisplayedString, - referencedColumn: component.relations.referenced_column_name, + referencedColumn: component.relations().referenced_column_name, }); expect(component.suggestions()).toEqual([ diff --git a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.ts index 9ac292260..0ba4e4d31 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, Input, signal } from '@angular/core'; +import { Component, inject, model, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -43,7 +43,10 @@ interface Suggestion { ], }) export class ForeignKeyEditComponent extends BaseEditFieldComponent { - @Input() value; + readonly value = model(); + + private _tables = inject(TablesService); + private _connections = inject(ConnectionsService); public connectionID: string; public currentDisplayedString: string; @@ -55,23 +58,18 @@ export class ForeignKeyEditComponent extends BaseEditFieldComponent { public primaeyKeys: { data_type: string; column_name: string }[]; public fkRelations: TableForeignKey = null; - private _debounceTimer: ReturnType; - constructor( - private _tables: TablesService, - private _connections: ConnectionsService, - ) { - super(); - } + private _debounceTimer: ReturnType; async ngOnInit(): Promise { super.ngOnInit(); this.connectionID = this._connections.currentConnectionID; - if (this.widgetStructure?.widget_params) { - this.fkRelations = this.widgetStructure.widget_params as TableForeignKey; - } else if (this.relations) { - this.fkRelations = this.relations; + const ws = this.widgetStructure(); + if (ws?.widget_params) { + this.fkRelations = ws.widget_params as TableForeignKey; + } else if (this.relations()) { + this.fkRelations = this.relations(); } if (this.fkRelations) { @@ -83,14 +81,14 @@ export class ForeignKeyEditComponent extends BaseEditFieldComponent { requstedPage: 1, chunkSize: 10, foreignKeyRowName: this.fkRelations.referenced_column_name, - foreignKeyRowValue: this.value, + foreignKeyRowValue: this.value(), }), )) as FetchTableResponse; if (res.rows.length) { this.identityColumn = res.identity_column; const modifiedRow = this.getModifiedRow(res.rows[0]); - if (this.value) { + if (this.value()) { this.currentDisplayedString = this.identityColumn ? `${res.rows[0][this.identityColumn]} (${Object.values(modifiedRow) .filter((value) => value) diff --git a/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.html b/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.html index 58a206351..8b0a93f85 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.html @@ -1,10 +1,12 @@ - {{normalizedLabel}} - {{normalizedLabel()}} + - Value doesn't match pattern. + @if (idField.errors?.pattern) { + Value doesn't match pattern. + } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.ts index 343442eec..27bfe59f6 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; +import { Component, model } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -12,5 +12,5 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule], }) export class IdEditComponent extends BaseEditFieldComponent { - @Input() value: string; + readonly value = model(); } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.html b/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.html index f6c3ce76e..6912ae595 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.html @@ -1,14 +1,20 @@
- {{normalizedLabel}} - {{prefix}} - {{normalizedLabel()}} + @if (prefix) { + {{prefix}} + } + - URL is invalid. + @if (image.errors?.isInvalidURL) { + URL is invalid. + } - + @if (!image.errors?.isInvalidURL) { + + }
diff --git a/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.spec.ts index f07eebb22..ca2d5a45e 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.spec.ts @@ -14,10 +14,59 @@ describe('ImageComponent', () => { fixture = TestBed.createComponent(ImageEditComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should have empty prefix by default', () => { + expect(component.prefix).toBe(''); + }); + + it('should parse prefix from widget params object', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: { prefix: 'https://cdn.example.com/' } } as any); + component.ngOnInit(); + expect(component.prefix).toBe('https://cdn.example.com/'); + }); + + it('should parse prefix from widget params string', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: JSON.stringify({ prefix: 'https://images.test/' }) } as any); + component.ngOnInit(); + expect(component.prefix).toBe('https://images.test/'); + }); + + it('should keep empty prefix when widget params have no prefix', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: {} } as any); + component.ngOnInit(); + expect(component.prefix).toBe(''); + }); + + it('should update prefix on ngOnChanges', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: { prefix: 'https://cdn.test/' } } as any); + component.ngOnChanges(); + expect(component.prefix).toBe('https://cdn.test/'); + }); + + it('should return empty string for imageUrl when value is empty', () => { + fixture.componentRef.setInput('value', ''); + expect(component.imageUrl).toBe(''); + }); + + it('should return value without prefix when prefix is empty', () => { + fixture.componentRef.setInput('value', 'photo.jpg'); + expect(component.imageUrl).toBe('photo.jpg'); + }); + + it('should return prefix + value for imageUrl', () => { + component.prefix = 'https://cdn.example.com/'; + fixture.componentRef.setInput('value', 'photo.jpg'); + expect(component.imageUrl).toBe('https://cdn.example.com/photo.jpg'); + }); + + it('should handle invalid JSON in widget params gracefully', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: 'invalid-json' } as any); + component.ngOnInit(); + expect(component.prefix).toBe(''); + }); }); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.ts index 7b544baea..6cb24b5e1 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, Input, OnInit } from '@angular/core'; +import { Component, computed, model, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -13,7 +13,7 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule, UrlValidatorDirective], }) export class ImageEditComponent extends BaseEditFieldComponent implements OnInit { - @Input() value: string; + readonly value = model(); public prefix: string = ''; ngOnInit(): void { @@ -25,13 +25,17 @@ export class ImageEditComponent extends BaseEditFieldComponent implements OnInit this._parseWidgetParams(); } + get imageUrl(): string { + const val = this.value(); + if (!val) return ''; + return this.prefix + val; + } + private _parseWidgetParams(): void { - if (this.widgetStructure?.widget_params) { + const ws = this.widgetStructure(); + if (ws?.widget_params) { try { - const params = - typeof this.widgetStructure.widget_params === 'string' - ? JSON.parse(this.widgetStructure.widget_params) - : this.widgetStructure.widget_params; + const params = typeof ws.widget_params === 'string' ? JSON.parse(ws.widget_params) : ws.widget_params; if (params.prefix !== undefined) { this.prefix = params.prefix || ''; @@ -41,9 +45,4 @@ export class ImageEditComponent extends BaseEditFieldComponent implements OnInit } } } - - get imageUrl(): string { - if (!this.value) return ''; - return this.prefix + this.value; - } } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.html b/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.html index 115648820..6dce1ee7a 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.html @@ -1,11 +1,11 @@ -{{ normalizedLabel }} {{ required ? '*' : '' }} +{{ normalizedLabel() }} {{ required() ? '*' : '' }}
-
+
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.spec.ts index a8970dd7a..0cd651222 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.spec.ts @@ -29,8 +29,8 @@ describe('JsonEditorEditComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(JsonEditorEditComponent); component = fixture.componentInstance; - component.label = 'metadata'; - component.value = { id: 1, name: 'test', settings: { enabled: true } }; + fixture.componentRef.setInput('label', 'metadata'); + fixture.componentRef.setInput('value', { id: 1, name: 'test', settings: { enabled: true } }); fixture.detectChanges(); }); @@ -62,21 +62,21 @@ describe('JsonEditorEditComponent', () => { }); it('should handle null value', () => { - component.value = null; + fixture.componentRef.setInput('value', null); component.ngOnInit(); // JSON.stringify(null) returns "null", fallback to '{}' only for undefined expect((component.mutableCodeModel as any).value).toBe('null'); }); it('should handle undefined value with fallback', () => { - component.value = undefined; + fixture.componentRef.setInput('value', undefined); component.ngOnInit(); // undefined is falsy so falls back to '{}' expect((component.mutableCodeModel as any).value).toBe('{}'); }); it('should handle array value', () => { - component.value = [1, 2, 3] as any; + fixture.componentRef.setInput('value', [1, 2, 3] as any); component.ngOnInit(); const modelValue = (component.mutableCodeModel as any).value; expect(modelValue).toContain('1'); @@ -85,8 +85,8 @@ describe('JsonEditorEditComponent', () => { }); it('should normalize label from base class', () => { - component.label = 'json_config_data'; + fixture.componentRef.setInput('label', 'json_config_data'); component.ngOnInit(); - expect(component.normalizedLabel).toBeDefined(); + expect(component.normalizedLabel()).toBeDefined(); }); }); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.ts index c1c1e9caf..093a6c5f1 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; +import { Component, inject, model } from '@angular/core'; import { CodeEditorModule } from '@ngstack/code-editor'; import { UiSettingsService } from 'src/app/services/ui-settings.service'; import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; @@ -11,7 +11,9 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone imports: [CommonModule, CodeEditorModule], }) export class JsonEditorEditComponent extends BaseEditFieldComponent { - @Input() value: Object; + readonly value = model(); + + private _uiSettings = inject(UiSettingsService); public mutableCodeModel: Object; public codeEditorOptions = { @@ -22,16 +24,12 @@ export class JsonEditorEditComponent extends BaseEditFieldComponent { }; public codeEditorTheme = 'vs-dark'; - constructor(private _uiSettings: UiSettingsService) { - super(); - } - ngOnInit(): void { super.ngOnInit(); this.mutableCodeModel = { language: 'json', - uri: `${this.label}.json`, - value: JSON.stringify(this.value, undefined, 4) || '{}', + uri: `${this.label()}.json`, + value: JSON.stringify(this.value(), undefined, 4) || '{}', }; this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs'; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.html b/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.html index 5992b3971..7ac207d5e 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.html @@ -1,6 +1,6 @@ - {{normalizedLabel}} - -
{{value.length}} / {{maxLength}}
- This field is required. - Maximum length is {{maxLength}} characters. - {{getValidationErrorMessage()}} + @if (maxLength && maxLength > 0 && value() && (maxLength - value().length) < 100) { +
{{value().length}} / {{maxLength}}
+ } + @if (textareaField.errors?.['required']) { + This field is required. + } + @if (textareaField.errors?.['maxlength']) { + Maximum length is {{maxLength}} characters. + } + @if (textareaField.errors?.['invalidPattern'] || textareaField.errors?.[('invalid' + validateType)]) { + {{getValidationErrorMessage()}} + }
diff --git a/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.spec.ts index fa17154ce..c503cc958 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.spec.ts @@ -15,10 +15,71 @@ describe('LongTextEditComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(LongTextEditComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should set maxLength from structure character_maximum_length', () => { + fixture.componentRef.setInput('structure', { character_maximum_length: 1000 } as any); + component.ngOnInit(); + expect(component.maxLength).toBe(1000); + }); + + it('should keep maxLength null when structure has no character_maximum_length', () => { + fixture.componentRef.setInput('structure', {} as any); + component.ngOnInit(); + expect(component.maxLength).toBeNull(); + }); + + it('should default rowsCount to 4 when no widget params', () => { + component.ngOnInit(); + expect(component.rowsCount).toBe('4'); + }); + + it('should parse rowsCount from widget params', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: { rows: '10' } } as any); + component.ngOnInit(); + expect(component.rowsCount).toBe('10'); + }); + + it('should parse validateType from widget params object', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: { validate: 'isJSON' } } as any); + component.ngOnInit(); + expect(component.validateType).toBe('isJSON'); + }); + + it('should parse validateType from widget params string', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: JSON.stringify({ validate: 'isEmail', rows: '6' }) } as any); + component.ngOnInit(); + expect(component.validateType).toBe('isEmail'); + expect(component.rowsCount).toBe('6'); + }); + + it('should parse regexPattern from widget params', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: { validate: 'regex', regex: '^\\d+$' } } as any); + component.ngOnInit(); + expect(component.regexPattern).toBe('^\\d+$'); + }); + + it('should return empty string for getValidationErrorMessage when no validateType', () => { + component.validateType = null; + expect(component.getValidationErrorMessage()).toBe(''); + }); + + it('should return regex message for regex validateType', () => { + component.validateType = 'regex'; + expect(component.getValidationErrorMessage()).toBe("Value doesn't match the required pattern"); + }); + + it('should return correct message for known validateType', () => { + component.validateType = 'isIP'; + expect(component.getValidationErrorMessage()).toBe('Invalid IP address'); + }); + + it('should return fallback message for unknown validateType', () => { + component.validateType = 'customRule'; + expect(component.getValidationErrorMessage()).toBe('Invalid customRule'); + }); }); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.ts index de4aac234..2be2040e8 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, Input, OnInit } from '@angular/core'; +import { Component, model, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -13,7 +13,7 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone imports: [CommonModule, MatFormFieldModule, MatInputModule, FormsModule, TextValidatorDirective], }) export class LongTextEditComponent extends BaseEditFieldComponent implements OnInit { - @Input() value: string; + readonly value = model(); static type = 'text'; public rowsCount: string; @@ -24,17 +24,14 @@ export class LongTextEditComponent extends BaseEditFieldComponent implements OnI override ngOnInit(): void { super.ngOnInit(); - // Use character_maximum_length from the field structure if available - if (this.structure?.character_maximum_length) { - this.maxLength = this.structure.character_maximum_length; + const struct = this.structure(); + if (struct?.character_maximum_length) { + this.maxLength = struct.character_maximum_length; } - // Parse widget parameters - if (this.widgetStructure?.widget_params) { - const params = - typeof this.widgetStructure.widget_params === 'string' - ? JSON.parse(this.widgetStructure.widget_params) - : this.widgetStructure.widget_params; + const ws = this.widgetStructure(); + if (ws?.widget_params) { + const params = typeof ws.widget_params === 'string' ? JSON.parse(ws.widget_params) : ws.widget_params; this.rowsCount = params.rows || '4'; this.validateType = params.validate || null; @@ -53,7 +50,6 @@ export class LongTextEditComponent extends BaseEditFieldComponent implements OnI return "Value doesn't match the required pattern"; } - // Create user-friendly messages for common validators const messages = { isEmail: 'Invalid email address', isURL: 'Invalid URL', diff --git a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.html b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.html index 115648820..6dce1ee7a 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.html @@ -1,11 +1,11 @@ -{{ normalizedLabel }} {{ required ? '*' : '' }} +{{ normalizedLabel() }} {{ required() ? '*' : '' }}
-
+ \ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.spec.ts index 8db02d691..33a653f03 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.spec.ts @@ -29,11 +29,11 @@ describe('MarkdownEditComponent', () => { fixture = TestBed.createComponent(MarkdownEditComponent); component = fixture.componentInstance; - component.widgetStructure = { + fixture.componentRef.setInput('widgetStructure', { widget_params: {}, - } as any; - component.label = 'description'; - component.value = '# Hello World\n\nThis is **bold** text.'; + } as any); + fixture.componentRef.setInput('label', 'description'); + fixture.componentRef.setInput('value', '# Hello World\n\nThis is **bold** text.'); fixture.detectChanges(); }); @@ -81,17 +81,17 @@ describe('MarkdownEditComponent', () => { const newFixture = TestBed.createComponent(MarkdownEditComponent); const newComponent = newFixture.componentInstance; - newComponent.widgetStructure = { widget_params: {} } as any; - newComponent.label = 'content'; - newComponent.value = 'test'; + newFixture.componentRef.setInput('widgetStructure', { widget_params: {} } as any); + newFixture.componentRef.setInput('label', 'content'); + newFixture.componentRef.setInput('value', 'test'); newFixture.detectChanges(); expect(newComponent.codeEditorTheme).toBe('vs'); }); it('should normalize label from base class', () => { - component.label = 'product_description'; + fixture.componentRef.setInput('label', 'product_description'); component.ngOnInit(); - expect(component.normalizedLabel).toBeDefined(); + expect(component.normalizedLabel()).toBeDefined(); }); }); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.ts index 1e78e7df8..a64e0c41b 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; +import { Component, inject, model } from '@angular/core'; import { CodeEditorModule } from '@ngstack/code-editor'; import { UiSettingsService } from 'src/app/services/ui-settings.service'; import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; @@ -11,7 +11,9 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone imports: [CommonModule, CodeEditorModule], }) export class MarkdownEditComponent extends BaseEditFieldComponent { - @Input() value; + readonly value = model(); + + private _uiSettings = inject(UiSettingsService); public mutableCodeModel: Object; public codeEditorOptions = { @@ -22,16 +24,12 @@ export class MarkdownEditComponent extends BaseEditFieldComponent { }; public codeEditorTheme = 'vs-dark'; - constructor(private _uiSettings: UiSettingsService) { - super(); - } - ngOnInit(): void { super.ngOnInit(); this.mutableCodeModel = { language: 'markdown', - uri: `${this.label}.md`, - value: this.value, + uri: `${this.label()}.md`, + value: this.value(), }; this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs'; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.html b/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.html index 20116f2e0..accbb98e5 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.html @@ -1,41 +1,44 @@
- - Currency - - - {{ displayCurrencyFn(currency) }} - - - + @if (showCurrencySelector) { + + Currency + + @for (currency of currencies; track currency.code) { + + {{ displayCurrencyFn(currency) }} + + } + + + } - - {{normalizedLabel}} - {{selectedCurrencyData.symbol}} - {{normalizedLabel()}} + @if (selectedCurrencyData && displayAmount) { + {{selectedCurrencyData.symbol}} + } +
diff --git a/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.spec.ts index 9794d502e..ef047acc4 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.spec.ts @@ -29,10 +29,10 @@ describe('MoneyEditComponent', () => { component = fixture.componentInstance; // Set required properties from base component - component.label = 'Test Money'; - component.required = false; - component.disabled = false; - component.readonly = false; + fixture.componentRef.setInput('label', 'Test Money'); + fixture.componentRef.setInput('required', false); + fixture.componentRef.setInput('disabled', false); + fixture.componentRef.setInput('readonly', false); fixture.detectChanges(); }); @@ -48,21 +48,21 @@ describe('MoneyEditComponent', () => { }); it('should parse string value correctly', () => { - component.value = '100.50 EUR'; + fixture.componentRef.setInput('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' }; + fixture.componentRef.setInput('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; + fixture.componentRef.setInput('value', 150.25); component.ngOnInit(); expect(component.selectedCurrency).toBe('USD'); expect(component.amount).toBe(150.25); @@ -70,7 +70,7 @@ describe('MoneyEditComponent', () => { }); it('should handle empty value', () => { - component.value = ''; + fixture.componentRef.setInput('value', ''); component.ngOnInit(); expect(component.selectedCurrency).toBe('USD'); expect(component.amount).toBe(''); @@ -162,7 +162,7 @@ describe('MoneyEditComponent', () => { }); it('should configure from widget params', () => { - component.widgetStructure = { + fixture.componentRef.setInput('widgetStructure', { field_name: 'test_field', widget_type: 'Money', name: 'Test Widget', @@ -173,7 +173,7 @@ describe('MoneyEditComponent', () => { decimal_places: 3, allow_negative: false, }, - }; + }); component.configureFromWidgetParams(); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.ts index 91b626004..78dc21504 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, Input, OnInit } from '@angular/core'; +import { Component, model, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -14,7 +14,7 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone imports: [CommonModule, MatFormFieldModule, MatInputModule, MatSelectModule, FormsModule], }) export class MoneyEditComponent extends BaseEditFieldComponent implements OnInit { - @Input() value: string | number | MoneyValue = ''; + readonly value = model(''); static type = 'money'; @@ -36,8 +36,9 @@ export class MoneyEditComponent extends BaseEditFieldComponent implements OnInit } configureFromWidgetParams(): void { - if (this.widgetStructure?.widget_params) { - const params = this.widgetStructure.widget_params; + const ws = this.widgetStructure(); + if (ws?.widget_params) { + const params = ws.widget_params; if (typeof params.default_currency === 'string') { this.defaultCurrency = params.default_currency; @@ -58,16 +59,17 @@ export class MoneyEditComponent extends BaseEditFieldComponent implements OnInit } 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; + const currentValue = this.value(); + if (currentValue) { + if (typeof currentValue === 'string') { + this.parseStringValue(currentValue); + } else if (typeof currentValue === 'object' && (currentValue as MoneyValue).amount !== undefined && (currentValue as MoneyValue).currency) { + this.amount = (currentValue as MoneyValue).amount; + this.selectedCurrency = (currentValue as MoneyValue).currency; this.displayAmount = this.formatAmount(this.amount); - } else if (typeof this.value === 'number') { + } else if (typeof currentValue === 'number') { // Handle numeric values when currency selector is disabled - this.amount = this.value; + this.amount = currentValue; this.selectedCurrency = this.defaultCurrency; this.displayAmount = this.formatAmount(this.amount); } @@ -161,21 +163,21 @@ export class MoneyEditComponent extends BaseEditFieldComponent implements OnInit public updateValue(): void { if (this.amount === '' || this.amount === null || this.amount === undefined) { - this.value = ''; + this.value.set(''); } else { if (this.showCurrencySelector) { // Store as object with amount and currency when selector is enabled - this.value = { + this.value.set({ 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.value.set(typeof this.amount === 'string' ? parseFloat(this.amount) || 0 : this.amount); } } - this.onFieldChange.emit(this.value); + this.onFieldChange.emit(this.value()); } get selectedCurrencyData(): Money { diff --git a/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.html b/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.html index 9511a12cc..fac966063 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.html @@ -1,7 +1,7 @@ - {{normalizedLabel}} - {{normalizedLabel()}} + diff --git a/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.ts index 269406436..e29c7c9de 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.ts @@ -1,4 +1,4 @@ -import { Component, Input } from '@angular/core'; +import { Component, model } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -11,7 +11,7 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone imports: [MatFormFieldModule, MatInputModule, FormsModule], }) export class NumberEditComponent extends BaseEditFieldComponent { - @Input() value: number; + readonly value = model(); static type = 'number'; } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.html b/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.html index 040618f8d..f504a012a 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.html @@ -1,12 +1,12 @@ - {{normalizedLabel}} - {{normalizedLabel()}} + To keep password the same keep this field blank. Clear password diff --git a/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.spec.ts index 91043c250..c4d097039 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.spec.ts @@ -31,28 +31,28 @@ describe('PasswordEditComponent', () => { describe('ngOnInit', () => { it('should reset masked password value to empty string', () => { - component.value = '***'; + fixture.componentRef.setInput('value', '***'); component.ngOnInit(); - expect(component.value).toBe(''); + expect(component.value()).toBe(''); }); it('should not emit onFieldChange when password is masked (empty after reset)', () => { const event = vi.spyOn(component.onFieldChange, 'emit'); - component.value = '***'; + fixture.componentRef.setInput('value', '***'); component.ngOnInit(); expect(event).not.toHaveBeenCalled(); }); it('should emit onFieldChange when password has actual value', () => { const event = vi.spyOn(component.onFieldChange, 'emit'); - component.value = 'actualPassword'; + fixture.componentRef.setInput('value', 'actualPassword'); component.ngOnInit(); expect(event).toHaveBeenCalledWith('actualPassword'); }); it('should not emit onFieldChange when password is empty string', () => { const event = vi.spyOn(component.onFieldChange, 'emit'); - component.value = ''; + fixture.componentRef.setInput('value', ''); component.ngOnInit(); expect(event).not.toHaveBeenCalled(); }); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.ts index 634a3a70b..361ab3239 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.ts @@ -1,4 +1,4 @@ -import { Component, Input } from '@angular/core'; +import { Component, model } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -12,21 +12,19 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone imports: [MatFormFieldModule, MatInputModule, MatCheckboxModule, FormsModule], }) export class PasswordEditComponent extends BaseEditFieldComponent { - @Input() value: string; + readonly value = model(); public clearPassword: boolean; ngOnInit(): void { super.ngOnInit(); - if (this.value === '***') this.value = ''; - // Don't emit empty password value to skip sending it to backend - if (this.value !== '') { - this.onFieldChange.emit(this.value); + if (this.value() === '***') this.value.set(''); + if (this.value() !== '') { + this.onFieldChange.emit(this.value()); } } onPasswordChange(newValue: string) { - // Only emit non-empty values to prevent sending empty strings to backend if (newValue !== '') { this.onFieldChange.emit(newValue); } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.html b/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.html index 1dbf20da7..fc1aca099 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.html @@ -6,8 +6,8 @@ matInput [formControl]="countryControl" [matAutocomplete]="countryAutocomplete" - [readonly]="readonly" - [disabled]="disabled" + [readonly]="readonly()" + [disabled]="disabled()" placeholder="Search country..."> - {{normalizedLabel}} + {{normalizedLabel()}} + attr.data-testid="record-{{label()}}-phone"> @if (selectedCountry && !displayPhoneNumber.startsWith('+')) { Example: {{getExamplePhoneNumber()}} } @@ -50,4 +50,4 @@ } -
+ \ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.spec.ts index d5254ea04..c57ca7064 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.spec.ts @@ -32,8 +32,8 @@ describe('PhoneEditComponent', () => { component = fixture.componentInstance; // Set basic required properties - component.label = 'Phone'; - component.key = 'phone'; + fixture.componentRef.setInput('label', 'Phone'); + fixture.componentRef.setInput('key', 'phone'); fixture.detectChanges(); }); @@ -57,7 +57,7 @@ describe('PhoneEditComponent', () => { component.onPhoneNumberChange(); - expect(component.value).toBe('+12024561111'); + expect(component.value()).toBe('+12024561111'); }); it('should format US phone number in E164 format when user enters raw digits', () => { @@ -66,7 +66,7 @@ describe('PhoneEditComponent', () => { component.onPhoneNumberChange(); - expect(component.value).toBe('+12024561111'); + expect(component.value()).toBe('+12024561111'); }); it('should handle US phone number with different formatting', () => { @@ -75,7 +75,7 @@ describe('PhoneEditComponent', () => { component.onPhoneNumberChange(); - expect(component.value).toBe('+12024561111'); + expect(component.value()).toBe('+12024561111'); }); it('should handle US phone number with country code already included', () => { @@ -84,7 +84,7 @@ describe('PhoneEditComponent', () => { component.onPhoneNumberChange(); - expect(component.value).toBe('+12024561111'); + expect(component.value()).toBe('+12024561111'); }); it('should not format invalid US phone number', () => { @@ -94,7 +94,7 @@ describe('PhoneEditComponent', () => { component.onPhoneNumberChange(); // Should either be empty or the cleaned input, but not a malformed international number - expect(component.value).not.toMatch(/^\+1123$/); + expect(component.value()).not.toMatch(/^\+1123$/); }); }); @@ -110,7 +110,7 @@ describe('PhoneEditComponent', () => { component.onPhoneNumberChange(); - expect(component.value).toBe('+442079460958'); + expect(component.value()).toBe('+442079460958'); }); it('should format German phone number in E164 format', () => { @@ -124,7 +124,7 @@ describe('PhoneEditComponent', () => { component.onPhoneNumberChange(); - expect(component.value).toBe('+493012345678'); + expect(component.value()).toBe('+493012345678'); }); }); @@ -285,19 +285,19 @@ describe('PhoneEditComponent', () => { component.onPhoneNumberChange(); - expect(component.onFieldChange.emit).toHaveBeenCalledWith(component.value); + expect(component.onFieldChange.emit).toHaveBeenCalledWith(component.value()); }); }); describe('Widget Configuration', () => { it('should configure from widget params', () => { - component.widgetStructure = { + fixture.componentRef.setInput('widgetStructure', { widget_params: { preferred_countries: ['CA', 'GB'], enable_placeholder: false, phone_validation: false, }, - } as Partial as WidgetStructure; + } as Partial as WidgetStructure); component.configureFromWidgetParams(); @@ -307,7 +307,7 @@ describe('PhoneEditComponent', () => { }); it('should handle missing widget params', () => { - component.widgetStructure = {} as Partial as WidgetStructure; + fixture.componentRef.setInput('widgetStructure', {} as Partial as WidgetStructure); expect(() => component.configureFromWidgetParams()).not.toThrow(); }); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.ts index 15d764adb..d07321b22 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, computed, Input, OnInit } from '@angular/core'; +import { Component, computed, model, OnInit } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; @@ -31,7 +31,7 @@ export interface CountryCode { ], }) export class PhoneEditComponent extends BaseEditFieldComponent implements OnInit { - @Input() value: string = ''; + readonly value = model(''); static type = 'phone'; @@ -61,250 +61,130 @@ export class PhoneEditComponent extends BaseEditFieldComponent implements OnInit }); countries: CountryCode[] = [ - { code: 'AF', name: 'Afghanistan', dialCode: '+93', flag: '🇦🇫' }, - { code: 'AL', name: 'Albania', dialCode: '+355', flag: '🇦🇱' }, - { code: 'DZ', name: 'Algeria', dialCode: '+213', flag: '🇩🇿' }, - { code: 'AS', name: 'American Samoa', dialCode: '+1684', flag: '🇦🇸' }, - { code: 'AD', name: 'Andorra', dialCode: '+376', flag: '🇦🇩' }, - { code: 'AO', name: 'Angola', dialCode: '+244', flag: '🇦🇴' }, - { code: 'AI', name: 'Anguilla', dialCode: '+1264', flag: '🇦🇮' }, - { code: 'AQ', name: 'Antarctica', dialCode: '+672', flag: '🇦🇶' }, - { code: 'AG', name: 'Antigua and Barbuda', dialCode: '+1268', flag: '🇦🇬' }, - { code: 'AR', name: 'Argentina', dialCode: '+54', flag: '🇦🇷' }, - { code: 'AM', name: 'Armenia', dialCode: '+374', flag: '🇦🇲' }, - { code: 'AW', name: 'Aruba', dialCode: '+297', flag: '🇦🇼' }, - { code: 'AU', name: 'Australia', dialCode: '+61', flag: '🇦🇺' }, - { code: 'AT', name: 'Austria', dialCode: '+43', flag: '🇦🇹' }, - { code: 'AZ', name: 'Azerbaijan', dialCode: '+994', flag: '🇦🇿' }, - { code: 'BS', name: 'Bahamas', dialCode: '+1242', flag: '🇧🇸' }, - { code: 'BH', name: 'Bahrain', dialCode: '+973', flag: '🇧🇭' }, - { code: 'BD', name: 'Bangladesh', dialCode: '+880', flag: '🇧🇩' }, - { code: 'BB', name: 'Barbados', dialCode: '+1246', flag: '🇧🇧' }, - { code: 'BY', name: 'Belarus', dialCode: '+375', flag: '🇧🇾' }, - { code: 'BE', name: 'Belgium', dialCode: '+32', flag: '🇧🇪' }, - { code: 'BZ', name: 'Belize', dialCode: '+501', flag: '🇧🇿' }, - { code: 'BJ', name: 'Benin', dialCode: '+229', flag: '🇧🇯' }, - { code: 'BM', name: 'Bermuda', dialCode: '+1441', flag: '🇧🇲' }, - { code: 'BT', name: 'Bhutan', dialCode: '+975', flag: '🇧🇹' }, - { code: 'BO', name: 'Bolivia', dialCode: '+591', flag: '🇧🇴' }, - { code: 'BA', name: 'Bosnia and Herzegovina', dialCode: '+387', flag: '🇧🇦' }, - { code: 'BW', name: 'Botswana', dialCode: '+267', flag: '🇧🇼' }, - { code: 'BR', name: 'Brazil', dialCode: '+55', flag: '🇧🇷' }, - { code: 'IO', name: 'British Indian Ocean Territory', dialCode: '+246', flag: '🇮🇴' }, - { code: 'BN', name: 'Brunei', dialCode: '+673', flag: '🇧🇳' }, - { code: 'BG', name: 'Bulgaria', dialCode: '+359', flag: '🇧🇬' }, - { code: 'BF', name: 'Burkina Faso', dialCode: '+226', flag: '🇧🇫' }, - { code: 'BI', name: 'Burundi', dialCode: '+257', flag: '🇧🇮' }, - { code: 'KH', name: 'Cambodia', dialCode: '+855', flag: '🇰🇭' }, - { code: 'CM', name: 'Cameroon', dialCode: '+237', flag: '🇨🇲' }, - { code: 'CA', name: 'Canada', dialCode: '+1', flag: '🇨🇦' }, - { code: 'CV', name: 'Cape Verde', dialCode: '+238', flag: '🇨🇻' }, - { code: 'KY', name: 'Cayman Islands', dialCode: '+1345', flag: '🇰🇾' }, - { code: 'CF', name: 'Central African Republic', dialCode: '+236', flag: '🇨🇫' }, - { code: 'TD', name: 'Chad', dialCode: '+235', flag: '🇹🇩' }, - { code: 'CL', name: 'Chile', dialCode: '+56', flag: '🇨🇱' }, - { code: 'CN', name: 'China', dialCode: '+86', flag: '🇨🇳' }, - { code: 'CX', name: 'Christmas Island', dialCode: '+61', flag: '🇨🇽' }, - { code: 'CC', name: 'Cocos Islands', dialCode: '+61', flag: '🇨🇨' }, - { code: 'CO', name: 'Colombia', dialCode: '+57', flag: '🇨🇴' }, - { code: 'KM', name: 'Comoros', dialCode: '+269', flag: '🇰🇲' }, - { code: 'CG', name: 'Congo', dialCode: '+242', flag: '🇨🇬' }, - { code: 'CD', name: 'Congo (DRC)', dialCode: '+243', flag: '🇨🇩' }, - { code: 'CK', name: 'Cook Islands', dialCode: '+682', flag: '🇨🇰' }, - { code: 'CR', name: 'Costa Rica', dialCode: '+506', flag: '🇨🇷' }, - { code: 'CI', name: "Côte d'Ivoire", dialCode: '+225', flag: '🇨🇮' }, - { code: 'HR', name: 'Croatia', dialCode: '+385', flag: '🇭🇷' }, - { code: 'CU', name: 'Cuba', dialCode: '+53', flag: '🇨🇺' }, - { code: 'CW', name: 'Curaçao', dialCode: '+599', flag: '🇨🇼' }, - { code: 'CY', name: 'Cyprus', dialCode: '+357', flag: '🇨🇾' }, - { code: 'CZ', name: 'Czech Republic', dialCode: '+420', flag: '🇨🇿' }, - { code: 'DK', name: 'Denmark', dialCode: '+45', flag: '🇩🇰' }, - { code: 'DJ', name: 'Djibouti', dialCode: '+253', flag: '🇩🇯' }, - { code: 'DM', name: 'Dominica', dialCode: '+1767', flag: '🇩🇲' }, - { code: 'DO', name: 'Dominican Republic', dialCode: '+1', flag: '🇩🇴' }, - { code: 'EC', name: 'Ecuador', dialCode: '+593', flag: '🇪🇨' }, - { code: 'EG', name: 'Egypt', dialCode: '+20', flag: '🇪🇬' }, - { code: 'SV', name: 'El Salvador', dialCode: '+503', flag: '🇸🇻' }, - { code: 'GQ', name: 'Equatorial Guinea', dialCode: '+240', flag: '🇬🇶' }, - { code: 'ER', name: 'Eritrea', dialCode: '+291', flag: '🇪🇷' }, - { code: 'EE', name: 'Estonia', dialCode: '+372', flag: '🇪🇪' }, - { code: 'ET', name: 'Ethiopia', dialCode: '+251', flag: '🇪🇹' }, - { code: 'FK', name: 'Falkland Islands', dialCode: '+500', flag: '🇫🇰' }, - { code: 'FO', name: 'Faroe Islands', dialCode: '+298', flag: '🇫🇴' }, - { code: 'FJ', name: 'Fiji', dialCode: '+679', flag: '🇫🇯' }, - { code: 'FI', name: 'Finland', dialCode: '+358', flag: '🇫🇮' }, - { code: 'FR', name: 'France', dialCode: '+33', flag: '🇫🇷' }, - { code: 'GF', name: 'French Guiana', dialCode: '+594', flag: '🇬🇫' }, - { code: 'PF', name: 'French Polynesia', dialCode: '+689', flag: '🇵🇫' }, - { code: 'GA', name: 'Gabon', dialCode: '+241', flag: '🇬🇦' }, - { code: 'GM', name: 'Gambia', dialCode: '+220', flag: '🇬🇲' }, - { code: 'GE', name: 'Georgia', dialCode: '+995', flag: '🇬🇪' }, - { code: 'DE', name: 'Germany', dialCode: '+49', flag: '🇩🇪' }, - { code: 'GH', name: 'Ghana', dialCode: '+233', flag: '🇬🇭' }, - { code: 'GI', name: 'Gibraltar', dialCode: '+350', flag: '🇬🇮' }, - { code: 'GR', name: 'Greece', dialCode: '+30', flag: '🇬🇷' }, - { code: 'GL', name: 'Greenland', dialCode: '+299', flag: '🇬🇱' }, - { code: 'GD', name: 'Grenada', dialCode: '+1473', flag: '🇬🇩' }, - { code: 'GP', name: 'Guadeloupe', dialCode: '+590', flag: '🇬🇵' }, - { code: 'GU', name: 'Guam', dialCode: '+1671', flag: '🇬🇺' }, - { code: 'GT', name: 'Guatemala', dialCode: '+502', flag: '🇬🇹' }, - { code: 'GG', name: 'Guernsey', dialCode: '+44', flag: '🇬🇬' }, - { code: 'GN', name: 'Guinea', dialCode: '+224', flag: '🇬🇳' }, - { code: 'GW', name: 'Guinea-Bissau', dialCode: '+245', flag: '🇬🇼' }, - { code: 'GY', name: 'Guyana', dialCode: '+592', flag: '🇬🇾' }, - { code: 'HT', name: 'Haiti', dialCode: '+509', flag: '🇭🇹' }, - { code: 'VA', name: 'Holy See', dialCode: '+379', flag: '🇻🇦' }, - { code: 'HN', name: 'Honduras', dialCode: '+504', flag: '🇭🇳' }, - { code: 'HK', name: 'Hong Kong', dialCode: '+852', flag: '🇭🇰' }, - { code: 'HU', name: 'Hungary', dialCode: '+36', flag: '🇭🇺' }, - { code: 'IS', name: 'Iceland', dialCode: '+354', flag: '🇮🇸' }, - { code: 'IN', name: 'India', dialCode: '+91', flag: '🇮🇳' }, - { code: 'ID', name: 'Indonesia', dialCode: '+62', flag: '🇮🇩' }, - { code: 'IR', name: 'Iran', dialCode: '+98', flag: '🇮🇷' }, - { code: 'IQ', name: 'Iraq', dialCode: '+964', flag: '🇮🇶' }, - { code: 'IE', name: 'Ireland', dialCode: '+353', flag: '🇮🇪' }, - { code: 'IM', name: 'Isle of Man', dialCode: '+44', flag: '🇮🇲' }, - { code: 'IL', name: 'Israel', dialCode: '+972', flag: '🇮🇱' }, - { code: 'IT', name: 'Italy', dialCode: '+39', flag: '🇮🇹' }, - { code: 'JM', name: 'Jamaica', dialCode: '+1876', flag: '🇯🇲' }, - { code: 'JP', name: 'Japan', dialCode: '+81', flag: '🇯🇵' }, - { code: 'JE', name: 'Jersey', dialCode: '+44', flag: '🇯🇪' }, - { code: 'JO', name: 'Jordan', dialCode: '+962', flag: '🇯🇴' }, - { code: 'KZ', name: 'Kazakhstan', dialCode: '+7', flag: '🇰🇿' }, - { code: 'KE', name: 'Kenya', dialCode: '+254', flag: '🇰🇪' }, - { code: 'KI', name: 'Kiribati', dialCode: '+686', flag: '🇰🇮' }, - { code: 'KP', name: 'North Korea', dialCode: '+850', flag: '🇰🇵' }, - { code: 'KR', name: 'South Korea', dialCode: '+82', flag: '🇰🇷' }, - { code: 'KW', name: 'Kuwait', dialCode: '+965', flag: '🇰🇼' }, - { code: 'KG', name: 'Kyrgyzstan', dialCode: '+996', flag: '🇰🇬' }, - { code: 'LA', name: 'Laos', dialCode: '+856', flag: '🇱🇦' }, - { code: 'LV', name: 'Latvia', dialCode: '+371', flag: '🇱🇻' }, - { code: 'LB', name: 'Lebanon', dialCode: '+961', flag: '🇱🇧' }, - { code: 'LS', name: 'Lesotho', dialCode: '+266', flag: '🇱🇸' }, - { code: 'LR', name: 'Liberia', dialCode: '+231', flag: '🇱🇷' }, - { code: 'LY', name: 'Libya', dialCode: '+218', flag: '🇱🇾' }, - { code: 'LI', name: 'Liechtenstein', dialCode: '+423', flag: '🇱🇮' }, - { code: 'LT', name: 'Lithuania', dialCode: '+370', flag: '🇱🇹' }, - { code: 'LU', name: 'Luxembourg', dialCode: '+352', flag: '🇱🇺' }, - { code: 'MO', name: 'Macau', dialCode: '+853', flag: '🇲🇴' }, - { code: 'MK', name: 'North Macedonia', dialCode: '+389', flag: '🇲🇰' }, - { code: 'MG', name: 'Madagascar', dialCode: '+261', flag: '🇲🇬' }, - { code: 'MW', name: 'Malawi', dialCode: '+265', flag: '🇲🇼' }, - { code: 'MY', name: 'Malaysia', dialCode: '+60', flag: '🇲🇾' }, - { code: 'MV', name: 'Maldives', dialCode: '+960', flag: '🇲🇻' }, - { code: 'ML', name: 'Mali', dialCode: '+223', flag: '🇲🇱' }, - { code: 'MT', name: 'Malta', dialCode: '+356', flag: '🇲🇹' }, - { code: 'MH', name: 'Marshall Islands', dialCode: '+692', flag: '🇲🇭' }, - { code: 'MQ', name: 'Martinique', dialCode: '+596', flag: '🇲🇶' }, - { code: 'MR', name: 'Mauritania', dialCode: '+222', flag: '🇲🇷' }, - { code: 'MU', name: 'Mauritius', dialCode: '+230', flag: '🇲🇺' }, - { code: 'YT', name: 'Mayotte', dialCode: '+262', flag: '🇾🇹' }, - { code: 'MX', name: 'Mexico', dialCode: '+52', flag: '🇲🇽' }, - { code: 'FM', name: 'Micronesia', dialCode: '+691', flag: '🇫🇲' }, - { code: 'MD', name: 'Moldova', dialCode: '+373', flag: '🇲🇩' }, - { code: 'MC', name: 'Monaco', dialCode: '+377', flag: '🇲🇨' }, - { code: 'MN', name: 'Mongolia', dialCode: '+976', flag: '🇲🇳' }, - { code: 'ME', name: 'Montenegro', dialCode: '+382', flag: '🇲🇪' }, - { code: 'MS', name: 'Montserrat', dialCode: '+1664', flag: '🇲🇸' }, - { code: 'MA', name: 'Morocco', dialCode: '+212', flag: '🇲🇦' }, - { code: 'MZ', name: 'Mozambique', dialCode: '+258', flag: '🇲🇿' }, - { code: 'MM', name: 'Myanmar', dialCode: '+95', flag: '🇲🇲' }, - { code: 'NA', name: 'Namibia', dialCode: '+264', flag: '🇳🇦' }, - { code: 'NR', name: 'Nauru', dialCode: '+674', flag: '🇳🇷' }, - { code: 'NP', name: 'Nepal', dialCode: '+977', flag: '🇳🇵' }, - { code: 'NL', name: 'Netherlands', dialCode: '+31', flag: '🇳🇱' }, - { code: 'NC', name: 'New Caledonia', dialCode: '+687', flag: '🇳🇨' }, - { code: 'NZ', name: 'New Zealand', dialCode: '+64', flag: '🇳🇿' }, - { code: 'NI', name: 'Nicaragua', dialCode: '+505', flag: '🇳🇮' }, - { code: 'NE', name: 'Niger', dialCode: '+227', flag: '🇳🇪' }, - { code: 'NG', name: 'Nigeria', dialCode: '+234', flag: '🇳🇬' }, - { code: 'NU', name: 'Niue', dialCode: '+683', flag: '🇳🇺' }, - { code: 'NF', name: 'Norfolk Island', dialCode: '+672', flag: '🇳🇫' }, - { code: 'MP', name: 'Northern Mariana Islands', dialCode: '+1670', flag: '🇲🇵' }, - { code: 'NO', name: 'Norway', dialCode: '+47', flag: '🇳🇴' }, - { code: 'OM', name: 'Oman', dialCode: '+968', flag: '🇴🇲' }, - { code: 'PK', name: 'Pakistan', dialCode: '+92', flag: '🇵🇰' }, - { code: 'PW', name: 'Palau', dialCode: '+680', flag: '🇵🇼' }, - { code: 'PS', name: 'Palestine', dialCode: '+970', flag: '🇵🇸' }, - { code: 'PA', name: 'Panama', dialCode: '+507', flag: '🇵🇦' }, - { code: 'PG', name: 'Papua New Guinea', dialCode: '+675', flag: '🇵🇬' }, - { code: 'PY', name: 'Paraguay', dialCode: '+595', flag: '🇵🇾' }, - { code: 'PE', name: 'Peru', dialCode: '+51', flag: '🇵🇪' }, - { code: 'PH', name: 'Philippines', dialCode: '+63', flag: '🇵🇭' }, - { code: 'PN', name: 'Pitcairn Islands', dialCode: '+64', flag: '🇵🇳' }, - { code: 'PL', name: 'Poland', dialCode: '+48', flag: '🇵🇱' }, - { code: 'PT', name: 'Portugal', dialCode: '+351', flag: '🇵🇹' }, - { code: 'PR', name: 'Puerto Rico', dialCode: '+1787', flag: '🇵🇷' }, - { code: 'QA', name: 'Qatar', dialCode: '+974', flag: '🇶🇦' }, - { code: 'RE', name: 'Réunion', dialCode: '+262', flag: '🇷🇪' }, - { code: 'RO', name: 'Romania', dialCode: '+40', flag: '🇷🇴' }, - { code: 'RU', name: 'Russia', dialCode: '+7', flag: '🇷🇺' }, - { code: 'RW', name: 'Rwanda', dialCode: '+250', flag: '🇷🇼' }, - { code: 'BL', name: 'Saint Barthélemy', dialCode: '+590', flag: '🇧🇱' }, - { code: 'SH', name: 'Saint Helena', dialCode: '+290', flag: '🇸🇭' }, - { code: 'KN', name: 'Saint Kitts and Nevis', dialCode: '+1869', flag: '🇰🇳' }, - { code: 'LC', name: 'Saint Lucia', dialCode: '+1758', flag: '🇱🇨' }, - { code: 'MF', name: 'Saint Martin', dialCode: '+590', flag: '🇲🇫' }, - { code: 'PM', name: 'Saint Pierre and Miquelon', dialCode: '+508', flag: '🇵🇲' }, - { code: 'VC', name: 'Saint Vincent and the Grenadines', dialCode: '+1784', flag: '🇻🇨' }, - { code: 'WS', name: 'Samoa', dialCode: '+685', flag: '🇼🇸' }, - { code: 'SM', name: 'San Marino', dialCode: '+378', flag: '🇸🇲' }, - { code: 'ST', name: 'São Tomé and Príncipe', dialCode: '+239', flag: '🇸🇹' }, - { code: 'SA', name: 'Saudi Arabia', dialCode: '+966', flag: '🇸🇦' }, - { code: 'SN', name: 'Senegal', dialCode: '+221', flag: '🇸🇳' }, - { code: 'RS', name: 'Serbia', dialCode: '+381', flag: '🇷🇸' }, - { code: 'SC', name: 'Seychelles', dialCode: '+248', flag: '🇸🇨' }, - { code: 'SL', name: 'Sierra Leone', dialCode: '+232', flag: '🇸🇱' }, - { code: 'SG', name: 'Singapore', dialCode: '+65', flag: '🇸🇬' }, - { code: 'SX', name: 'Sint Maarten', dialCode: '+1721', flag: '🇸🇽' }, - { code: 'SK', name: 'Slovakia', dialCode: '+421', flag: '🇸🇰' }, - { code: 'SI', name: 'Slovenia', dialCode: '+386', flag: '🇸🇮' }, - { code: 'SB', name: 'Solomon Islands', dialCode: '+677', flag: '🇸🇧' }, - { code: 'SO', name: 'Somalia', dialCode: '+252', flag: '🇸🇴' }, - { code: 'ZA', name: 'South Africa', dialCode: '+27', flag: '🇿🇦' }, - { code: 'GS', name: 'South Georgia and the South Sandwich Islands', dialCode: '+500', flag: '🇬🇸' }, - { code: 'SS', name: 'South Sudan', dialCode: '+211', flag: '🇸🇸' }, - { code: 'ES', name: 'Spain', dialCode: '+34', flag: '🇪🇸' }, - { code: 'LK', name: 'Sri Lanka', dialCode: '+94', flag: '🇱🇰' }, - { code: 'SD', name: 'Sudan', dialCode: '+249', flag: '🇸🇩' }, - { code: 'SR', name: 'Suriname', dialCode: '+597', flag: '🇸🇷' }, - { code: 'SJ', name: 'Svalbard and Jan Mayen', dialCode: '+47', flag: '🇸🇯' }, - { code: 'SZ', name: 'Eswatini', dialCode: '+268', flag: '🇸🇿' }, - { code: 'SE', name: 'Sweden', dialCode: '+46', flag: '🇸🇪' }, - { code: 'CH', name: 'Switzerland', dialCode: '+41', flag: '🇨🇭' }, - { code: 'SY', name: 'Syria', dialCode: '+963', flag: '🇸🇾' }, - { code: 'TW', name: 'Taiwan', dialCode: '+886', flag: '🇹🇼' }, - { code: 'TJ', name: 'Tajikistan', dialCode: '+992', flag: '🇹🇯' }, - { code: 'TZ', name: 'Tanzania', dialCode: '+255', flag: '🇹🇿' }, - { code: 'TH', name: 'Thailand', dialCode: '+66', flag: '🇹🇭' }, - { code: 'TL', name: 'Timor-Leste', dialCode: '+670', flag: '🇹🇱' }, - { code: 'TG', name: 'Togo', dialCode: '+228', flag: '🇹🇬' }, - { code: 'TK', name: 'Tokelau', dialCode: '+690', flag: '🇹🇰' }, - { code: 'TO', name: 'Tonga', dialCode: '+676', flag: '🇹🇴' }, - { code: 'TT', name: 'Trinidad and Tobago', dialCode: '+1868', flag: '🇹🇹' }, - { code: 'TN', name: 'Tunisia', dialCode: '+216', flag: '🇹🇳' }, - { code: 'TR', name: 'Turkey', dialCode: '+90', flag: '🇹🇷' }, - { code: 'TM', name: 'Turkmenistan', dialCode: '+993', flag: '🇹🇲' }, - { code: 'TC', name: 'Turks and Caicos Islands', dialCode: '+1649', flag: '🇹🇨' }, - { code: 'TV', name: 'Tuvalu', dialCode: '+688', flag: '🇹🇻' }, - { code: 'UG', name: 'Uganda', dialCode: '+256', flag: '🇺🇬' }, - { code: 'UA', name: 'Ukraine', dialCode: '+380', flag: '🇺🇦' }, - { code: 'AE', name: 'United Arab Emirates', dialCode: '+971', flag: '🇦🇪' }, - { code: 'GB', name: 'United Kingdom', dialCode: '+44', flag: '🇬🇧' }, - { code: 'US', name: 'United States', dialCode: '+1', flag: '🇺🇸' }, - { code: 'UM', name: 'United States Minor Outlying Islands', dialCode: '+1', flag: '🇺🇲' }, - { code: 'UY', name: 'Uruguay', dialCode: '+598', flag: '🇺🇾' }, - { code: 'UZ', name: 'Uzbekistan', dialCode: '+998', flag: '🇺🇿' }, - { code: 'VU', name: 'Vanuatu', dialCode: '+678', flag: '🇻🇺' }, - { code: 'VE', name: 'Venezuela', dialCode: '+58', flag: '🇻🇪' }, - { code: 'VN', name: 'Vietnam', dialCode: '+84', flag: '🇻🇳' }, - { code: 'VG', name: 'British Virgin Islands', dialCode: '+1284', flag: '🇻🇬' }, - { code: 'VI', name: 'U.S. Virgin Islands', dialCode: '+1340', flag: '🇻🇮' }, - { code: 'WF', name: 'Wallis and Futuna', dialCode: '+681', flag: '🇼🇫' }, - { code: 'EH', name: 'Western Sahara', dialCode: '+212', flag: '🇪🇭' }, - { code: 'YE', name: 'Yemen', dialCode: '+967', flag: '🇾🇪' }, - { code: 'ZM', name: 'Zambia', dialCode: '+260', flag: '🇿🇲' }, - { code: 'ZW', name: 'Zimbabwe', dialCode: '+263', flag: '🇿🇼' }, + { code: 'AF', name: 'Afghanistan', dialCode: '+93', flag: '\u{1F1E6}\u{1F1EB}' }, + { code: 'AL', name: 'Albania', dialCode: '+355', flag: '\u{1F1E6}\u{1F1F1}' }, + { code: 'DZ', name: 'Algeria', dialCode: '+213', flag: '\u{1F1E9}\u{1F1FF}' }, + { code: 'AS', name: 'American Samoa', dialCode: '+1684', flag: '\u{1F1E6}\u{1F1F8}' }, + { code: 'AD', name: 'Andorra', dialCode: '+376', flag: '\u{1F1E6}\u{1F1E9}' }, + { code: 'AO', name: 'Angola', dialCode: '+244', flag: '\u{1F1E6}\u{1F1F4}' }, + { code: 'AI', name: 'Anguilla', dialCode: '+1264', flag: '\u{1F1E6}\u{1F1EE}' }, + { code: 'AQ', name: 'Antarctica', dialCode: '+672', flag: '\u{1F1E6}\u{1F1F6}' }, + { code: 'AG', name: 'Antigua and Barbuda', dialCode: '+1268', flag: '\u{1F1E6}\u{1F1EC}' }, + { code: 'AR', name: 'Argentina', dialCode: '+54', flag: '\u{1F1E6}\u{1F1F7}' }, + { code: 'AM', name: 'Armenia', dialCode: '+374', flag: '\u{1F1E6}\u{1F1F2}' }, + { code: 'AW', name: 'Aruba', dialCode: '+297', flag: '\u{1F1E6}\u{1F1FC}' }, + { code: 'AU', name: 'Australia', dialCode: '+61', flag: '\u{1F1E6}\u{1F1FA}' }, + { code: 'AT', name: 'Austria', dialCode: '+43', flag: '\u{1F1E6}\u{1F1F9}' }, + { code: 'AZ', name: 'Azerbaijan', dialCode: '+994', flag: '\u{1F1E6}\u{1F1FF}' }, + { code: 'BS', name: 'Bahamas', dialCode: '+1242', flag: '\u{1F1E7}\u{1F1F8}' }, + { code: 'BH', name: 'Bahrain', dialCode: '+973', flag: '\u{1F1E7}\u{1F1ED}' }, + { code: 'BD', name: 'Bangladesh', dialCode: '+880', flag: '\u{1F1E7}\u{1F1E9}' }, + { code: 'BB', name: 'Barbados', dialCode: '+1246', flag: '\u{1F1E7}\u{1F1E7}' }, + { code: 'BY', name: 'Belarus', dialCode: '+375', flag: '\u{1F1E7}\u{1F1FE}' }, + { code: 'BE', name: 'Belgium', dialCode: '+32', flag: '\u{1F1E7}\u{1F1EA}' }, + { code: 'BZ', name: 'Belize', dialCode: '+501', flag: '\u{1F1E7}\u{1F1FF}' }, + { code: 'BJ', name: 'Benin', dialCode: '+229', flag: '\u{1F1E7}\u{1F1EF}' }, + { code: 'BM', name: 'Bermuda', dialCode: '+1441', flag: '\u{1F1E7}\u{1F1F2}' }, + { code: 'BT', name: 'Bhutan', dialCode: '+975', flag: '\u{1F1E7}\u{1F1F9}' }, + { code: 'BO', name: 'Bolivia', dialCode: '+591', flag: '\u{1F1E7}\u{1F1F4}' }, + { code: 'BA', name: 'Bosnia and Herzegovina', dialCode: '+387', flag: '\u{1F1E7}\u{1F1E6}' }, + { code: 'BW', name: 'Botswana', dialCode: '+267', flag: '\u{1F1E7}\u{1F1FC}' }, + { code: 'BR', name: 'Brazil', dialCode: '+55', flag: '\u{1F1E7}\u{1F1F7}' }, + { code: 'IO', name: 'British Indian Ocean Territory', dialCode: '+246', flag: '\u{1F1EE}\u{1F1F4}' }, + { code: 'BN', name: 'Brunei', dialCode: '+673', flag: '\u{1F1E7}\u{1F1F3}' }, + { code: 'BG', name: 'Bulgaria', dialCode: '+359', flag: '\u{1F1E7}\u{1F1EC}' }, + { code: 'BF', name: 'Burkina Faso', dialCode: '+226', flag: '\u{1F1E7}\u{1F1EB}' }, + { code: 'BI', name: 'Burundi', dialCode: '+257', flag: '\u{1F1E7}\u{1F1EE}' }, + { code: 'KH', name: 'Cambodia', dialCode: '+855', flag: '\u{1F1F0}\u{1F1ED}' }, + { code: 'CM', name: 'Cameroon', dialCode: '+237', flag: '\u{1F1E8}\u{1F1F2}' }, + { code: 'CA', name: 'Canada', dialCode: '+1', flag: '\u{1F1E8}\u{1F1E6}' }, + { code: 'CV', name: 'Cape Verde', dialCode: '+238', flag: '\u{1F1E8}\u{1F1FB}' }, + { code: 'KY', name: 'Cayman Islands', dialCode: '+1345', flag: '\u{1F1F0}\u{1F1FE}' }, + { code: 'CF', name: 'Central African Republic', dialCode: '+236', flag: '\u{1F1E8}\u{1F1EB}' }, + { code: 'TD', name: 'Chad', dialCode: '+235', flag: '\u{1F1F9}\u{1F1E9}' }, + { code: 'CL', name: 'Chile', dialCode: '+56', flag: '\u{1F1E8}\u{1F1F1}' }, + { code: 'CN', name: 'China', dialCode: '+86', flag: '\u{1F1E8}\u{1F1F3}' }, + { code: 'CO', name: 'Colombia', dialCode: '+57', flag: '\u{1F1E8}\u{1F1F4}' }, + { code: 'CR', name: 'Costa Rica', dialCode: '+506', flag: '\u{1F1E8}\u{1F1F7}' }, + { code: 'HR', name: 'Croatia', dialCode: '+385', flag: '\u{1F1ED}\u{1F1F7}' }, + { code: 'CU', name: 'Cuba', dialCode: '+53', flag: '\u{1F1E8}\u{1F1FA}' }, + { code: 'CY', name: 'Cyprus', dialCode: '+357', flag: '\u{1F1E8}\u{1F1FE}' }, + { code: 'CZ', name: 'Czech Republic', dialCode: '+420', flag: '\u{1F1E8}\u{1F1FF}' }, + { code: 'DK', name: 'Denmark', dialCode: '+45', flag: '\u{1F1E9}\u{1F1F0}' }, + { code: 'DO', name: 'Dominican Republic', dialCode: '+1', flag: '\u{1F1E9}\u{1F1F4}' }, + { code: 'EC', name: 'Ecuador', dialCode: '+593', flag: '\u{1F1EA}\u{1F1E8}' }, + { code: 'EG', name: 'Egypt', dialCode: '+20', flag: '\u{1F1EA}\u{1F1EC}' }, + { code: 'SV', name: 'El Salvador', dialCode: '+503', flag: '\u{1F1F8}\u{1F1FB}' }, + { code: 'EE', name: 'Estonia', dialCode: '+372', flag: '\u{1F1EA}\u{1F1EA}' }, + { code: 'ET', name: 'Ethiopia', dialCode: '+251', flag: '\u{1F1EA}\u{1F1F9}' }, + { code: 'FI', name: 'Finland', dialCode: '+358', flag: '\u{1F1EB}\u{1F1EE}' }, + { code: 'FR', name: 'France', dialCode: '+33', flag: '\u{1F1EB}\u{1F1F7}' }, + { code: 'DE', name: 'Germany', dialCode: '+49', flag: '\u{1F1E9}\u{1F1EA}' }, + { code: 'GH', name: 'Ghana', dialCode: '+233', flag: '\u{1F1EC}\u{1F1ED}' }, + { code: 'GR', name: 'Greece', dialCode: '+30', flag: '\u{1F1EC}\u{1F1F7}' }, + { code: 'GT', name: 'Guatemala', dialCode: '+502', flag: '\u{1F1EC}\u{1F1F9}' }, + { code: 'HN', name: 'Honduras', dialCode: '+504', flag: '\u{1F1ED}\u{1F1F3}' }, + { code: 'HK', name: 'Hong Kong', dialCode: '+852', flag: '\u{1F1ED}\u{1F1F0}' }, + { code: 'HU', name: 'Hungary', dialCode: '+36', flag: '\u{1F1ED}\u{1F1FA}' }, + { code: 'IS', name: 'Iceland', dialCode: '+354', flag: '\u{1F1EE}\u{1F1F8}' }, + { code: 'IN', name: 'India', dialCode: '+91', flag: '\u{1F1EE}\u{1F1F3}' }, + { code: 'ID', name: 'Indonesia', dialCode: '+62', flag: '\u{1F1EE}\u{1F1E9}' }, + { code: 'IR', name: 'Iran', dialCode: '+98', flag: '\u{1F1EE}\u{1F1F7}' }, + { code: 'IQ', name: 'Iraq', dialCode: '+964', flag: '\u{1F1EE}\u{1F1F6}' }, + { code: 'IE', name: 'Ireland', dialCode: '+353', flag: '\u{1F1EE}\u{1F1EA}' }, + { code: 'IL', name: 'Israel', dialCode: '+972', flag: '\u{1F1EE}\u{1F1F1}' }, + { code: 'IT', name: 'Italy', dialCode: '+39', flag: '\u{1F1EE}\u{1F1F9}' }, + { code: 'JM', name: 'Jamaica', dialCode: '+1876', flag: '\u{1F1EF}\u{1F1F2}' }, + { code: 'JP', name: 'Japan', dialCode: '+81', flag: '\u{1F1EF}\u{1F1F5}' }, + { code: 'JO', name: 'Jordan', dialCode: '+962', flag: '\u{1F1EF}\u{1F1F4}' }, + { code: 'KZ', name: 'Kazakhstan', dialCode: '+7', flag: '\u{1F1F0}\u{1F1FF}' }, + { code: 'KE', name: 'Kenya', dialCode: '+254', flag: '\u{1F1F0}\u{1F1EA}' }, + { code: 'KR', name: 'South Korea', dialCode: '+82', flag: '\u{1F1F0}\u{1F1F7}' }, + { code: 'KW', name: 'Kuwait', dialCode: '+965', flag: '\u{1F1F0}\u{1F1FC}' }, + { code: 'LV', name: 'Latvia', dialCode: '+371', flag: '\u{1F1F1}\u{1F1FB}' }, + { code: 'LB', name: 'Lebanon', dialCode: '+961', flag: '\u{1F1F1}\u{1F1E7}' }, + { code: 'LT', name: 'Lithuania', dialCode: '+370', flag: '\u{1F1F1}\u{1F1F9}' }, + { code: 'LU', name: 'Luxembourg', dialCode: '+352', flag: '\u{1F1F1}\u{1F1FA}' }, + { code: 'MY', name: 'Malaysia', dialCode: '+60', flag: '\u{1F1F2}\u{1F1FE}' }, + { code: 'MX', name: 'Mexico', dialCode: '+52', flag: '\u{1F1F2}\u{1F1FD}' }, + { code: 'MA', name: 'Morocco', dialCode: '+212', flag: '\u{1F1F2}\u{1F1E6}' }, + { code: 'NL', name: 'Netherlands', dialCode: '+31', flag: '\u{1F1F3}\u{1F1F1}' }, + { code: 'NZ', name: 'New Zealand', dialCode: '+64', flag: '\u{1F1F3}\u{1F1FF}' }, + { code: 'NG', name: 'Nigeria', dialCode: '+234', flag: '\u{1F1F3}\u{1F1EC}' }, + { code: 'NO', name: 'Norway', dialCode: '+47', flag: '\u{1F1F3}\u{1F1F4}' }, + { code: 'PK', name: 'Pakistan', dialCode: '+92', flag: '\u{1F1F5}\u{1F1F0}' }, + { code: 'PA', name: 'Panama', dialCode: '+507', flag: '\u{1F1F5}\u{1F1E6}' }, + { code: 'PE', name: 'Peru', dialCode: '+51', flag: '\u{1F1F5}\u{1F1EA}' }, + { code: 'PH', name: 'Philippines', dialCode: '+63', flag: '\u{1F1F5}\u{1F1ED}' }, + { code: 'PL', name: 'Poland', dialCode: '+48', flag: '\u{1F1F5}\u{1F1F1}' }, + { code: 'PT', name: 'Portugal', dialCode: '+351', flag: '\u{1F1F5}\u{1F1F9}' }, + { code: 'PR', name: 'Puerto Rico', dialCode: '+1787', flag: '\u{1F1F5}\u{1F1F7}' }, + { code: 'QA', name: 'Qatar', dialCode: '+974', flag: '\u{1F1F6}\u{1F1E6}' }, + { code: 'RO', name: 'Romania', dialCode: '+40', flag: '\u{1F1F7}\u{1F1F4}' }, + { code: 'RU', name: 'Russia', dialCode: '+7', flag: '\u{1F1F7}\u{1F1FA}' }, + { code: 'SA', name: 'Saudi Arabia', dialCode: '+966', flag: '\u{1F1F8}\u{1F1E6}' }, + { code: 'RS', name: 'Serbia', dialCode: '+381', flag: '\u{1F1F7}\u{1F1F8}' }, + { code: 'SG', name: 'Singapore', dialCode: '+65', flag: '\u{1F1F8}\u{1F1EC}' }, + { code: 'SK', name: 'Slovakia', dialCode: '+421', flag: '\u{1F1F8}\u{1F1F0}' }, + { code: 'SI', name: 'Slovenia', dialCode: '+386', flag: '\u{1F1F8}\u{1F1EE}' }, + { code: 'ZA', name: 'South Africa', dialCode: '+27', flag: '\u{1F1FF}\u{1F1E6}' }, + { code: 'ES', name: 'Spain', dialCode: '+34', flag: '\u{1F1EA}\u{1F1F8}' }, + { code: 'LK', name: 'Sri Lanka', dialCode: '+94', flag: '\u{1F1F1}\u{1F1F0}' }, + { code: 'SE', name: 'Sweden', dialCode: '+46', flag: '\u{1F1F8}\u{1F1EA}' }, + { code: 'CH', name: 'Switzerland', dialCode: '+41', flag: '\u{1F1E8}\u{1F1ED}' }, + { code: 'TW', name: 'Taiwan', dialCode: '+886', flag: '\u{1F1F9}\u{1F1FC}' }, + { code: 'TH', name: 'Thailand', dialCode: '+66', flag: '\u{1F1F9}\u{1F1ED}' }, + { code: 'TR', name: 'Turkey', dialCode: '+90', flag: '\u{1F1F9}\u{1F1F7}' }, + { code: 'UA', name: 'Ukraine', dialCode: '+380', flag: '\u{1F1FA}\u{1F1E6}' }, + { code: 'AE', name: 'United Arab Emirates', dialCode: '+971', flag: '\u{1F1E6}\u{1F1EA}' }, + { code: 'GB', name: 'United Kingdom', dialCode: '+44', flag: '\u{1F1EC}\u{1F1E7}' }, + { code: 'US', name: 'United States', dialCode: '+1', flag: '\u{1F1FA}\u{1F1F8}' }, + { code: 'UY', name: 'Uruguay', dialCode: '+598', flag: '\u{1F1FA}\u{1F1FE}' }, + { code: 'UZ', name: 'Uzbekistan', dialCode: '+998', flag: '\u{1F1FA}\u{1F1FF}' }, + { code: 'VE', name: 'Venezuela', dialCode: '+58', flag: '\u{1F1FB}\u{1F1EA}' }, + { code: 'VN', name: 'Vietnam', dialCode: '+84', flag: '\u{1F1FB}\u{1F1F3}' }, + { code: 'ZM', name: 'Zambia', dialCode: '+260', flag: '\u{1F1FF}\u{1F1F2}' }, + { code: 'ZW', name: 'Zimbabwe', dialCode: '+263', flag: '\u{1F1FF}\u{1F1FC}' }, ]; ngOnInit(): void { @@ -314,8 +194,9 @@ export class PhoneEditComponent extends BaseEditFieldComponent implements OnInit } configureFromWidgetParams(): void { - if (this.widgetStructure?.widget_params) { - const params = this.widgetStructure.widget_params; + const ws = this.widgetStructure(); + if (ws?.widget_params) { + const params = ws.widget_params; if (params.preferred_countries && Array.isArray(params.preferred_countries)) { this.preferredCountries = params.preferred_countries; @@ -399,139 +280,6 @@ export class PhoneEditComponent extends BaseEditFieldComponent implements OnInit AU: '(02) 1234 5678', DE: '030 12345678', FR: '01 23 45 67 89', - IT: '06 1234 5678', - ES: '91 123 45 67', - NL: '020 123 4567', - BE: '02 123 45 67', - CH: '044 123 45 67', - AT: '01 12345678', - SE: '08-123 456 78', - NO: '22 12 34 56', - DK: '32 12 34 56', - FI: '09 1234 5678', - PL: '12 123 45 67', - CZ: '224 123 456', - HU: '(06 1) 123 4567', - SK: '2 1234 5678', - SI: '1 123 45 67', - HR: '1 123 4567', - RO: '021 123 4567', - BG: '02 123 4567', - GR: '21 1234 5678', - PT: '21 123 4567', - IE: '01 123 4567', - LU: '621 123 456', - MT: '2123 4567', - CY: '22 123456', - EE: '372 1234', - LV: '2123 4567', - LT: '8 612 34567', - RU: '8 (495) 123-45-67', - UA: '044 123 4567', - BY: '8 017 123-45-67', - MD: '22 123456', - JP: '03-1234-5678', - KR: '02-123-4567', - CN: '010 1234 5678', - HK: '2123 4567', - TW: '02 1234 5678', - SG: '6123 4567', - MY: '03-1234 5678', - TH: '02 123 4567', - PH: '02 1234 5678', - ID: '021 1234 5678', - VN: '28 1234 5678', - IN: '011 1234 5678', - PK: '21 1234 5678', - BD: '2 1234 5678', - LK: '11 234 5678', - NP: '1 123 4567', - AF: '20 123 4567', - IR: '021 1234 5678', - IQ: '1 123 4567', - SA: '011 123 4567', - AE: '4 123 4567', - QA: '4412 3456', - KW: '2221 2345', - BH: '1712 3456', - OM: '2412 3456', - JO: '6 123 4567', - LB: '1 123 456', - SY: '11 123 4567', - IL: '2-123-4567', - PS: '59 123 4567', - TR: '(0212) 123 45 67', - GE: '32 123 45 67', - AM: '10 123456', - AZ: '12 123 45 67', - KZ: '8 (7172) 12 34 56', - KG: '312 123456', - TJ: '372 123456', - UZ: '71 123 45 67', - TM: '12 123456', - MN: '11 123456', - ZA: '011 123 4567', - EG: '02 12345678', - MA: '522 123456', - TN: '71 123 456', - DZ: '21 12 34 56', - LY: '21 123 4567', - SD: '15 123 4567', - ET: '11 123 4567', - KE: '20 123 4567', - UG: '41 123 4567', - TZ: '22 123 4567', - RW: '78 123 4567', - BI: '22 12 34 56', - DJ: '77 12 34 56', - SO: '1 123456', - ER: '1 123 456', - SS: '95 123 4567', - CF: '70 12 34 56', - TD: '22 12 34 56', - CM: '6 71 23 45 67', - GQ: '222 123456', - GA: '06 12 34 56', - CG: '06 612 3456', - CD: '12 123 4567', - AO: '222 123456', - ZM: '21 123 4567', - ZW: '4 123456', - BW: '71 123 456', - NA: '61 123 4567', - SZ: '2505 1234', - LS: '2212 3456', - MZ: '21 123456', - MW: '1 123 456', - MG: '20 12 345 67', - MU: '212 3456', - SC: '4 123 456', - KM: '773 1234', - YT: '269 61 23 45', - RE: '262 12 34 56', - MV: '330 1234', - BR: '(11) 1234-5678', - AR: '011 1234-5678', - CL: '2 1234 5678', - CO: '(601) 234 5678', - PE: '1 123 4567', - VE: '0212-1234567', - EC: '2 123 4567', - BO: '2 123 4567', - PY: '21 123 456', - UY: '2 123 4567', - GY: '222 1234', - SR: '421234', - GF: '594 12 34 56', - FK: '41234', - MX: '55 1234 5678', - GT: '2 123 4567', - BZ: '223 1234', - SV: '2123 4567', - HN: '2 123 4567', - NI: '2 123 4567', - CR: '2 123 4567', - PA: '123 4567', }; return exampleNumbers[this.selectedCountry.code] || `${this.selectedCountry.dialCode} 123 4567`; @@ -559,8 +307,9 @@ export class PhoneEditComponent extends BaseEditFieldComponent implements OnInit } private initializePhoneNumber(): void { - if (this.value) { - this.parseExistingPhoneNumber(this.value); + const currentValue = this.value(); + if (currentValue) { + this.parseExistingPhoneNumber(currentValue); } else { this.setDefaultCountry(); this.displayPhoneNumber = ''; @@ -619,8 +368,8 @@ export class PhoneEditComponent extends BaseEditFieldComponent implements OnInit private formatAndUpdatePhoneNumber(): void { if (!this.displayPhoneNumber) { this.phoneNumber = ''; - this.value = ''; - this.onFieldChange.emit(this.value); + this.value.set(''); + this.onFieldChange.emit(this.value()); return; } @@ -666,8 +415,8 @@ export class PhoneEditComponent extends BaseEditFieldComponent implements OnInit private updateFullPhoneNumber(): void { if (!this.displayPhoneNumber && !this.phoneNumber) { - this.value = ''; - this.onFieldChange.emit(this.value); + this.value.set(''); + this.onFieldChange.emit(this.value()); return; } @@ -681,15 +430,15 @@ export class PhoneEditComponent extends BaseEditFieldComponent implements OnInit } if (phoneNumber?.isValid()) { - this.value = phoneNumber.number; + this.value.set(phoneNumber.number); } else { - this.value = this.displayPhoneNumber.replace(/\s/g, ''); + this.value.set(this.displayPhoneNumber.replace(/\s/g, '')); } } catch (error) { console.warn('Error formatting phone number:', error); - this.value = this.displayPhoneNumber.replace(/\s/g, ''); + this.value.set(this.displayPhoneNumber.replace(/\s/g, '')); } - this.onFieldChange.emit(this.value); + this.onFieldChange.emit(this.value()); } } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.html b/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.html index 31aec55fd..d37548790 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.html @@ -1,10 +1,12 @@ - - {{normalizedLabel}} X coordinate - - - - {{normalizedLabel}} Y coordinate - - +@if (value()) { + + {{normalizedLabel()}} X coordinate + + + + {{normalizedLabel()}} Y coordinate + + +} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.ts index 99f57ee00..c4043f6a9 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; +import { Component, model } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -12,5 +12,5 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone imports: [CommonModule, MatFormFieldModule, MatInputModule, FormsModule], }) export class PointEditComponent extends BaseEditFieldComponent { - @Input() value; + readonly value = model(); } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.html b/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.html index afc1f22b7..52d9eba05 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.html @@ -1,18 +1,18 @@
- {{ normalizedLabel }} {{ required ? '*' : '' }} + {{ normalizedLabel() }} {{ required() ? '*' : '' }}
{{ min }} - {{ value || min }} + {{ value() || min }} {{ max }}
- diff --git a/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.spec.ts new file mode 100644 index 000000000..972d45f3f --- /dev/null +++ b/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.spec.ts @@ -0,0 +1,75 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RangeEditComponent } from './range.component'; + +describe('RangeEditComponent', () => { + let component: RangeEditComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RangeEditComponent, BrowserAnimationsModule], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RangeEditComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have default min, max, and step values', () => { + expect(component.min).toBe(0); + expect(component.max).toBe(100); + expect(component.step).toBe(1); + }); + + it('should parse widget params on init', () => { + fixture.componentRef.setInput('widgetStructure', { + widget_params: { min: 10, max: 200, step: 5 }, + } as any); + component.ngOnInit(); + expect(component.min).toBe(10); + expect(component.max).toBe(200); + expect(component.step).toBe(5); + }); + + it('should parse widget params on changes', () => { + fixture.componentRef.setInput('widgetStructure', { + widget_params: { min: 5, max: 50, step: 2 }, + } as any); + component.ngOnChanges(); + expect(component.min).toBe(5); + expect(component.max).toBe(50); + expect(component.step).toBe(2); + }); + + it('should keep defaults when widget_params is undefined', () => { + fixture.componentRef.setInput('widgetStructure', undefined); + component.ngOnInit(); + expect(component.min).toBe(0); + expect(component.max).toBe(100); + expect(component.step).toBe(1); + }); + + it('should handle partial widget params', () => { + fixture.componentRef.setInput('widgetStructure', { + widget_params: { min: 20 }, + } as any); + component.ngOnInit(); + expect(component.min).toBe(20); + expect(component.max).toBe(100); + expect(component.step).toBe(1); + }); + + it('should emit onFieldChange when value changes', () => { + vi.spyOn(component.onFieldChange, 'emit'); + component.onValueChange(42); + expect(component.value()).toBe(42); + expect(component.onFieldChange.emit).toHaveBeenCalledWith(42); + }); +}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.ts index 81bd8b1c0..ea146c828 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, ElementRef, Input, ViewChild } from '@angular/core'; +import { Component, ElementRef, model, ViewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -13,7 +13,7 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone }) export class RangeEditComponent extends BaseEditFieldComponent { @ViewChild('rangeInput') rangeInput: ElementRef; - @Input() value: number; + readonly value = model(); static type = 'range'; public min: number = 0; @@ -30,14 +30,15 @@ export class RangeEditComponent extends BaseEditFieldComponent { } public onValueChange(newValue: number): void { - this.value = newValue; - this.onFieldChange.emit(this.value); + this.value.set(newValue); + this.onFieldChange.emit(this.value()); } private _parseWidgetParams(): void { - if (this.widgetStructure?.widget_params) { + const ws = this.widgetStructure(); + if (ws?.widget_params) { try { - const params = this.widgetStructure.widget_params; + const params = ws.widget_params; if (params.min !== undefined) { this.min = Number(params.min) || 0; } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.html b/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.html index b8546cb7f..fc9019433 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.html @@ -1,12 +1,13 @@ - {{normalizedLabel}} - {{normalizedLabel()}} + - - {{option.label}} - + @for (option of options; track option.value) { + + {{option.label}} + + } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.spec.ts index c22baf7ea..3ef4a1b34 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.spec.ts @@ -15,10 +15,66 @@ describe('SelectEditComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(SelectEditComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should have empty options by default', () => { + expect(component.options).toEqual([]); + }); + + it('should load options from widgetStructure widget_params', () => { + const options = [ + { value: 'opt1', label: 'Option 1' }, + { value: 'opt2', label: 'Option 2' }, + ]; + fixture.componentRef.setInput('widgetStructure', { widget_params: { options } } as any); + component.ngOnInit(); + expect(component.options).toEqual(options); + }); + + it('should prepend null option when widgetStructure allow_null is true', () => { + const options = [{ value: 'opt1', label: 'Option 1' }]; + fixture.componentRef.setInput('widgetStructure', { widget_params: { options, allow_null: true } } as any); + component.ngOnInit(); + expect(component.options[0]).toEqual({ value: null, label: '' }); + expect(component.options.length).toBe(2); + }); + + it('should not prepend null option when widgetStructure allow_null is false', () => { + const options = [{ value: 'opt1', label: 'Option 1' }]; + fixture.componentRef.setInput('widgetStructure', { widget_params: { options, allow_null: false } } as any); + component.ngOnInit(); + expect(component.options.length).toBe(1); + expect(component.options[0].value).toBe('opt1'); + }); + + it('should load options from structure data_type_params when no widgetStructure', () => { + fixture.componentRef.setInput('structure', { + data_type_params: ['active', 'inactive', 'pending'], + allow_null: false, + } as any); + component.ngOnInit(); + expect(component.options).toEqual([ + { value: 'active', label: 'active' }, + { value: 'inactive', label: 'inactive' }, + { value: 'pending', label: 'pending' }, + ]); + }); + + it('should prepend null option from structure when allow_null is true', () => { + fixture.componentRef.setInput('structure', { + data_type_params: ['active', 'inactive'], + allow_null: true, + } as any); + component.ngOnInit(); + expect(component.options[0]).toEqual({ value: null, label: '' }); + expect(component.options.length).toBe(3); + }); + + it('should return 0 from originalOrder', () => { + expect(component.originalOrder()).toBe(0); + }); }); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.ts index 6daa91e53..19c2e5d78 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, CUSTOM_ELEMENTS_SCHEMA, Input } from '@angular/core'; +import { Component, CUSTOM_ELEMENTS_SCHEMA, model } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; @@ -13,7 +13,7 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone schemas: [CUSTOM_ELEMENTS_SCHEMA], }) export class SelectEditComponent extends BaseEditFieldComponent { - @Input() value: string; + readonly value = model(); public options: { value: string | null; label: string }[] = []; @@ -23,16 +23,19 @@ export class SelectEditComponent extends BaseEditFieldComponent { ngOnInit(): void { super.ngOnInit(); - if (this.widgetStructure) { - this.options = this.widgetStructure.widget_params.options; - if (this.widgetStructure.widget_params.allow_null) { + const ws = this.widgetStructure(); + const struct = this.structure(); + + if (ws) { + this.options = ws.widget_params.options; + if (ws.widget_params.allow_null) { this.options = [{ value: null, label: '' }, ...this.options]; } - } else if (this.structure) { - this.options = this.structure.data_type_params.map((option) => { + } else if (struct) { + this.options = struct.data_type_params.map((option) => { return { value: option, label: option }; }); - if (this.structure.allow_null) { + if (struct.allow_null) { this.options = [{ value: null, label: '' }, ...this.options]; } } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.html b/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.html index 3ccc8b0bc..83a49985b 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.html @@ -1,6 +1,9 @@
- {{ normalizedLabel }} - NULL - {{ value }} + {{ normalizedLabel() }} + @if (value() === null) { + NULL + } + @if (value()) { + {{ value() }} + }
- diff --git a/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.ts index 8251ee483..c4402c407 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; +import { Component, model } from '@angular/core'; import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; @Component({ @@ -9,5 +9,5 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone styleUrls: ['./static-text.component.css'], }) export class StaticTextEditComponent extends BaseEditFieldComponent { - @Input() value: string; + readonly value = model(); } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.html b/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.html index 49c432cb0..b46bc7023 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.html @@ -1,16 +1,24 @@ - {{normalizedLabel}} - {{normalizedLabel()}} + -
{{value.length}} / {{maxLength}}
- This field is required. - Maximum length is {{maxLength}} characters. - {{getValidationErrorMessage()}} + @if (maxLength && maxLength > 0 && value() && (maxLength - value().length) < 100) { +
{{value().length}} / {{maxLength}}
+ } + @if (textField.errors?.['required']) { + This field is required. + } + @if (textField.errors?.['maxlength']) { + Maximum length is {{maxLength}} characters. + } + @if (textField.errors?.['invalidPattern'] || textField.errors?.[('invalid' + validateType)]) { + {{getValidationErrorMessage()}} + }
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.spec.ts index d8f7dc665..5f3dd5d3d 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.spec.ts @@ -15,10 +15,69 @@ describe('TextEditComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(TextEditComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should set maxLength from structure character_maximum_length', () => { + fixture.componentRef.setInput('structure', { character_maximum_length: 255 } as any); + component.ngOnInit(); + expect(component.maxLength).toBe(255); + }); + + it('should keep maxLength null when structure has no character_maximum_length', () => { + fixture.componentRef.setInput('structure', {} as any); + component.ngOnInit(); + expect(component.maxLength).toBeNull(); + }); + + it('should parse validateType from widget params object', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: { validate: 'isEmail' } } as any); + component.ngOnInit(); + expect(component.validateType).toBe('isEmail'); + }); + + it('should parse validateType from widget params string', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: JSON.stringify({ validate: 'isURL' }) } as any); + component.ngOnInit(); + expect(component.validateType).toBe('isURL'); + }); + + it('should parse regexPattern from widget params', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: { validate: 'regex', regex: '^[a-z]+$' } } as any); + component.ngOnInit(); + expect(component.regexPattern).toBe('^[a-z]+$'); + }); + + it('should return empty string for getValidationErrorMessage when no validateType', () => { + component.validateType = null; + expect(component.getValidationErrorMessage()).toBe(''); + }); + + it('should return regex message for regex validateType', () => { + component.validateType = 'regex'; + expect(component.getValidationErrorMessage()).toBe("Value doesn't match the required pattern"); + }); + + it('should return correct message for isEmail validateType', () => { + component.validateType = 'isEmail'; + expect(component.getValidationErrorMessage()).toBe('Invalid email address'); + }); + + it('should return correct message for isURL validateType', () => { + component.validateType = 'isURL'; + expect(component.getValidationErrorMessage()).toBe('Invalid URL'); + }); + + it('should return correct message for isJSON validateType', () => { + component.validateType = 'isJSON'; + expect(component.getValidationErrorMessage()).toBe('Invalid JSON'); + }); + + it('should return fallback message for unknown validateType', () => { + component.validateType = 'customValidator'; + expect(component.getValidationErrorMessage()).toBe('Invalid customValidator'); + }); }); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.ts index d5dbc6e64..052f867d6 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.ts @@ -1,13 +1,11 @@ import { CommonModule } from '@angular/common'; -import { Component, Injectable, Input, OnInit } from '@angular/core'; +import { Component, model, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { TextValidatorDirective } from 'src/app/directives/text-validator.directive'; import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; -@Injectable() - @Component({ selector: 'app-edit-text', templateUrl: './text.component.html', @@ -15,7 +13,7 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone imports: [CommonModule, MatFormFieldModule, MatInputModule, FormsModule, TextValidatorDirective], }) export class TextEditComponent extends BaseEditFieldComponent implements OnInit { - @Input() value: string; + readonly value = model(); static type = 'text'; @@ -26,17 +24,14 @@ export class TextEditComponent extends BaseEditFieldComponent implements OnInit override ngOnInit(): void { super.ngOnInit(); - // Use character_maximum_length from the field structure if available - if (this.structure?.character_maximum_length) { - this.maxLength = this.structure.character_maximum_length; + const struct = this.structure(); + if (struct?.character_maximum_length) { + this.maxLength = struct.character_maximum_length; } - // Parse widget parameters for validation - if (this.widgetStructure?.widget_params) { - const params = - typeof this.widgetStructure.widget_params === 'string' - ? JSON.parse(this.widgetStructure.widget_params) - : this.widgetStructure.widget_params; + const ws = this.widgetStructure(); + if (ws?.widget_params) { + const params = typeof ws.widget_params === 'string' ? JSON.parse(ws.widget_params) : ws.widget_params; this.validateType = params.validate || null; this.regexPattern = params.regex || null; @@ -52,7 +47,6 @@ export class TextEditComponent extends BaseEditFieldComponent implements OnInit return "Value doesn't match the required pattern"; } - // Create user-friendly messages for common validators const messages = { isEmail: 'Invalid email address', isURL: 'Invalid URL', diff --git a/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.html b/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.html index 3f6f22293..3d61ace1d 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.html @@ -1,39 +1,39 @@
- {{normalizedLabel}} * + {{normalizedLabel()}} @if (required()) { * } years - months - days - hours - minutes - seconds -
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.spec.ts index 2d1d53d27..c07a0fb26 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.spec.ts @@ -15,10 +15,58 @@ describe('TimeIntervalEditComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(TimeIntervalEditComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { + fixture.detectChanges(); expect(component).toBeTruthy(); }); + + it('should have default empty interval values', () => { + expect(component.interval.years).toBe(''); + expect(component.interval.months).toBe(''); + expect(component.interval.days).toBe(''); + expect(component.interval.hours).toBe(''); + expect(component.interval.minutes).toBe(''); + expect(component.interval.seconds).toBe(''); + expect(component.interval.milliseconds).toBe(''); + }); + + it('should assign value to interval on init when value exists', () => { + const intervalValue = { + years: '1', + months: '2', + days: '3', + hours: '4', + minutes: '30', + seconds: '0', + milliseconds: '0', + }; + fixture.componentRef.setInput('value', intervalValue); + component.ngOnInit(); + expect(component.interval).toBe(intervalValue); + }); + + it('should keep default interval when value is falsy on init', () => { + fixture.componentRef.setInput('value', null); + component.ngOnInit(); + expect(component.interval.years).toBe(''); + }); + + it('should emit postgres interval string on input change', () => { + vi.spyOn(component.onFieldChange, 'emit'); + component.interval = { + years: '1', + months: '0', + days: '5', + hours: '0', + minutes: '0', + seconds: '0', + milliseconds: '0', + }; + component.onInputChange(); + expect(component.onFieldChange.emit).toHaveBeenCalled(); + const emittedValue = (component.onFieldChange.emit as any).mock.calls[0][0]; + expect(typeof emittedValue).toBe('string'); + }); }); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.ts index d064675c8..0a87bfc33 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.ts @@ -1,6 +1,6 @@ import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; +import { Component, model } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -14,7 +14,7 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone styleUrls: ['./time-interval.component.css'], }) export class TimeIntervalEditComponent extends BaseEditFieldComponent { - @Input() value; + readonly value = model(); public interval = { years: '', @@ -28,7 +28,7 @@ export class TimeIntervalEditComponent extends BaseEditFieldComponent { ngOnInit(): void { super.ngOnInit(); - if (this.value) this.interval = this.value; + if (this.value()) this.interval = this.value(); } onInputChange() { diff --git a/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.html b/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.html index 26644f2b2..44b2b29f5 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.html @@ -1,7 +1,7 @@ - {{normalizedLabel}} - {{normalizedLabel()}} + diff --git a/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.ts index 62da2f653..6a70d213a 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Component, model } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -11,7 +11,7 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone imports: [MatFormFieldModule, MatInputModule, FormsModule], }) export class TimeEditComponent extends BaseEditFieldComponent { - @Input() value: string; - @Output() onFieldChange = new EventEmitter(); + readonly value = model(); + static type = 'datetime'; } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.html b/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.html index da953d197..839c684fa 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.html @@ -1,15 +1,17 @@ - {{normalizedLabel}} + {{normalizedLabel()}} - - {{timezone.label}} - + @for (timezone of timezones; track timezone.value) { + + {{timezone.label}} + + } - + \ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.spec.ts index 1bf8e97fb..9d40c99d1 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.spec.ts @@ -36,15 +36,15 @@ describe('TimezoneEditComponent', () => { it('should emit value on change', () => { vi.spyOn(component.onFieldChange, 'emit'); const testValue = 'America/New_York'; - component.value = testValue; + fixture.componentRef.setInput('value', testValue); component.onFieldChange.emit(testValue); expect(component.onFieldChange.emit).toHaveBeenCalledWith(testValue); }); it('should add null option when allow_null is true', () => { - component.widgetStructure = { + fixture.componentRef.setInput('widgetStructure', { widget_params: { allow_null: true }, - } as any; + } as any); component.ngOnInit(); const nullOption = component.timezones.find((tz) => tz.value === null); expect(nullOption).toBeDefined(); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.ts index 761960671..ec3e63ad5 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, CUSTOM_ELEMENTS_SCHEMA, Input } from '@angular/core'; +import { Component, CUSTOM_ELEMENTS_SCHEMA, model } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; @@ -14,7 +14,7 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone schemas: [CUSTOM_ELEMENTS_SCHEMA], }) export class TimezoneEditComponent extends BaseEditFieldComponent { - @Input() value: string; + readonly value = model(); public timezones: { value: string; label: string }[] = []; @@ -44,9 +44,10 @@ export class TimezoneEditComponent extends BaseEditFieldComponent { this.timezones.sort((a, b) => a.value.localeCompare(b.value)); // Check widget params for allow_null option - if (this.widgetStructure?.widget_params?.allow_null) { + const ws = this.widgetStructure(); + if (ws?.widget_params?.allow_null) { this.timezones = [{ value: null, label: '' }, ...this.timezones]; - } else if (this.structure?.allow_null) { + } else if (this.structure()?.allow_null) { this.timezones = [{ value: null, label: '' }, ...this.timezones]; } } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.html b/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.html index 1c8da6fbf..4ee0ac040 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.html @@ -1,11 +1,15 @@ - {{normalizedLabel}} - {{prefix}} - {{normalizedLabel()}} + @if (prefix) { + {{prefix}} + } + - URL is invalid. + @if (image.errors?.isInvalidURL) { + URL is invalid. + } \ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.spec.ts index c52f40c1e..9edd8c4a4 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.spec.ts @@ -14,10 +14,49 @@ describe('UrlComponent', () => { fixture = TestBed.createComponent(UrlEditComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should have empty prefix by default', () => { + expect(component.prefix).toBe(''); + }); + + it('should parse prefix from widget params object', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: { prefix: 'https://api.example.com/' } } as any); + component.ngOnInit(); + expect(component.prefix).toBe('https://api.example.com/'); + }); + + it('should parse prefix from widget params string', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: JSON.stringify({ prefix: 'https://test.com/' }) } as any); + component.ngOnInit(); + expect(component.prefix).toBe('https://test.com/'); + }); + + it('should keep empty prefix when widget params have no prefix', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: {} } as any); + component.ngOnInit(); + expect(component.prefix).toBe(''); + }); + + it('should update prefix on ngOnChanges', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: { prefix: 'https://updated.com/' } } as any); + component.ngOnChanges(); + expect(component.prefix).toBe('https://updated.com/'); + }); + + it('should handle invalid JSON in widget params gracefully', () => { + fixture.componentRef.setInput('widgetStructure', { widget_params: 'invalid-json' } as any); + component.ngOnInit(); + expect(component.prefix).toBe(''); + }); + + it('should not change prefix when widgetStructure is undefined', () => { + fixture.componentRef.setInput('widgetStructure', undefined); + component.ngOnInit(); + expect(component.prefix).toBe(''); + }); }); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.ts index 879b9f08e..4d2428727 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, Input, OnInit } from '@angular/core'; +import { Component, model, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -13,7 +13,7 @@ import { BaseEditFieldComponent } from '../base-row-field/base-row-field.compone styleUrl: './url.component.css', }) export class UrlEditComponent extends BaseEditFieldComponent implements OnInit { - @Input() value: string; + readonly value = model(); public prefix: string = ''; ngOnInit(): void { @@ -26,12 +26,10 @@ export class UrlEditComponent extends BaseEditFieldComponent implements OnInit { } private _parseWidgetParams(): void { - if (this.widgetStructure?.widget_params) { + const ws = this.widgetStructure(); + if (ws?.widget_params) { try { - const params = - typeof this.widgetStructure.widget_params === 'string' - ? JSON.parse(this.widgetStructure.widget_params) - : this.widgetStructure.widget_params; + const params = typeof ws.widget_params === 'string' ? JSON.parse(ws.widget_params) : ws.widget_params; if (params.prefix !== undefined) { this.prefix = params.prefix || ''; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/uuid/uuid.component.html b/frontend/src/app/components/ui-components/record-edit-fields/uuid/uuid.component.html index d8972f391..4b28ea60b 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/uuid/uuid.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/uuid/uuid.component.html @@ -1,17 +1,17 @@ - {{ normalizedLabel }} + {{ normalizedLabel() }} - @if (!readonly && !disabled) { + @if (!readonly() && !disabled()) { -
\ No newline at end of file + diff --git a/frontend/src/app/components/ui-components/table-display-fields/color/color.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/color/color.component.spec.ts new file mode 100644 index 000000000..6026f618d --- /dev/null +++ b/frontend/src/app/components/ui-components/table-display-fields/color/color.component.spec.ts @@ -0,0 +1,44 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ColorDisplayComponent } from './color.component'; + +describe('ColorDisplayComponent', () => { + let component: ColorDisplayComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ColorDisplayComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ColorDisplayComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should detect valid color', () => { + fixture.componentRef.setInput('value', '#ff0000'); + fixture.detectChanges(); + + expect(component.isValidColor).toBe(true); + }); + + it('should detect invalid color', () => { + fixture.componentRef.setInput('value', 'not-a-color'); + fixture.detectChanges(); + + expect(component.isValidColor).toBe(false); + }); + + it('should normalize hex color', () => { + fixture.componentRef.setInput('value', 'ff0000'); + fixture.detectChanges(); + + expect(component.normalizedColorForDisplay).toBe('#ff0000'); + }); +}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/color/color.component.ts b/frontend/src/app/components/ui-components/table-display-fields/color/color.component.ts index ec125a11d..7f83e1416 100644 --- a/frontend/src/app/components/ui-components/table-display-fields/color/color.component.ts +++ b/frontend/src/app/components/ui-components/table-display-fields/color/color.component.ts @@ -1,51 +1,51 @@ +import { Component } from '@angular/core'; + +import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; import { ClipboardModule } from '@angular/cdk/clipboard'; -import { NgIf } from '@angular/common'; -import { Component, Injectable } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import colorString from 'color-string'; -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; -@Injectable() @Component({ - selector: 'app-display-color', - templateUrl: './color.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './color.component.css'], - imports: [NgIf, ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule], + selector: 'app-display-color', + templateUrl: './color.component.html', + styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './color.component.css'], + imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] }) export class ColorDisplayComponent extends BaseTableDisplayFieldComponent { - get isValidColor(): boolean { - if (!this.value) return false; - return this.parseColor(this.value) !== null; - } - - get normalizedColorForDisplay(): string { - const parsed = this.parseColor(this.value); - if (parsed) { - const [r, g, b] = parsed.value; - return `#${this.toHex(r)}${this.toHex(g)}${this.toHex(b)}`; - } - return '#000000'; - } - - private parseColor(color: string): any { - if (!color) return null; + get isValidColor(): boolean { + if (!this.value()) return false; + return this.parseColor(this.value()) !== null; + } - // Try parsing with color-string - const parsed = colorString.get(color); - if (parsed) return parsed; + get normalizedColorForDisplay(): string { + const parsed = this.parseColor(this.value()); + if (parsed) { + const [r, g, b] = parsed.value; + return `#${this.toHex(r)}${this.toHex(g)}${this.toHex(b)}`; + } + return '#000000'; + } - // Try hex without hash - if (/^[A-Fa-f0-9]{6}$|^[A-Fa-f0-9]{3}$/.test(color)) { - return colorString.get('#' + color); - } - return null; - } + private parseColor(color: string): any { + if (!color) return null; + + // Try parsing with color-string + const parsed = colorString.get(color); + if (parsed) return parsed; + + // Try hex without hash + if (/^[A-Fa-f0-9]{6}$|^[A-Fa-f0-9]{3}$/.test(color)) { + return colorString.get('#' + color); + } + + return null; + } - private toHex(n: number): string { - const hex = n.toString(16); - return hex.length === 1 ? '0' + hex : hex; - } -} + private toHex(n: number): string { + const hex = n.toString(16); + return hex.length === 1 ? '0' + hex : hex; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/ui-components/table-display-fields/country/country.component.html b/frontend/src/app/components/ui-components/table-display-fields/country/country.component.html index 54639a23b..0be43269a 100644 --- a/frontend/src/app/components/ui-components/table-display-fields/country/country.component.html +++ b/frontend/src/app/components/ui-components/table-display-fields/country/country.component.html @@ -1,6 +1,8 @@
- {{ countryFlag }} + @if (showFlag && countryFlag) { + {{ countryFlag }} + } {{ countryName }} - {{ value || '—' }} + @if (relations() && value()) { + + } @else { + {{ value() || '—' }} + }
- +@if (value()) { +
+
{{ formattedJson }}
+ +
+} diff --git a/frontend/src/app/components/ui-components/table-display-fields/json-editor/json-editor.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/json-editor/json-editor.component.spec.ts new file mode 100644 index 000000000..57e6bac60 --- /dev/null +++ b/frontend/src/app/components/ui-components/table-display-fields/json-editor/json-editor.component.spec.ts @@ -0,0 +1,33 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { JsonEditorDisplayComponent } from './json-editor.component'; + +describe('JsonEditorDisplayComponent', () => { + let component: JsonEditorDisplayComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [JsonEditorDisplayComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(JsonEditorDisplayComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should format JSON value', () => { + fixture.componentRef.setInput('value', '{"name":"test","count":3}'); + expect(component.formattedJson).toBe(JSON.stringify({ name: 'test', count: 3 }, null, 2)); + }); + + it('should handle invalid JSON', () => { + fixture.componentRef.setInput('value', 'not valid json'); + expect(component.formattedJson).toBe('not valid json'); + }); +}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/json-editor/json-editor.component.ts b/frontend/src/app/components/ui-components/table-display-fields/json-editor/json-editor.component.ts index d13b3cd25..bee4bc5fa 100644 --- a/frontend/src/app/components/ui-components/table-display-fields/json-editor/json-editor.component.ts +++ b/frontend/src/app/components/ui-components/table-display-fields/json-editor/json-editor.component.ts @@ -1,26 +1,25 @@ +import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; import { ClipboardModule } from '@angular/cdk/clipboard'; -import { CommonModule } from '@angular/common'; import { Component } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; @Component({ - selector: 'app-json-editor-display', - templateUrl: './json-editor.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './json-editor.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule, CommonModule], + selector: 'app-json-editor-display', + templateUrl: './json-editor.component.html', + styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './json-editor.component.css'], + imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] }) export class JsonEditorDisplayComponent extends BaseTableDisplayFieldComponent { - get formattedJson(): string { - if (!this.value) return ''; + get formattedJson(): string { + if (!this.value()) return ''; - try { - const parsedValue = typeof this.value === 'string' ? JSON.parse(this.value) : this.value; - return JSON.stringify(parsedValue, null, 2); - } catch (_e) { - return String(this.value); - } - } + try { + const parsedValue = typeof this.value() === 'string' ? JSON.parse(this.value()) : this.value(); + return JSON.stringify(parsedValue, null, 2); + } catch (_e) { + return String(this.value()); + } + } } diff --git a/frontend/src/app/components/ui-components/table-display-fields/language/language.component.ts b/frontend/src/app/components/ui-components/table-display-fields/language/language.component.ts index 2d09732cd..078e541f3 100644 --- a/frontend/src/app/components/ui-components/table-display-fields/language/language.component.ts +++ b/frontend/src/app/components/ui-components/table-display-fields/language/language.component.ts @@ -1,5 +1,4 @@ import { ClipboardModule } from '@angular/cdk/clipboard'; -import { CommonModule } from '@angular/common'; import { Component, computed, input, output } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; @@ -11,7 +10,7 @@ import { getLanguageFlag, LANGUAGES } from '../../../../consts/languages'; selector: 'app-language-display', templateUrl: './language.component.html', styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './language.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule, CommonModule], + imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule], }) export class LanguageDisplayComponent { static type = 'language'; diff --git a/frontend/src/app/components/ui-components/table-display-fields/long-text/long-text.component.html b/frontend/src/app/components/ui-components/table-display-fields/long-text/long-text.component.html index 79e15809d..7c16b17dc 100644 --- a/frontend/src/app/components/ui-components/table-display-fields/long-text/long-text.component.html +++ b/frontend/src/app/components/ui-components/table-display-fields/long-text/long-text.component.html @@ -1,10 +1,10 @@
- {{value || '—'}} + {{value() || '—'}} + @if (value()) { + + }
diff --git a/frontend/src/app/components/ui-components/table-display-fields/s3/s3.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/s3/s3.component.spec.ts new file mode 100644 index 000000000..4ebc1c424 --- /dev/null +++ b/frontend/src/app/components/ui-components/table-display-fields/s3/s3.component.spec.ts @@ -0,0 +1,57 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { S3DisplayComponent } from './s3.component'; +import { S3Service } from 'src/app/services/s3.service'; +import { ConnectionsService } from 'src/app/services/connections.service'; +import { TablesService } from 'src/app/services/tables.service'; +import { vi } from 'vitest'; + +describe('S3DisplayComponent', () => { + let component: S3DisplayComponent; + let fixture: ComponentFixture; + + const mockS3Service: Partial = { + getFileUrl: vi.fn(), + }; + + const mockConnectionsService: Partial = { + currentConnectionID: 'test-conn', + }; + + const mockTablesService: Partial = { + currentTableName: 'test-table', + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [S3DisplayComponent], + providers: [ + { provide: S3Service, useValue: mockS3Service }, + { provide: ConnectionsService, useValue: mockConnectionsService }, + { provide: TablesService, useValue: mockTablesService }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(S3DisplayComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should detect image type from widget params', () => { + fixture.componentRef.setInput('widgetStructure', { + widget_params: { + bucket: 'my-bucket', + aws_access_key_id_secret_name: 'key', + aws_secret_access_key_secret_name: 'secret', + type: 'image', + }, + }); + component.ngOnInit(); + expect(component.isImageType).toBe(true); + }); +}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/s3/s3.component.ts b/frontend/src/app/components/ui-components/table-display-fields/s3/s3.component.ts index 475b1af0a..95630e55d 100644 --- a/frontend/src/app/components/ui-components/table-display-fields/s3/s3.component.ts +++ b/frontend/src/app/components/ui-components/table-display-fields/s3/s3.component.ts @@ -1,5 +1,4 @@ import { ClipboardModule } from '@angular/cdk/clipboard'; -import { CommonModule } from '@angular/common'; import { Component, OnInit } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; @@ -23,7 +22,7 @@ interface S3WidgetParams { selector: 'app-s3-display', templateUrl: './s3.component.html', styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './s3.component.css'], - imports: [CommonModule, ClipboardModule, MatButtonModule, MatIconModule, MatProgressSpinnerModule, MatTooltipModule], + imports: [ClipboardModule, MatButtonModule, MatIconModule, MatProgressSpinnerModule, MatTooltipModule], }) export class S3DisplayComponent extends BaseTableDisplayFieldComponent implements OnInit { public params: S3WidgetParams; @@ -46,7 +45,7 @@ export class S3DisplayComponent extends BaseTableDisplayFieldComponent implement this.tableName = this.tablesService.currentTableName; this._parseWidgetParams(); - if (this.value && this.isImageType && this.rowPrimaryKey) { + if (this.value() && this.isImageType && this.rowPrimaryKey) { this._loadPreview(); } } @@ -56,22 +55,22 @@ export class S3DisplayComponent extends BaseTableDisplayFieldComponent implement } get rowPrimaryKey(): Record | null { - if (!this.rowData || !this.primaryKeys) return null; + if (!this.rowData() || !this.primaryKeys()) return null; const primaryKey: Record = {}; - for (const pk of this.primaryKeys) { - primaryKey[pk.column_name] = this.rowData[pk.column_name]; + for (const pk of this.primaryKeys()) { + primaryKey[pk.column_name] = this.rowData()[pk.column_name]; } return primaryKey; } private _parseWidgetParams(): void { - if (this.widgetStructure?.widget_params) { + if (this.widgetStructure()?.widget_params) { try { this.params = - typeof this.widgetStructure.widget_params === 'string' - ? JSON.parse(this.widgetStructure.widget_params) - : this.widgetStructure.widget_params; + typeof this.widgetStructure().widget_params === 'string' + ? JSON.parse(this.widgetStructure().widget_params as unknown as string) + : this.widgetStructure().widget_params; } catch (e) { console.error('Error parsing S3 widget params:', e); } @@ -80,14 +79,14 @@ export class S3DisplayComponent extends BaseTableDisplayFieldComponent implement private async _loadPreview(): Promise { const primaryKey = this.rowPrimaryKey; - if (!this.value || !this.connectionId || !this.tableName || !primaryKey) return; + if (!this.value() || !this.connectionId || !this.tableName || !primaryKey) return; this.isLoading = true; const response = await this.s3Service.getFileUrl( this.connectionId, this.tableName, - this.widgetStructure.field_name, + this.widgetStructure().field_name, primaryKey, ); diff --git a/frontend/src/app/components/ui-components/table-display-fields/select/select.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/select/select.component.spec.ts new file mode 100644 index 000000000..b926c3793 --- /dev/null +++ b/frontend/src/app/components/ui-components/table-display-fields/select/select.component.spec.ts @@ -0,0 +1,52 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SelectDisplayComponent } from './select.component'; + +describe('SelectDisplayComponent', () => { + let component: SelectDisplayComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SelectDisplayComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SelectDisplayComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display option label from widget params', () => { + fixture.componentRef.setInput('value', 'opt1'); + fixture.componentRef.setInput('widgetStructure', { + widget_params: { + options: [ + { value: 'opt1', label: 'Option One' }, + { value: 'opt2', label: 'Option Two' }, + ], + }, + }); + component.ngOnInit(); + fixture.detectChanges(); + expect(component.displayValue).toBe('Option One'); + }); + + it('should display raw value when no options match', () => { + fixture.componentRef.setInput('value', 'unknown'); + fixture.componentRef.setInput('widgetStructure', { + widget_params: { + options: [ + { value: 'opt1', label: 'Option One' }, + ], + }, + }); + component.ngOnInit(); + fixture.detectChanges(); + expect(component.displayValue).toBe('unknown'); + }); +}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/select/select.component.ts b/frontend/src/app/components/ui-components/table-display-fields/select/select.component.ts index b0c1022a1..461dea151 100644 --- a/frontend/src/app/components/ui-components/table-display-fields/select/select.component.ts +++ b/frontend/src/app/components/ui-components/table-display-fields/select/select.component.ts @@ -1,43 +1,43 @@ -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { CommonModule } from '@angular/common'; import { Component, OnInit } from '@angular/core'; + +import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; +import { ClipboardModule } from '@angular/cdk/clipboard'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; @Component({ - selector: 'app-select-display', - templateUrl: './select.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './select.component.css'], - imports: [CommonModule, ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule], + selector: 'app-select-display', + templateUrl: './select.component.html', + styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './select.component.css'], + imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] }) export class SelectDisplayComponent extends BaseTableDisplayFieldComponent implements OnInit { - public displayValue: string; - public backgroundColor: string; + public displayValue: string; + public backgroundColor: string; - ngOnInit(): void { - this.setDisplayValue(); - } + ngOnInit(): void { + this.setDisplayValue(); + } - private setDisplayValue(): void { - if (!this.value) { - this.displayValue = '—'; - return; - } + private setDisplayValue(): void { + if (!this.value()) { + this.displayValue = '—'; + return; + } - if (this.widgetStructure?.widget_params?.options) { - // Find the matching option based on value and use its label - const option = this.widgetStructure.widget_params.options.find( - (opt: { value: any; label: string }) => opt.value === this.value, - ); - this.displayValue = option ? option.label : this.value; - this.backgroundColor = option?.background_color ? option.background_color : 'transparent'; - } else if (this.structure?.data_type_params) { - // If no widget structure but we have data_type_params, just use the value - this.displayValue = this.value; - } else { - this.displayValue = this.value; - } - } + if (this.widgetStructure()?.widget_params?.options) { + // Find the matching option based on value and use its label + const option = this.widgetStructure().widget_params.options.find( + (opt: { value: any, label: string }) => opt.value === this.value() + ); + this.displayValue = option ? option.label : this.value(); + this.backgroundColor = option?.background_color ? option.background_color : 'transparent'; + } else if (this.structure()?.data_type_params) { + // If no widget structure but we have data_type_params, just use the value + this.displayValue = this.value(); + } else { + this.displayValue = this.value(); + } + } } diff --git a/frontend/src/app/components/ui-components/table-display-fields/static-text/static-text.component.html b/frontend/src/app/components/ui-components/table-display-fields/static-text/static-text.component.html index acf48b2d5..479ddf07a 100644 --- a/frontend/src/app/components/ui-components/table-display-fields/static-text/static-text.component.html +++ b/frontend/src/app/components/ui-components/table-display-fields/static-text/static-text.component.html @@ -1,9 +1,9 @@
- {{value || '—'}} + {{value() || '—'}}