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
4 changes: 4 additions & 0 deletions npm/ng-packs/apps/dev-app/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export const appConfig: ApplicationConfig = {
registerLocaleFn: registerLocaleForEsBuild(),
sendNullsAsQueryParam: false,
skipGetAppConfiguration: false,
uiLocalization: {
enabled: true,
basePath: '/assets/localization',
},
}),
),
provideAbpOAuth(),
Expand Down
4 changes: 4 additions & 0 deletions npm/ng-packs/apps/dev-app/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
1 change: 1 addition & 0 deletions npm/ng-packs/apps/dev-app/src/app/home/home.component.html
Original file line number Diff line number Diff line change
@@ -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">
Expand Down
Original file line number Diff line number Diff line change
@@ -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 changes: 10 additions & 0 deletions npm/ng-packs/apps/dev-app/src/assets/localization/en.json
Original file line number Diff line number Diff line change
@@ -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 changes: 10 additions & 0 deletions npm/ng-packs/apps/dev-app/src/assets/localization/tr.json
Original file line number Diff line number Diff line change
@@ -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 changes: 20 additions & 0 deletions npm/ng-packs/packages/core/src/lib/models/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Comment on lines +121 to +125
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

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

The comment says "UI-only mode" but this feature appears to be hybrid UI+backend localization merging (based on uiLocalization options and the merge logic in LocalizationService). Please adjust the comment to accurately describe what is being enabled/initialized here.

Copilot uses AI. Check for mistakes.
await getInitialData();
}),
LocaleProvider,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
1 change: 1 addition & 0 deletions npm/ng-packs/packages/core/src/lib/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>>
>;
Expand Down Expand Up @@ -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);
});
Expand Down
119 changes: 119 additions & 0 deletions npm/ng-packs/packages/core/src/lib/services/ui-localization.service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Member

Choose a reason for hiding this comment

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

The interface name UILocalizationResource might be confused with ABP.LocalizationResource (defined in abp\npm\ng-packs\packages\core\src\lib\models\common.ts), as they represent different structures:

  • ABP.LocalizationResource: { resourceName: string, texts: Record<string, string> }
  • UILocalizationResource: { [resourceName: string]: Record<string, string> } (dictionary format)

Consider renaming to something more descriptive like UiLocalizationDictionary or UiLocalizationFileContent to better reflect its purpose as the JSON file content format.

Copy link
Member

Choose a reason for hiding this comment

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

it is not necessary change

[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();
Comment on lines +40 to +43
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

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

shareReplay(1) is applied to a stream that is immediately subscribed to and not exposed to other consumers, so it provides no benefit here and can be misleading. Consider removing it, or alternatively expose a shared observable if caching/reuse is intended.

Copilot uses AI. Check for mistakes.
}

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