From 291affddfd4cf8d8ff0e9324acca2ee75385879b Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Tue, 27 Jan 2026 12:28:59 +0000 Subject: [PATCH 1/2] Migrate language widget to signals and adopt @if/@for template syntax Migrate all three language widget components (display, record-view, edit) from decorator-based @Input/@Output to Angular signal APIs (input, output, computed, signal). Update S3 widget template and CLAUDE.md coding conventions to require built-in control flow (@if, @for) over structural directives. Co-Authored-By: Claude Opus 4.5 --- frontend/CLAUDE.md | 5 + .../language/language.component.html | 34 +-- .../language/language.component.spec.ts | 85 ++++---- .../language/language.component.ts | 200 +++++++++--------- .../record-edit-fields/s3/s3.component.html | 38 ++-- .../language/language.component.html | 6 +- .../language/language.component.spec.ts | 55 +++-- .../language/language.component.ts | 79 ++++--- .../language/language.component.html | 8 +- .../language/language.component.spec.ts | 81 ++++--- .../language/language.component.ts | 78 ++++--- 11 files changed, 346 insertions(+), 323 deletions(-) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index dead28afa..86ae600ea 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -140,6 +140,11 @@ Database configurations are defined in `src/app/consts/databases.ts`. ## 📝 Code Conventions +### Template Syntax +- **Use built-in control flow** (`@if`, `@for`, `@switch`) instead of structural directives (`*ngIf`, `*ngFor`, `*ngSwitch`) in all new code +- Example: `@if (condition) { ... }` instead of `
...
` +- Example: `@for (item of items; track item.id) { ... }` instead of `
...
` + ### Naming Conventions - **Files**: `kebab-case.component.ts` - **Classes**: `PascalCase` (e.g., `DbTableSettingsComponent`) diff --git a/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.html b/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.html index 7a6570ebb..7f25a437e 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.html @@ -1,22 +1,32 @@ - {{normalizedLabel}} + {{normalizedLabel()}}
- {{selectedLanguageFlag}} + @if (selectedLanguageFlag() && showFlag()) { + {{selectedLanguageFlag()}} + }
- - {{language.flag}} - {{language.label}} - {{language.nativeName}} - ({{language.value}}) - + @for (language of filteredLanguages | async; track language.value) { + + @if (language.flag && showFlag()) { + {{language.flag}} + } + {{language.label}} + @if (language.nativeName && language.label !== language.nativeName) { + {{language.nativeName}} + } + @if (language.value) { + ({{language.value}}) + } + + }
diff --git a/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.spec.ts index 649add210..caa590924 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.spec.ts @@ -1,50 +1,45 @@ +import { provideHttpClient } from '@angular/common/http'; import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { LanguageEditComponent } from './language.component'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideHttpClient } from '@angular/common/http'; +import { LanguageEditComponent } from './language.component'; describe('LanguageEditComponent', () => { - let component: LanguageEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - LanguageEditComponent, - BrowserAnimationsModule - ], - providers: [provideHttpClient()] - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(LanguageEditComponent); - component = fixture.componentInstance; - component.widgetStructure = { widget_params: {} } as any; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should load languages on init', () => { - component.ngOnInit(); - expect(component.languages.length).toBeGreaterThan(0); - }); - - it('should set initial value when value is provided', () => { - component.value = 'en'; - component.ngOnInit(); - expect(component.selectedLanguageFlag).toBeTruthy(); - }); - - it('should parse widget params for show_flag', () => { - component.widgetStructure = { - widget_params: { show_flag: false } - } as any; - component.ngOnInit(); - expect(component.showFlag).toBe(false); - }); + let component: LanguageEditComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LanguageEditComponent, BrowserAnimationsModule], + providers: [provideHttpClient()], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(LanguageEditComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('widgetStructure', { widget_params: {} }); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should load languages on init', () => { + expect(component.languages.length).toBeGreaterThan(0); + }); + + it('should set initial value when value is provided', () => { + fixture.componentRef.setInput('value', 'en'); + component.ngOnInit(); + expect(component.selectedLanguageFlag()).toBeTruthy(); + }); + + it('should parse widget params for show_flag', () => { + fixture.componentRef.setInput('widgetStructure', { + widget_params: { show_flag: false }, + }); + fixture.detectChanges(); + expect(component.showFlag()).toBe(false); + }); }); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.ts index 59a71d0e5..65ee3dc33 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.ts +++ b/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.ts @@ -1,116 +1,126 @@ -import { LANGUAGES, getLanguageFlag, } from '../../../../consts/languages'; -import { CUSTOM_ELEMENTS_SCHEMA, Component, Input } from '@angular/core'; -import { map, startWith } from 'rxjs/operators'; - -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; import { CommonModule } from '@angular/common'; -import { FormControl } from '@angular/forms'; -import { FormsModule } from '@angular/forms'; +import { Component, CUSTOM_ELEMENTS_SCHEMA, computed, input, OnInit, output, signal } from '@angular/core'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { Observable } from 'rxjs'; -import { ReactiveFormsModule } from '@angular/forms'; +import { map, startWith } from 'rxjs/operators'; +import { TableField, TableForeignKey, WidgetStructure } from 'src/app/models/table'; +import { getLanguageFlag, LANGUAGES } from '../../../../consts/languages'; +import { normalizeFieldName } from '../../../../lib/normalize'; + +interface LanguageOption { + value: string | null; + label: string; + flag: string; + nativeName?: string; +} @Component({ - selector: 'app-edit-language', - imports: [CommonModule, FormsModule, ReactiveFormsModule, MatFormFieldModule, MatAutocompleteModule, MatInputModule], - templateUrl: './language.component.html', - styleUrls: ['./language.component.css'], - schemas: [CUSTOM_ELEMENTS_SCHEMA] + selector: 'app-edit-language', + imports: [CommonModule, FormsModule, ReactiveFormsModule, MatFormFieldModule, MatAutocompleteModule, MatInputModule], + templateUrl: './language.component.html', + styleUrls: ['./language.component.css'], + schemas: [CUSTOM_ELEMENTS_SCHEMA], }) -export class LanguageEditComponent extends BaseEditFieldComponent { - @Input() value: string; +export class LanguageEditComponent implements OnInit { + 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(); + readonly value = input(); + + readonly onFieldChange = output(); - public languages: {value: string | null, label: string, flag: string, nativeName?: string}[] = []; - public languageControl = new FormControl<{value: string | null, label: string, flag: string, nativeName?: string} | string>(''); - public filteredLanguages: Observable<{value: string | null, label: string, flag: string, nativeName?: string}[]>; - public showFlag: boolean = true; - public selectedLanguageFlag: string = ''; + readonly normalizedLabel = computed(() => normalizeFieldName(this.label() || '')); - originalOrder = () => { return 0; } + readonly showFlag = computed(() => { + const ws = this.widgetStructure(); + if (!ws?.widget_params) return true; + try { + const params = typeof ws.widget_params === 'string' ? JSON.parse(ws.widget_params) : ws.widget_params; + return params.show_flag !== undefined ? params.show_flag : true; + } catch { + return true; + } + }); - ngOnInit(): void { - super.ngOnInit(); - this.parseWidgetParams(); - this.loadLanguages(); - this.setupAutocomplete(); - this.setInitialValue(); - } + public languages: LanguageOption[] = []; + public languageControl = new FormControl(''); + public filteredLanguages: Observable; + public selectedLanguageFlag = signal(''); - private parseWidgetParams(): void { - if (this.widgetStructure?.widget_params) { - try { - const params = typeof this.widgetStructure.widget_params === 'string' - ? JSON.parse(this.widgetStructure.widget_params) - : this.widgetStructure.widget_params; + originalOrder = () => { + return 0; + }; - if (params.show_flag !== undefined) { - this.showFlag = params.show_flag; - } - } catch (e) { - console.error('Error parsing language widget params:', e); - } - } - } + ngOnInit(): void { + this.loadLanguages(); + this.setupAutocomplete(); + this.setInitialValue(); + } - private setupAutocomplete(): void { - this.filteredLanguages = this.languageControl.valueChanges.pipe( - startWith(''), - map(value => { - // Update flag when value changes - if (typeof value === 'object' && value !== null) { - this.selectedLanguageFlag = value.flag; - } else if (typeof value === 'string') { - // Clear flag if user is typing - this.selectedLanguageFlag = ''; - } - return this._filter(typeof value === 'string' ? value : (value?.label || '')); - }) - ); - } + displayFn(language: any): string { + if (!language) return ''; + return typeof language === 'string' ? language : language.label; + } - private setInitialValue(): void { - if (this.value) { - const language = this.languages.find(l => l.value && l.value.toLowerCase() === this.value.toLowerCase()); - if (language) { - this.languageControl.setValue(language); - this.selectedLanguageFlag = language.flag; - } - } - } + onLanguageSelected(selectedLanguage: LanguageOption): void { + this.selectedLanguageFlag.set(selectedLanguage.flag); + this.onFieldChange.emit(selectedLanguage.value); + } - private _filter(value: string): {value: string | null, label: string, flag: string, nativeName?: string}[] { - const filterValue = value.toLowerCase(); - return this.languages.filter(language => - language.label?.toLowerCase().includes(filterValue) || - (language.value?.toLowerCase().includes(filterValue)) || - (language.nativeName?.toLowerCase().includes(filterValue)) - ); - } + private setupAutocomplete(): void { + this.filteredLanguages = this.languageControl.valueChanges.pipe( + startWith(''), + map((value) => { + if (typeof value === 'object' && value !== null) { + this.selectedLanguageFlag.set(value.flag); + } else if (typeof value === 'string') { + this.selectedLanguageFlag.set(''); + } + return this._filter(typeof value === 'string' ? value : value?.label || ''); + }), + ); + } - onLanguageSelected(selectedLanguage: {value: string | null, label: string, flag: string, nativeName?: string}): void { - this.value = selectedLanguage.value; - this.selectedLanguageFlag = selectedLanguage.flag; - this.onFieldChange.emit(this.value); - } + private setInitialValue(): void { + const val = this.value(); + if (val) { + const language = this.languages.find((l) => l.value && l.value.toLowerCase() === val.toLowerCase()); + if (language) { + this.languageControl.setValue(language); + this.selectedLanguageFlag.set(language.flag); + } + } + } - displayFn(language: any): string { - if (!language) return ''; - // Only return the language label, flag is shown separately - return typeof language === 'string' ? language : language.label; - } + private _filter(value: string): LanguageOption[] { + const filterValue = value.toLowerCase(); + return this.languages.filter( + (language) => + language.label?.toLowerCase().includes(filterValue) || + language.value?.toLowerCase().includes(filterValue) || + language.nativeName?.toLowerCase().includes(filterValue), + ); + } - private loadLanguages(): void { - this.languages = LANGUAGES.map(language => ({ - value: language.code, - label: language.name, - flag: getLanguageFlag(language), - nativeName: language.nativeName - })).toSorted((a, b) => a.label.localeCompare(b.label)); + private loadLanguages(): void { + this.languages = LANGUAGES.map((language) => ({ + value: language.code, + label: language.name, + flag: getLanguageFlag(language), + nativeName: language.nativeName, + })).toSorted((a, b) => a.label.localeCompare(b.label)); - if (this.widgetStructure?.widget_params?.allow_null || this.structure?.allow_null) { - this.languages = [{ value: null, label: '', flag: '' }, ...this.languages]; - } - } + const ws = this.widgetStructure(); + if (ws?.widget_params?.allow_null || this.structure()?.allow_null) { + this.languages = [{ value: null, label: '', flag: '' }, ...this.languages]; + } + } } diff --git a/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.html b/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.html index fbf37ce73..c61abb7f4 100644 --- a/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.html +++ b/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.html @@ -15,23 +15,29 @@ upload Upload - + @if (previewUrl()) { + + } -
- - Preview -
- insert_drive_file - {{(internalValue() || value()) | slice:-30}} + @if (internalValue() || value()) { +
+ @if (isLoading()) { + + } @else if (isImage() && previewUrl()) { + Preview + } @else if (previewUrl()) { +
+ insert_drive_file + {{(internalValue() || value()) | slice:-30}} +
+ }
-
+ }
diff --git a/frontend/src/app/components/ui-components/record-view-fields/language/language.component.html b/frontend/src/app/components/ui-components/record-view-fields/language/language.component.html index 0615634dd..c09ca8c93 100644 --- a/frontend/src/app/components/ui-components/record-view-fields/language/language.component.html +++ b/frontend/src/app/components/ui-components/record-view-fields/language/language.component.html @@ -1,4 +1,6 @@ - {{ languageFlag }} - {{ languageName }} + @if (showFlag() && languageFlag()) { + {{ languageFlag() }} + } + {{ languageName() }} diff --git a/frontend/src/app/components/ui-components/record-view-fields/language/language.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/language/language.component.spec.ts index 08ef242f0..00f7346b2 100644 --- a/frontend/src/app/components/ui-components/record-view-fields/language/language.component.spec.ts +++ b/frontend/src/app/components/ui-components/record-view-fields/language/language.component.spec.ts @@ -1,38 +1,37 @@ +import { provideHttpClient } from '@angular/common/http'; import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { LanguageRecordViewComponent } from './language.component'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideHttpClient } from '@angular/common/http'; +import { LanguageRecordViewComponent } from './language.component'; describe('LanguageRecordViewComponent', () => { - let component: LanguageRecordViewComponent; - let fixture: ComponentFixture; + let component: LanguageRecordViewComponent; + let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [LanguageRecordViewComponent, BrowserAnimationsModule], - providers: [provideHttpClient()] - }).compileComponents(); + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LanguageRecordViewComponent, BrowserAnimationsModule], + providers: [provideHttpClient()], + }).compileComponents(); - fixture = TestBed.createComponent(LanguageRecordViewComponent); - component = fixture.componentInstance; - component.value = 'en'; - fixture.detectChanges(); - }); + fixture = TestBed.createComponent(LanguageRecordViewComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('value', 'en'); + fixture.detectChanges(); + }); - it('should create', () => { - expect(component).toBeTruthy(); - }); + it('should create', () => { + expect(component).toBeTruthy(); + }); - it('should display language name from code', () => { - component.value = 'en'; - component.ngOnInit(); - expect(component.languageName).toBe('English'); - }); + it('should display language name from code', () => { + fixture.componentRef.setInput('value', 'en'); + fixture.detectChanges(); + expect(component.languageName()).toBe('English'); + }); - it('should display em dash when value is empty', () => { - component.value = null; - component.ngOnInit(); - expect(component.languageName).toBe('—'); - }); + it('should display em dash when value is empty', () => { + fixture.componentRef.setInput('value', null); + fixture.detectChanges(); + expect(component.languageName()).toBe('—'); + }); }); diff --git a/frontend/src/app/components/ui-components/record-view-fields/language/language.component.ts b/frontend/src/app/components/ui-components/record-view-fields/language/language.component.ts index 656ae30cc..2a15ed68e 100644 --- a/frontend/src/app/components/ui-components/record-view-fields/language/language.component.ts +++ b/frontend/src/app/components/ui-components/record-view-fields/language/language.component.ts @@ -1,51 +1,48 @@ -import { LANGUAGES, getLanguageFlag } from '../../../../consts/languages'; -import { Component, Injectable, OnInit } from '@angular/core'; - -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; import { CommonModule } from '@angular/common'; +import { Component, computed, input, output } from '@angular/core'; +import { TableField, WidgetStructure } from 'src/app/models/table'; +import { getLanguageFlag, LANGUAGES } from '../../../../consts/languages'; -@Injectable() @Component({ - selector: 'app-language-record-view', - templateUrl: './language.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './language.component.css'], - imports: [CommonModule] + selector: 'app-language-record-view', + templateUrl: './language.component.html', + styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './language.component.css'], + imports: [CommonModule], }) -export class LanguageRecordViewComponent extends BaseRecordViewFieldComponent implements OnInit { - static type = 'language'; +export class LanguageRecordViewComponent { + static type = 'language'; - public languageName: string = ''; - public languageFlag: string = ''; - public showFlag: boolean = true; + readonly key = input(); + readonly value = input(); + readonly structure = input(); + readonly widgetStructure = input(); + readonly rowData = input>(); + readonly primaryKeys = input>(); - ngOnInit(): void { - this.parseWidgetParams(); + readonly onCopyToClipboard = output(); - if (this.value) { - const language = LANGUAGES.find(l => l.code.toLowerCase() === this.value.toLowerCase()); - this.languageName = language ? language.name : this.value; - if (language) { - this.languageFlag = getLanguageFlag(language); - } - } else { - this.languageName = '—'; - this.languageFlag = ''; - } - } + readonly showFlag = computed(() => { + const ws = this.widgetStructure(); + if (!ws?.widget_params) return true; + try { + const params = typeof ws.widget_params === 'string' ? JSON.parse(ws.widget_params) : ws.widget_params; + return params.show_flag !== undefined ? params.show_flag : true; + } catch { + return true; + } + }); - private parseWidgetParams(): void { - if (this.widgetStructure?.widget_params) { - try { - const params = typeof this.widgetStructure.widget_params === 'string' - ? JSON.parse(this.widgetStructure.widget_params) - : this.widgetStructure.widget_params; + readonly languageName = computed(() => { + const val = this.value(); + if (!val) return '—'; + const language = LANGUAGES.find((l) => l.code.toLowerCase() === val.toLowerCase()); + return language ? language.name : val; + }); - if (params.show_flag !== undefined) { - this.showFlag = params.show_flag; - } - } catch (e) { - console.error('Error parsing language widget params:', e); - } - } - } + readonly languageFlag = computed(() => { + const val = this.value(); + if (!val) return ''; + const language = LANGUAGES.find((l) => l.code.toLowerCase() === val.toLowerCase()); + return language ? getLanguageFlag(language) : ''; + }); } diff --git a/frontend/src/app/components/ui-components/table-display-fields/language/language.component.html b/frontend/src/app/components/ui-components/table-display-fields/language/language.component.html index 85f344d1e..4da896023 100644 --- a/frontend/src/app/components/ui-components/table-display-fields/language/language.component.html +++ b/frontend/src/app/components/ui-components/table-display-fields/language/language.component.html @@ -1,12 +1,14 @@
- {{ languageFlag }} - {{ languageName }} + @if (showFlag() && languageFlag()) { + {{ languageFlag() }} + } + {{ languageName() }}