mirror of https://github.com/abpframework/abp.git
committed by
GitHub
13 changed files with 404 additions and 4 deletions
@ -0,0 +1,58 @@ |
|||
import { Component, inject, OnInit } from '@angular/core'; |
|||
import { LocalizationPipe, UILocalizationService, SessionStateService } from '@abp/ng.core'; |
|||
import { CommonModule } from '@angular/common'; |
|||
import { CardComponent, CardBodyComponent } from '@abp/ng.theme.shared'; |
|||
import { AsyncPipe } from '@angular/common'; |
|||
|
|||
@Component({ |
|||
selector: 'app-localization-test', |
|||
standalone: true, |
|||
imports: [CommonModule, LocalizationPipe, CardComponent, CardBodyComponent, AsyncPipe], |
|||
template: ` |
|||
<div class="container mt-5"> |
|||
<h2>Hybrid Localization Test</h2> |
|||
|
|||
<abp-card cardClass="mt-4"> |
|||
<abp-card-body> |
|||
<h5>Backend Localization (if available)</h5> |
|||
<p><strong>MyProjectName::Welcome:</strong> {{ 'MyProjectName::Welcome' | abpLocalization }}</p> |
|||
<p><strong>AbpAccount::Login:</strong> {{ 'AbpAccount::Login' | abpLocalization }}</p> |
|||
</abp-card-body> |
|||
</abp-card> |
|||
|
|||
<abp-card cardClass="mt-4"> |
|||
<abp-card-body> |
|||
<h5>UI Localization (from /assets/localization/{{ currentLanguage$ | async }}.json)</h5> |
|||
<p><strong>MyProjectName::CustomKey:</strong> {{ 'MyProjectName::CustomKey' | abpLocalization }}</p> |
|||
<p><strong>MyProjectName::TestMessage:</strong> {{ 'MyProjectName::TestMessage' | abpLocalization }}</p> |
|||
</abp-card-body> |
|||
</abp-card> |
|||
|
|||
<abp-card cardClass="mt-4"> |
|||
<abp-card-body> |
|||
<h5>UI Override (UI > Backend Priority)</h5> |
|||
<p><strong>AbpAccount::Login:</strong> {{ 'AbpAccount::Login' | abpLocalization }}</p> |
|||
<p class="text-muted">If backend has "Login", UI version should override it</p> |
|||
</abp-card-body> |
|||
</abp-card> |
|||
|
|||
<abp-card cardClass="mt-4"> |
|||
<abp-card-body> |
|||
<h5>Loaded UI Localizations</h5> |
|||
<pre>{{ loadedLocalizations | json }}</pre> |
|||
</abp-card-body> |
|||
</abp-card> |
|||
</div> |
|||
`,
|
|||
}) |
|||
export class LocalizationTestComponent implements OnInit { |
|||
private uiLocalizationService = inject(UILocalizationService); |
|||
private sessionState = inject(SessionStateService); |
|||
|
|||
loadedLocalizations: any = {}; |
|||
currentLanguage$ = this.sessionState.getLanguage$(); |
|||
|
|||
ngOnInit() { |
|||
this.loadedLocalizations = this.uiLocalizationService.getLoadedLocalizations(); |
|||
} |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
{ |
|||
"MyProjectName": { |
|||
"Welcome": "Welcome from UI (en.json)", |
|||
"CustomKey": "This is a UI-only localization", |
|||
"TestMessage": "UI localization is working!" |
|||
}, |
|||
"AbpAccount": { |
|||
"Login": "Sign In (UI Override)" |
|||
} |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
{ |
|||
"MyProjectName": { |
|||
"Welcome": "UI'dan Hoş Geldiniz (tr.json)", |
|||
"CustomKey": "Bu sadece UI'da olan bir localization", |
|||
"TestMessage": "UI localization çalışıyor!" |
|||
}, |
|||
"AbpAccount": { |
|||
"Login": "Giriş Yap (UI Override)" |
|||
} |
|||
} |
|||
@ -0,0 +1,119 @@ |
|||
import { Injectable, inject } from '@angular/core'; |
|||
import { HttpClient } from '@angular/common/http'; |
|||
import { BehaviorSubject, distinctUntilChanged, switchMap, of } from 'rxjs'; |
|||
import { catchError, shareReplay, tap } from 'rxjs/operators'; |
|||
import { ABP } from '../models/common'; |
|||
import { LocalizationService } from './localization.service'; |
|||
import { SessionStateService } from './session-state.service'; |
|||
import { CORE_OPTIONS } from '../tokens/options.token'; |
|||
|
|||
export interface UILocalizationResource { |
|||
[resourceName: string]: Record<string, string>; |
|||
} |
|||
|
|||
/** |
|||
* Service for managing UI localizations in ABP Angular applications. |
|||
* Automatically loads localization files based on selected language |
|||
* Merges with backend localizations (UI > Backend priority) |
|||
*/ |
|||
@Injectable({ providedIn: 'root' }) |
|||
export class UILocalizationService { |
|||
private http = inject(HttpClient); |
|||
private localizationService = inject(LocalizationService); |
|||
private sessionState = inject(SessionStateService); |
|||
private options = inject(CORE_OPTIONS); |
|||
|
|||
private loadedLocalizations$ = new BehaviorSubject<Record<string, UILocalizationResource>>({}); |
|||
|
|||
private currentLanguage$ = this.sessionState.getLanguage$(); |
|||
|
|||
constructor() { |
|||
const uiLocalization = this.options.uiLocalization; |
|||
if (uiLocalization?.enabled) { |
|||
this.subscribeToLanguageChanges(); |
|||
} |
|||
} |
|||
|
|||
private subscribeToLanguageChanges() { |
|||
this.currentLanguage$ |
|||
.pipe( |
|||
distinctUntilChanged(), |
|||
switchMap(culture => this.loadLocalizationFile(culture)) |
|||
) |
|||
.subscribe(); |
|||
} |
|||
|
|||
private loadLocalizationFile(culture: string) { |
|||
const config = this.options.uiLocalization; |
|||
if (!config?.enabled) return of(null); |
|||
|
|||
const basePath = config.basePath || '/assets/localization'; |
|||
const url = `${basePath}/${culture}.json`; |
|||
|
|||
return this.http.get<UILocalizationResource>(url).pipe( |
|||
catchError(() => { |
|||
// If file not found or error occurs, return null
|
|||
return of(null); |
|||
}), |
|||
tap(data => { |
|||
if (data) { |
|||
this.processLocalizationData(culture, data); |
|||
} |
|||
}), |
|||
); |
|||
} |
|||
|
|||
private processLocalizationData(culture: string, data: UILocalizationResource) { |
|||
const abpFormat: ABP.Localization[] = [ |
|||
{ |
|||
culture, |
|||
resources: Object.entries(data).map(([resourceName, texts]) => ({ |
|||
resourceName, |
|||
texts, |
|||
})), |
|||
}, |
|||
]; |
|||
this.localizationService.addLocalization(abpFormat); |
|||
|
|||
const current = this.loadedLocalizations$.value; |
|||
current[culture] = data; |
|||
this.loadedLocalizations$.next(current); |
|||
} |
|||
|
|||
addAngularLocalizeLocalization( |
|||
culture: string, |
|||
resourceName: string, |
|||
translations: Record<string, string>, |
|||
): void { |
|||
const abpFormat: ABP.Localization[] = [ |
|||
{ |
|||
culture, |
|||
resources: [ |
|||
{ |
|||
resourceName, |
|||
texts: translations, |
|||
}, |
|||
], |
|||
}, |
|||
]; |
|||
this.localizationService.addLocalization(abpFormat); |
|||
|
|||
const current = this.loadedLocalizations$.value; |
|||
if (!current[culture]) { |
|||
current[culture] = {}; |
|||
} |
|||
if (!current[culture][resourceName]) { |
|||
current[culture][resourceName] = {}; |
|||
} |
|||
current[culture][resourceName] = { |
|||
...current[culture][resourceName], |
|||
...translations, |
|||
}; |
|||
this.loadedLocalizations$.next(current); |
|||
} |
|||
|
|||
getLoadedLocalizations(culture?: string): UILocalizationResource { |
|||
const lang = culture || this.sessionState.getLanguage(); |
|||
return this.loadedLocalizations$.value[lang] || {}; |
|||
} |
|||
} |
|||
@ -0,0 +1,160 @@ |
|||
import { createServiceFactory, SpectatorService } from '@ngneat/spectator/vitest'; |
|||
import { of, Subject, throwError } from 'rxjs'; |
|||
import { HttpClient } from '@angular/common/http'; |
|||
import { UILocalizationService } from '../services/ui-localization.service'; |
|||
import { LocalizationService } from '../services/localization.service'; |
|||
import { SessionStateService } from '../services/session-state.service'; |
|||
import { CORE_OPTIONS } from '../tokens/options.token'; |
|||
|
|||
describe('UILocalizationService', () => { |
|||
let spectator: SpectatorService<UILocalizationService>; |
|||
let service: UILocalizationService; |
|||
let language$: Subject<string>; |
|||
let httpGet: ReturnType<typeof vi.fn>; |
|||
let addLocalizationSpy: ReturnType<typeof vi.fn>; |
|||
|
|||
const createService = createServiceFactory({ |
|||
service: UILocalizationService, |
|||
mocks: [HttpClient, LocalizationService], |
|||
providers: [ |
|||
{ |
|||
provide: SessionStateService, |
|||
useFactory: () => { |
|||
let currentLanguage = 'en'; |
|||
language$ = new Subject<string>(); |
|||
language$.subscribe(lang => { |
|||
currentLanguage = lang; |
|||
}); |
|||
return { |
|||
getLanguage: vi.fn(() => currentLanguage), |
|||
getLanguage$: vi.fn(() => language$.asObservable()), |
|||
}; |
|||
}, |
|||
}, |
|||
{ |
|||
provide: CORE_OPTIONS, |
|||
useValue: { |
|||
uiLocalization: { |
|||
enabled: true, |
|||
basePath: '/assets/localization', |
|||
}, |
|||
}, |
|||
}, |
|||
], |
|||
}); |
|||
|
|||
beforeEach(() => { |
|||
spectator = createService(); |
|||
service = spectator.service; |
|||
const http = spectator.inject(HttpClient); |
|||
const localizationService = spectator.inject(LocalizationService); |
|||
httpGet = vi.fn(); |
|||
(http as any).get = httpGet; |
|||
addLocalizationSpy = vi.fn(); |
|||
(localizationService as any).addLocalization = addLocalizationSpy; |
|||
}); |
|||
|
|||
describe('when uiLocalization is enabled', () => { |
|||
it('should load localization file when language changes', () => { |
|||
const uiData = { MyApp: { Welcome: 'Welcome from UI' } }; |
|||
httpGet.mockReturnValue(of(uiData)); |
|||
|
|||
language$.next('en'); |
|||
|
|||
expect(httpGet).toHaveBeenCalledWith('/assets/localization/en.json'); |
|||
expect(addLocalizationSpy).toHaveBeenCalledWith([ |
|||
{ |
|||
culture: 'en', |
|||
resources: [{ resourceName: 'MyApp', texts: { Welcome: 'Welcome from UI' } }], |
|||
}, |
|||
]); |
|||
}); |
|||
|
|||
it('should use default basePath when not provided', () => { |
|||
expect(httpGet).not.toHaveBeenCalled(); |
|||
httpGet.mockReturnValue(of({})); |
|||
language$.next('en'); |
|||
expect(httpGet).toHaveBeenCalledWith('/assets/localization/en.json'); |
|||
}); |
|||
|
|||
it('should not call addLocalization when file is missing (HTTP error)', () => { |
|||
httpGet.mockReturnValue(throwError(() => new Error('404'))); |
|||
|
|||
language$.next('fr'); |
|||
|
|||
expect(httpGet).toHaveBeenCalledWith('/assets/localization/fr.json'); |
|||
expect(addLocalizationSpy).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it('should cache loaded data in getLoadedLocalizations', () => { |
|||
const uiData = { AbpAccount: { Login: 'Sign In (UI)' } }; |
|||
httpGet.mockReturnValue(of(uiData)); |
|||
|
|||
language$.next('en'); |
|||
|
|||
const loaded = service.getLoadedLocalizations('en'); |
|||
expect(loaded).toEqual(uiData); |
|||
}); |
|||
|
|||
it('should load again when language changes to another culture', () => { |
|||
httpGet.mockReturnValue(of({})); |
|||
language$.next('en'); |
|||
expect(httpGet).toHaveBeenCalledTimes(1); |
|||
|
|||
httpGet.mockClear(); |
|||
httpGet.mockReturnValue(of({ MyApp: { Title: 'Titre' } })); |
|||
language$.next('fr'); |
|||
|
|||
expect(httpGet).toHaveBeenCalledWith('/assets/localization/fr.json'); |
|||
expect(addLocalizationSpy).toHaveBeenCalledWith([ |
|||
{ |
|||
culture: 'fr', |
|||
resources: [{ resourceName: 'MyApp', texts: { Title: 'Titre' } }], |
|||
}, |
|||
]); |
|||
}); |
|||
}); |
|||
|
|||
describe('addAngularLocalizeLocalization', () => { |
|||
it('should add localization via LocalizationService (UI data in merge pipeline)', () => { |
|||
service.addAngularLocalizeLocalization('en', 'MyApp', { |
|||
CustomKey: 'UI Override', |
|||
}); |
|||
|
|||
expect(addLocalizationSpy).toHaveBeenCalledWith([ |
|||
{ |
|||
culture: 'en', |
|||
resources: [ |
|||
{ |
|||
resourceName: 'MyApp', |
|||
texts: { CustomKey: 'UI Override' }, |
|||
}, |
|||
], |
|||
}, |
|||
]); |
|||
}); |
|||
|
|||
it('should merge into getLoadedLocalizations cache', () => { |
|||
service.addAngularLocalizeLocalization('en', 'MyApp', { Key1: 'Val1' }); |
|||
service.addAngularLocalizeLocalization('en', 'MyApp', { Key2: 'Val2' }); |
|||
|
|||
const loaded = service.getLoadedLocalizations('en'); |
|||
expect(loaded.MyApp).toEqual({ Key1: 'Val1', Key2: 'Val2' }); |
|||
}); |
|||
}); |
|||
|
|||
describe('getLoadedLocalizations', () => { |
|||
it('should return empty object when no culture loaded', () => { |
|||
expect(service.getLoadedLocalizations('de')).toEqual({}); |
|||
}); |
|||
|
|||
it('should return current language when culture not passed', () => { |
|||
const uiData = { R: { K: 'V' } }; |
|||
httpGet.mockReturnValue(of(uiData)); |
|||
language$.next('tr'); |
|||
|
|||
const loaded = service.getLoadedLocalizations(); |
|||
expect(loaded).toEqual(uiData); |
|||
}); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue