Skip to content

Commit 291affd

Browse files
guguclaude
andcommitted
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 <[email protected]>
1 parent 35acfeb commit 291affd

File tree

11 files changed

+346
-323
lines changed

11 files changed

+346
-323
lines changed

frontend/CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ Database configurations are defined in `src/app/consts/databases.ts`.
140140

141141
## 📝 Code Conventions
142142

143+
### Template Syntax
144+
- **Use built-in control flow** (`@if`, `@for`, `@switch`) instead of structural directives (`*ngIf`, `*ngFor`, `*ngSwitch`) in all new code
145+
- Example: `@if (condition) { ... }` instead of `<div *ngIf="condition">...</div>`
146+
- Example: `@for (item of items; track item.id) { ... }` instead of `<div *ngFor="let item of items">...</div>`
147+
143148
### Naming Conventions
144149
- **Files**: `kebab-case.component.ts`
145150
- **Classes**: `PascalCase` (e.g., `DbTableSettingsComponent`)
Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,32 @@
11
<mat-form-field class="language-form-field" appearance="outline">
2-
<mat-label>{{normalizedLabel}}</mat-label>
2+
<mat-label>{{normalizedLabel()}}</mat-label>
33
<div class="language-input-container">
4-
<span *ngIf="selectedLanguageFlag && showFlag" class="language-flag-prefix">{{selectedLanguageFlag}}</span>
4+
@if (selectedLanguageFlag() && showFlag()) {
5+
<span class="language-flag-prefix">{{selectedLanguageFlag()}}</span>
6+
}
57
<input type="text" matInput
6-
[required]="required" [disabled]="disabled" [readonly]="readonly"
7-
attr.data-testid="record-{{label}}-language"
8+
[required]="required()" [disabled]="disabled()" [readonly]="readonly()"
9+
attr.data-testid="record-{{label()}}-language"
810
[formControl]="languageControl"
911
[matAutocomplete]="auto"
1012
class="language-input">
1113
</div>
1214
<mat-autocomplete #auto="matAutocomplete" [displayWith]="displayFn">
13-
<mat-option *ngFor="let language of filteredLanguages | async"
14-
[value]="language"
15-
(onSelectionChange)="language && onLanguageSelected(language)">
16-
<span *ngIf="language.flag && showFlag" class="language-flag">{{language.flag}}</span>
17-
<span class="language-name">{{language.label}}</span>
18-
<span *ngIf="language.nativeName && language.label !== language.nativeName" class="language-native">{{language.nativeName}}</span>
19-
<span *ngIf="language.value" class="language-code">({{language.value}})</span>
20-
</mat-option>
15+
@for (language of filteredLanguages | async; track language.value) {
16+
<mat-option
17+
[value]="language"
18+
(onSelectionChange)="language && onLanguageSelected(language)">
19+
@if (language.flag && showFlag()) {
20+
<span class="language-flag">{{language.flag}}</span>
21+
}
22+
<span class="language-name">{{language.label}}</span>
23+
@if (language.nativeName && language.label !== language.nativeName) {
24+
<span class="language-native">{{language.nativeName}}</span>
25+
}
26+
@if (language.value) {
27+
<span class="language-code">({{language.value}})</span>
28+
}
29+
</mat-option>
30+
}
2131
</mat-autocomplete>
2232
</mat-form-field>
Lines changed: 40 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,45 @@
1+
import { provideHttpClient } from '@angular/common/http';
12
import { ComponentFixture, TestBed } from '@angular/core/testing';
2-
3-
import { LanguageEditComponent } from './language.component';
43
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
5-
import { provideHttpClient } from '@angular/common/http';
4+
import { LanguageEditComponent } from './language.component';
65

76
describe('LanguageEditComponent', () => {
8-
let component: LanguageEditComponent;
9-
let fixture: ComponentFixture<LanguageEditComponent>;
10-
11-
beforeEach(async () => {
12-
await TestBed.configureTestingModule({
13-
imports: [
14-
LanguageEditComponent,
15-
BrowserAnimationsModule
16-
],
17-
providers: [provideHttpClient()]
18-
}).compileComponents();
19-
});
20-
21-
beforeEach(() => {
22-
fixture = TestBed.createComponent(LanguageEditComponent);
23-
component = fixture.componentInstance;
24-
component.widgetStructure = { widget_params: {} } as any;
25-
fixture.detectChanges();
26-
});
27-
28-
it('should create', () => {
29-
expect(component).toBeTruthy();
30-
});
31-
32-
it('should load languages on init', () => {
33-
component.ngOnInit();
34-
expect(component.languages.length).toBeGreaterThan(0);
35-
});
36-
37-
it('should set initial value when value is provided', () => {
38-
component.value = 'en';
39-
component.ngOnInit();
40-
expect(component.selectedLanguageFlag).toBeTruthy();
41-
});
42-
43-
it('should parse widget params for show_flag', () => {
44-
component.widgetStructure = {
45-
widget_params: { show_flag: false }
46-
} as any;
47-
component.ngOnInit();
48-
expect(component.showFlag).toBe(false);
49-
});
7+
let component: LanguageEditComponent;
8+
let fixture: ComponentFixture<LanguageEditComponent>;
9+
10+
beforeEach(async () => {
11+
await TestBed.configureTestingModule({
12+
imports: [LanguageEditComponent, BrowserAnimationsModule],
13+
providers: [provideHttpClient()],
14+
}).compileComponents();
15+
});
16+
17+
beforeEach(() => {
18+
fixture = TestBed.createComponent(LanguageEditComponent);
19+
component = fixture.componentInstance;
20+
fixture.componentRef.setInput('widgetStructure', { widget_params: {} });
21+
fixture.detectChanges();
22+
});
23+
24+
it('should create', () => {
25+
expect(component).toBeTruthy();
26+
});
27+
28+
it('should load languages on init', () => {
29+
expect(component.languages.length).toBeGreaterThan(0);
30+
});
31+
32+
it('should set initial value when value is provided', () => {
33+
fixture.componentRef.setInput('value', 'en');
34+
component.ngOnInit();
35+
expect(component.selectedLanguageFlag()).toBeTruthy();
36+
});
37+
38+
it('should parse widget params for show_flag', () => {
39+
fixture.componentRef.setInput('widgetStructure', {
40+
widget_params: { show_flag: false },
41+
});
42+
fixture.detectChanges();
43+
expect(component.showFlag()).toBe(false);
44+
});
5045
});
Lines changed: 105 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,116 +1,126 @@
1-
import { LANGUAGES, getLanguageFlag, } from '../../../../consts/languages';
2-
import { CUSTOM_ELEMENTS_SCHEMA, Component, Input } from '@angular/core';
3-
import { map, startWith } from 'rxjs/operators';
4-
5-
import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component';
61
import { CommonModule } from '@angular/common';
7-
import { FormControl } from '@angular/forms';
8-
import { FormsModule } from '@angular/forms';
2+
import { Component, CUSTOM_ELEMENTS_SCHEMA, computed, input, OnInit, output, signal } from '@angular/core';
3+
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
94
import { MatAutocompleteModule } from '@angular/material/autocomplete';
105
import { MatFormFieldModule } from '@angular/material/form-field';
116
import { MatInputModule } from '@angular/material/input';
127
import { Observable } from 'rxjs';
13-
import { ReactiveFormsModule } from '@angular/forms';
8+
import { map, startWith } from 'rxjs/operators';
9+
import { TableField, TableForeignKey, WidgetStructure } from 'src/app/models/table';
10+
import { getLanguageFlag, LANGUAGES } from '../../../../consts/languages';
11+
import { normalizeFieldName } from '../../../../lib/normalize';
12+
13+
interface LanguageOption {
14+
value: string | null;
15+
label: string;
16+
flag: string;
17+
nativeName?: string;
18+
}
1419

1520
@Component({
16-
selector: 'app-edit-language',
17-
imports: [CommonModule, FormsModule, ReactiveFormsModule, MatFormFieldModule, MatAutocompleteModule, MatInputModule],
18-
templateUrl: './language.component.html',
19-
styleUrls: ['./language.component.css'],
20-
schemas: [CUSTOM_ELEMENTS_SCHEMA]
21+
selector: 'app-edit-language',
22+
imports: [CommonModule, FormsModule, ReactiveFormsModule, MatFormFieldModule, MatAutocompleteModule, MatInputModule],
23+
templateUrl: './language.component.html',
24+
styleUrls: ['./language.component.css'],
25+
schemas: [CUSTOM_ELEMENTS_SCHEMA],
2126
})
22-
export class LanguageEditComponent extends BaseEditFieldComponent {
23-
@Input() value: string;
27+
export class LanguageEditComponent implements OnInit {
28+
readonly key = input<string>();
29+
readonly label = input<string>();
30+
readonly required = input<boolean>(false);
31+
readonly readonly = input<boolean>(false);
32+
readonly structure = input<TableField>();
33+
readonly disabled = input<boolean>(false);
34+
readonly widgetStructure = input<WidgetStructure>();
35+
readonly relations = input<TableForeignKey>();
36+
readonly value = input<string>();
37+
38+
readonly onFieldChange = output<any>();
2439

25-
public languages: {value: string | null, label: string, flag: string, nativeName?: string}[] = [];
26-
public languageControl = new FormControl<{value: string | null, label: string, flag: string, nativeName?: string} | string>('');
27-
public filteredLanguages: Observable<{value: string | null, label: string, flag: string, nativeName?: string}[]>;
28-
public showFlag: boolean = true;
29-
public selectedLanguageFlag: string = '';
40+
readonly normalizedLabel = computed(() => normalizeFieldName(this.label() || ''));
3041

31-
originalOrder = () => { return 0; }
42+
readonly showFlag = computed(() => {
43+
const ws = this.widgetStructure();
44+
if (!ws?.widget_params) return true;
45+
try {
46+
const params = typeof ws.widget_params === 'string' ? JSON.parse(ws.widget_params) : ws.widget_params;
47+
return params.show_flag !== undefined ? params.show_flag : true;
48+
} catch {
49+
return true;
50+
}
51+
});
3252

33-
ngOnInit(): void {
34-
super.ngOnInit();
35-
this.parseWidgetParams();
36-
this.loadLanguages();
37-
this.setupAutocomplete();
38-
this.setInitialValue();
39-
}
53+
public languages: LanguageOption[] = [];
54+
public languageControl = new FormControl<LanguageOption | string>('');
55+
public filteredLanguages: Observable<LanguageOption[]>;
56+
public selectedLanguageFlag = signal('');
4057

41-
private parseWidgetParams(): void {
42-
if (this.widgetStructure?.widget_params) {
43-
try {
44-
const params = typeof this.widgetStructure.widget_params === 'string'
45-
? JSON.parse(this.widgetStructure.widget_params)
46-
: this.widgetStructure.widget_params;
58+
originalOrder = () => {
59+
return 0;
60+
};
4761

48-
if (params.show_flag !== undefined) {
49-
this.showFlag = params.show_flag;
50-
}
51-
} catch (e) {
52-
console.error('Error parsing language widget params:', e);
53-
}
54-
}
55-
}
62+
ngOnInit(): void {
63+
this.loadLanguages();
64+
this.setupAutocomplete();
65+
this.setInitialValue();
66+
}
5667

57-
private setupAutocomplete(): void {
58-
this.filteredLanguages = this.languageControl.valueChanges.pipe(
59-
startWith(''),
60-
map(value => {
61-
// Update flag when value changes
62-
if (typeof value === 'object' && value !== null) {
63-
this.selectedLanguageFlag = value.flag;
64-
} else if (typeof value === 'string') {
65-
// Clear flag if user is typing
66-
this.selectedLanguageFlag = '';
67-
}
68-
return this._filter(typeof value === 'string' ? value : (value?.label || ''));
69-
})
70-
);
71-
}
68+
displayFn(language: any): string {
69+
if (!language) return '';
70+
return typeof language === 'string' ? language : language.label;
71+
}
7272

73-
private setInitialValue(): void {
74-
if (this.value) {
75-
const language = this.languages.find(l => l.value && l.value.toLowerCase() === this.value.toLowerCase());
76-
if (language) {
77-
this.languageControl.setValue(language);
78-
this.selectedLanguageFlag = language.flag;
79-
}
80-
}
81-
}
73+
onLanguageSelected(selectedLanguage: LanguageOption): void {
74+
this.selectedLanguageFlag.set(selectedLanguage.flag);
75+
this.onFieldChange.emit(selectedLanguage.value);
76+
}
8277

83-
private _filter(value: string): {value: string | null, label: string, flag: string, nativeName?: string}[] {
84-
const filterValue = value.toLowerCase();
85-
return this.languages.filter(language =>
86-
language.label?.toLowerCase().includes(filterValue) ||
87-
(language.value?.toLowerCase().includes(filterValue)) ||
88-
(language.nativeName?.toLowerCase().includes(filterValue))
89-
);
90-
}
78+
private setupAutocomplete(): void {
79+
this.filteredLanguages = this.languageControl.valueChanges.pipe(
80+
startWith(''),
81+
map((value) => {
82+
if (typeof value === 'object' && value !== null) {
83+
this.selectedLanguageFlag.set(value.flag);
84+
} else if (typeof value === 'string') {
85+
this.selectedLanguageFlag.set('');
86+
}
87+
return this._filter(typeof value === 'string' ? value : value?.label || '');
88+
}),
89+
);
90+
}
9191

92-
onLanguageSelected(selectedLanguage: {value: string | null, label: string, flag: string, nativeName?: string}): void {
93-
this.value = selectedLanguage.value;
94-
this.selectedLanguageFlag = selectedLanguage.flag;
95-
this.onFieldChange.emit(this.value);
96-
}
92+
private setInitialValue(): void {
93+
const val = this.value();
94+
if (val) {
95+
const language = this.languages.find((l) => l.value && l.value.toLowerCase() === val.toLowerCase());
96+
if (language) {
97+
this.languageControl.setValue(language);
98+
this.selectedLanguageFlag.set(language.flag);
99+
}
100+
}
101+
}
97102

98-
displayFn(language: any): string {
99-
if (!language) return '';
100-
// Only return the language label, flag is shown separately
101-
return typeof language === 'string' ? language : language.label;
102-
}
103+
private _filter(value: string): LanguageOption[] {
104+
const filterValue = value.toLowerCase();
105+
return this.languages.filter(
106+
(language) =>
107+
language.label?.toLowerCase().includes(filterValue) ||
108+
language.value?.toLowerCase().includes(filterValue) ||
109+
language.nativeName?.toLowerCase().includes(filterValue),
110+
);
111+
}
103112

104-
private loadLanguages(): void {
105-
this.languages = LANGUAGES.map(language => ({
106-
value: language.code,
107-
label: language.name,
108-
flag: getLanguageFlag(language),
109-
nativeName: language.nativeName
110-
})).toSorted((a, b) => a.label.localeCompare(b.label));
113+
private loadLanguages(): void {
114+
this.languages = LANGUAGES.map((language) => ({
115+
value: language.code,
116+
label: language.name,
117+
flag: getLanguageFlag(language),
118+
nativeName: language.nativeName,
119+
})).toSorted((a, b) => a.label.localeCompare(b.label));
111120

112-
if (this.widgetStructure?.widget_params?.allow_null || this.structure?.allow_null) {
113-
this.languages = [{ value: null, label: '', flag: '' }, ...this.languages];
114-
}
115-
}
121+
const ws = this.widgetStructure();
122+
if (ws?.widget_params?.allow_null || this.structure()?.allow_null) {
123+
this.languages = [{ value: null, label: '', flag: '' }, ...this.languages];
124+
}
125+
}
116126
}

0 commit comments

Comments
 (0)