Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions frontend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<div *ngIf="condition">...</div>`
- Example: `@for (item of items; track item.id) { ... }` instead of `<div *ngFor="let item of items">...</div>`

### Naming Conventions
- **Files**: `kebab-case.component.ts`
- **Classes**: `PascalCase` (e.g., `DbTableSettingsComponent`)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,32 @@
<mat-form-field class="language-form-field" appearance="outline">
<mat-label>{{normalizedLabel}}</mat-label>
<mat-label>{{normalizedLabel()}}</mat-label>
<div class="language-input-container">
<span *ngIf="selectedLanguageFlag && showFlag" class="language-flag-prefix">{{selectedLanguageFlag}}</span>
@if (selectedLanguageFlag() && showFlag()) {
<span class="language-flag-prefix">{{selectedLanguageFlag()}}</span>
}
<input type="text" matInput
[required]="required" [disabled]="disabled" [readonly]="readonly"
attr.data-testid="record-{{label}}-language"
[required]="required()" [disabled]="disabled()" [readonly]="readonly()"
attr.data-testid="record-{{label()}}-language"
[formControl]="languageControl"
[matAutocomplete]="auto"
class="language-input">
</div>
<mat-autocomplete #auto="matAutocomplete" [displayWith]="displayFn">
<mat-option *ngFor="let language of filteredLanguages | async"
[value]="language"
(onSelectionChange)="language && onLanguageSelected(language)">
<span *ngIf="language.flag && showFlag" class="language-flag">{{language.flag}}</span>
<span class="language-name">{{language.label}}</span>
<span *ngIf="language.nativeName && language.label !== language.nativeName" class="language-native">{{language.nativeName}}</span>
<span *ngIf="language.value" class="language-code">({{language.value}})</span>
</mat-option>
@for (language of filteredLanguages | async; track language.value) {
<mat-option
[value]="language"
(onSelectionChange)="language && onLanguageSelected(language)">
@if (language.flag && showFlag()) {
<span class="language-flag">{{language.flag}}</span>
}
<span class="language-name">{{language.label}}</span>
@if (language.nativeName && language.label !== language.nativeName) {
<span class="language-native">{{language.nativeName}}</span>
}
@if (language.value) {
<span class="language-code">({{language.value}})</span>
}
</mat-option>
}
</mat-autocomplete>
</mat-form-field>
Original file line number Diff line number Diff line change
@@ -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<LanguageEditComponent>;

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<LanguageEditComponent>;

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);
});
});
Original file line number Diff line number Diff line change
@@ -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<string>();
readonly label = input<string>();
readonly required = input<boolean>(false);
readonly readonly = input<boolean>(false);
readonly structure = input<TableField>();
readonly disabled = input<boolean>(false);
readonly widgetStructure = input<WidgetStructure>();
readonly relations = input<TableForeignKey>();
readonly value = input<string>();

readonly onFieldChange = output<any>();

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<LanguageOption | string>('');
public filteredLanguages: Observable<LanguageOption[]>;
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];
}
Comment on lines +113 to +124

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allow_null is read from ws.widget_params without handling the case where widget_params is a JSON string (which you already handle in showFlag). If widget_params is serialized, the null option won’t be added. Consider parsing widget_params once (e.g. a shared computed) and using that parsed object for both show_flag and allow_null.

Copilot uses AI. Check for mistakes.
}
}
Loading
Loading