Browse Source

Merge pull request #24731 from abpframework/feat/#24675-hybrid-localization

Angular - Hybrid localization support
pull/24785/head
sumeyye 4 days ago
committed by GitHub
parent
commit
ea3a62d26b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      npm/ng-packs/apps/dev-app/src/app/app.config.ts
  2. 4
      npm/ng-packs/apps/dev-app/src/app/app.routes.ts
  3. 1
      npm/ng-packs/apps/dev-app/src/app/home/home.component.html
  4. 58
      npm/ng-packs/apps/dev-app/src/app/localization-test/localization-test.component.ts
  5. 10
      npm/ng-packs/apps/dev-app/src/assets/localization/en.json
  6. 10
      npm/ng-packs/apps/dev-app/src/assets/localization/tr.json
  7. 20
      npm/ng-packs/packages/core/src/lib/models/common.ts
  8. 12
      npm/ng-packs/packages/core/src/lib/providers/core-module-config.provider.ts
  9. 4
      npm/ng-packs/packages/core/src/lib/proxy/volo/abp/asp-net-core/mvc/application-configurations/abp-application-configuration.service.ts
  10. 1
      npm/ng-packs/packages/core/src/lib/services/index.ts
  11. 5
      npm/ng-packs/packages/core/src/lib/services/localization.service.ts
  12. 119
      npm/ng-packs/packages/core/src/lib/services/ui-localization.service.ts
  13. 160
      npm/ng-packs/packages/core/src/lib/tests/ui-localization.service.spec.ts

4
npm/ng-packs/apps/dev-app/src/app/app.config.ts

@ -31,6 +31,10 @@ export const appConfig: ApplicationConfig = {
registerLocaleFn: registerLocaleForEsBuild(),
sendNullsAsQueryParam: false,
skipGetAppConfiguration: false,
uiLocalization: {
enabled: true,
basePath: '/assets/localization',
},
}),
),
provideAbpOAuth(),

4
npm/ng-packs/apps/dev-app/src/app/app.routes.ts

@ -10,6 +10,10 @@ export const appRoutes: Routes = [
path: 'dynamic-form',
loadComponent: () => import('./dynamic-form-page/dynamic-form-page.component').then(m => m.DynamicFormPageComponent),
},
{
path: 'localization-test',
loadComponent: () => import('./localization-test/localization-test.component').then(m => m.LocalizationTestComponent),
},
{
path: 'account',
loadChildren: () => import('@abp/ng.account').then(m => m.createRoutes()),

1
npm/ng-packs/apps/dev-app/src/app/home/home.component.html

@ -1,6 +1,7 @@
<div class="container">
<div class="text-center mb-4">
<a routerLink="/dynamic-form" class="btn btn-primary">Go to Dynamic Form</a>
<a routerLink="/localization-test" class="btn btn-secondary ms-2">Test Hybrid Localization</a>
</div>
<div class="p-5 text-center">
<div class="d-inline-block bg-success text-white p-1 h5 rounded mb-4" role="alert">

58
npm/ng-packs/apps/dev-app/src/app/localization-test/localization-test.component.ts

@ -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();
}
}

10
npm/ng-packs/apps/dev-app/src/assets/localization/en.json

@ -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)"
}
}

10
npm/ng-packs/apps/dev-app/src/assets/localization/tr.json

@ -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)"
}
}

20
npm/ng-packs/packages/core/src/lib/models/common.ts

@ -16,6 +16,26 @@ export namespace ABP {
othersGroup?: string;
dynamicLayouts?: Map<string, string>;
disableProjectNameInTitle?: boolean;
uiLocalization?: UILocalizationOptions;
}
export interface UILocalizationOptions {
/**
* Enable UI localization feature
* When enabled, localization files are automatically loaded based on selected language
* Files should be located at: {basePath}/{culture}.json
* Example: /assets/localization/en.json
* JSON format: { "ResourceName": { "Key": "Value" } }
* Merges with backend localizations (UI > Backend priority)
*/
enabled?: boolean;
/**
* Base path for localization JSON files
* Default: '/assets/localization'
* Files should be located at: {basePath}/{culture}.json
* Example: /assets/localization/en.json
*/
basePath?: string;
}
export interface Child {

12
npm/ng-packs/packages/core/src/lib/providers/core-module-config.provider.ts

@ -23,7 +23,12 @@ import { RoutesHandler } from '../handlers';
import { ABP, SortableItem } from '../models';
import { AuthErrorFilterService } from '../abstracts';
import { DEFAULT_DYNAMIC_LAYOUTS } from '../constants';
import { LocalizationService, LocalStorageListenerService, AbpTitleStrategy } from '../services';
import {
LocalizationService,
LocalStorageListenerService,
AbpTitleStrategy,
UILocalizationService,
} from '../services';
import { DefaultQueueManager, getInitialData } from '../utils';
import { CookieLanguageProvider, IncludeLocalizationResourcesProvider, LocaleProvider } from './';
import { timezoneInterceptor, transferStateInterceptor } from '../interceptors';
@ -113,6 +118,11 @@ export function provideAbpCore(...features: CoreFeature<CoreFeatureKind>[]) {
inject(LocalizationService);
inject(LocalStorageListenerService);
inject(RoutesHandler);
// Initialize UILocalizationService if UI-only mode is enabled
const options = inject(CORE_OPTIONS);
if (options?.uiLocalization?.enabled) {
inject(UILocalizationService);
}
await getInitialData();
}),
LocaleProvider,

4
npm/ng-packs/packages/core/src/lib/proxy/volo/abp/asp-net-core/mvc/application-configurations/abp-application-configuration.service.ts

@ -6,8 +6,8 @@ import { Injectable, inject } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class AbpApplicationConfigurationService {
private restService = inject(RestService);
export class AbpApplicationConfigurationService {
private restService = inject(RestService);
apiName = 'abp';

1
npm/ng-packs/packages/core/src/lib/services/index.ts

@ -8,6 +8,7 @@ export * from './http-wait.service';
export * from './lazy-load.service';
export * from './list.service';
export * from './localization.service';
export * from './ui-localization.service';
export * from './multi-tenancy.service';
export * from './permission.service';
export * from './replaceable-components.service';

5
npm/ng-packs/packages/core/src/lib/services/localization.service.ts

@ -60,6 +60,8 @@ export class LocalizationService {
private initLocalizationValues() {
localizations$.subscribe(val => this.addLocalization(val));
// Backend-based localization loading (always enabled)
// UI localizations are merged via addLocalization() (UI > Backend priority)
const legacyResources$ = this.configState.getDeep$('localization.values') as Observable<
Record<string, Record<string, string>>
>;
@ -90,7 +92,8 @@ export class LocalizationService {
const resourceName = entry[0];
const remoteTexts = entry[1];
let resource = local?.get(resourceName) || {};
resource = { ...resource, ...remoteTexts };
// UI > Backend priority: local texts override remote texts
resource = { ...remoteTexts, ...resource };
local?.set(resourceName, resource);
});

119
npm/ng-packs/packages/core/src/lib/services/ui-localization.service.ts

@ -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] || {};
}
}

160
npm/ng-packs/packages/core/src/lib/tests/ui-localization.service.spec.ts

@ -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…
Cancel
Save