diff --git a/apps/showcase/src/app/app.component.spec.ts b/apps/showcase/src/app/app.component.spec.ts index 9dc7fa203e..c8e8ebe01b 100644 --- a/apps/showcase/src/app/app.component.spec.ts +++ b/apps/showcase/src/app/app.component.spec.ts @@ -1,9 +1,31 @@ +import { Provider } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { StoreModule } from '@ngrx/store'; +import { ConfigurationDevtoolsModule } from '@o3r/configuration'; +import { LocalizationDevtoolsModule } from '@o3r/localization'; +import { mockTranslationModules } from '@o3r/testing/localization'; +import { TranslateCompiler } from '@ngx-translate/core'; +import { TranslateFakeCompiler } from '@ngx-translate/core'; import { AppComponent } from './app.component'; +const localizationConfiguration = { language: 'en' }; +const mockTranslations = { + en: {} +}; +const mockTranslationsCompilerProvider: Provider = { + provide: TranslateCompiler, + useClass: TranslateFakeCompiler +}; + describe('AppComponent', () => { beforeEach(() => TestBed.configureTestingModule({ - declarations: [AppComponent] + declarations: [AppComponent], + imports: [ + StoreModule.forRoot(), + ...mockTranslationModules(localizationConfiguration, mockTranslations, mockTranslationsCompilerProvider), + ConfigurationDevtoolsModule, + LocalizationDevtoolsModule + ] })); it('should create the app', () => { diff --git a/apps/showcase/src/app/app.component.ts b/apps/showcase/src/app/app.component.ts index cf70632e6b..61664671c8 100644 --- a/apps/showcase/src/app/app.component.ts +++ b/apps/showcase/src/app/app.component.ts @@ -43,14 +43,17 @@ export class AppComponent implements OnDestroy { private subscriptions = new Subscription(); - constructor(router: Router, private offcanvasService: NgbOffcanvas) { + constructor( + router: Router, + private offcanvasService: NgbOffcanvas + ) { const onNavigationEnd$ = router.events.pipe( filter((event): event is NavigationEnd => event instanceof NavigationEnd), share() ); this.activeUrl$ = onNavigationEnd$.pipe( map((event) => event.urlAfterRedirects), - shareReplay({bufferSize: 1, refCount: true}) + shareReplay({ bufferSize: 1, refCount: true }) ); this.subscriptions.add(onNavigationEnd$.subscribe((event) => { if (this.offcanvasRef) { diff --git a/apps/showcase/src/app/app.module.ts b/apps/showcase/src/app/app.module.ts index 9f41441fe8..c7ea2b9a30 100644 --- a/apps/showcase/src/app/app.module.ts +++ b/apps/showcase/src/app/app.module.ts @@ -12,8 +12,10 @@ import { RuntimeChecks, StoreModule } from '@ngrx/store'; import { StoreDevtoolsModule } from '@ngrx/store-devtools'; import { TranslateCompiler, TranslateModule } from '@ngx-translate/core'; import { prefersReducedMotion } from '@o3r/application'; +import { ConfigurationDevtoolsModule } from '@o3r/configuration'; import { LocalizationConfiguration, + LocalizationDevtoolsModule, LocalizationModule, MESSAGE_FORMAT_CONFIG, translateLoaderProvider, @@ -60,7 +62,8 @@ export function localizationConfigurationFactory(): Partial { + runInInjectionContext(m.injector, () => { + inject(ConfigurationDevtoolsConsoleService); + inject(LocalizationDevtoolsConsoleService); + }); + }) // eslint-disable-next-line no-console .catch(err => console.error(err)); diff --git a/packages/@o3r/application/schematics/ng-add/helpers/devtools-registration.ts b/packages/@o3r/application/schematics/ng-add/helpers/devtools-registration.ts index b0d4584441..36e9d23bbd 100644 --- a/packages/@o3r/application/schematics/ng-add/helpers/devtools-registration.ts +++ b/packages/@o3r/application/schematics/ng-add/helpers/devtools-registration.ts @@ -1,9 +1,10 @@ -import { Rule } from '@angular-devkit/schematics'; +import { chain, Rule } from '@angular-devkit/schematics'; import * as path from 'node:path'; import type { NgAddSchematicsSchema } from '../schema'; const DEVTOOL_MODULE_NAME = 'ApplicationDevtoolsModule'; -const DEVTOOL_SERVICE_NAME = 'ApplicationDevtoolsService'; +const CONSOLE_DEVTOOL_SERVICE_NAME = 'ApplicationDevtoolsConsoleService'; +const MESSAGE_DEVTOOL_SERVICE_NAME = 'ApplicationDevtoolsMessageService'; const PACKAGE_NAME: string = require(path.resolve(__dirname, '..', '..', '..', 'package.json')).name; /** @@ -14,10 +15,18 @@ const PACKAGE_NAME: string = require(path.resolve(__dirname, '..', '..', '..', ' */ export const registerDevtools = async (options: NgAddSchematicsSchema): Promise => { const { registerDevtoolsToApplication } = await import('@o3r/schematics'); - return registerDevtoolsToApplication({ - moduleName: DEVTOOL_MODULE_NAME, - packageName: PACKAGE_NAME, - serviceName: DEVTOOL_SERVICE_NAME, - projectName: options.projectName - }); + return chain([ + registerDevtoolsToApplication({ + moduleName: DEVTOOL_MODULE_NAME, + packageName: PACKAGE_NAME, + serviceName: CONSOLE_DEVTOOL_SERVICE_NAME, + projectName: options.projectName + }), + registerDevtoolsToApplication({ + moduleName: DEVTOOL_MODULE_NAME, + packageName: PACKAGE_NAME, + serviceName: MESSAGE_DEVTOOL_SERVICE_NAME, + projectName: options.projectName + }) + ]); }; diff --git a/packages/@o3r/components/schematics/ng-add/helpers/devtools-registration.ts b/packages/@o3r/components/schematics/ng-add/helpers/devtools-registration.ts index 1d9b7d516b..caa03ab4ea 100644 --- a/packages/@o3r/components/schematics/ng-add/helpers/devtools-registration.ts +++ b/packages/@o3r/components/schematics/ng-add/helpers/devtools-registration.ts @@ -3,7 +3,7 @@ import * as path from 'node:path'; import type { NgAddSchematicsSchema } from '../schema'; const DEVTOOL_MODULE_NAME = 'ComponentsDevtoolsModule'; -const DEVTOOL_SERVICE_NAME = 'ComponentsDevtoolsService'; +const DEVTOOL_SERVICE_NAME = 'ComponentsDevtoolsMessageService'; const PACKAGE_NAME: string = require(path.resolve(__dirname, '..', '..', '..', 'package.json')).name; /** diff --git a/packages/@o3r/configuration/schematics/ng-add/helpers/devtools-registration.ts b/packages/@o3r/configuration/schematics/ng-add/helpers/devtools-registration.ts index b9be5ba027..6721800274 100644 --- a/packages/@o3r/configuration/schematics/ng-add/helpers/devtools-registration.ts +++ b/packages/@o3r/configuration/schematics/ng-add/helpers/devtools-registration.ts @@ -1,9 +1,10 @@ -import { Rule } from '@angular-devkit/schematics'; +import { chain, Rule } from '@angular-devkit/schematics'; import * as path from 'node:path'; import type { NgAddSchematicsSchema } from '../schema'; const DEVTOOL_MODULE_NAME = 'ConfigurationDevtoolsModule'; -const DEVTOOL_SERVICE_NAME = 'ConfigurationDevtoolsService'; +const MESSAGE_DEVTOOL_SERVICE_NAME = 'ConfigurationDevtoolsMessageService'; +const CONSOLE_DEVTOOL_SERVICE_NAME = 'ConfigurationDevtoolsConsoleService'; const PACKAGE_NAME: string = require(path.resolve(__dirname, '..', '..', '..', 'package.json')).name; /** @@ -15,10 +16,18 @@ const PACKAGE_NAME: string = require(path.resolve(__dirname, '..', '..', '..', ' */ export const registerDevtools = async (options: NgAddSchematicsSchema): Promise => { const { registerDevtoolsToApplication } = await import('@o3r/schematics'); - return registerDevtoolsToApplication({ - moduleName: DEVTOOL_MODULE_NAME, - packageName: PACKAGE_NAME, - serviceName: DEVTOOL_SERVICE_NAME, - projectName: options.projectName - }); + return chain([ + registerDevtoolsToApplication({ + moduleName: DEVTOOL_MODULE_NAME, + packageName: PACKAGE_NAME, + serviceName: MESSAGE_DEVTOOL_SERVICE_NAME, + projectName: options.projectName + }), + registerDevtoolsToApplication({ + moduleName: DEVTOOL_MODULE_NAME, + packageName: PACKAGE_NAME, + serviceName: CONSOLE_DEVTOOL_SERVICE_NAME, + projectName: options.projectName + }) + ]); }; diff --git a/packages/@o3r/configuration/src/devkit/configuration-devtools.console.service.ts b/packages/@o3r/configuration/src/devkit/configuration-devtools.console.service.ts index b0a52cf78f..ca0004b280 100644 --- a/packages/@o3r/configuration/src/devkit/configuration-devtools.console.service.ts +++ b/packages/@o3r/configuration/src/devkit/configuration-devtools.console.service.ts @@ -1,15 +1,15 @@ /* eslint-disable no-console */ import { Inject, Injectable, Optional } from '@angular/core'; -import type { Configuration, CustomConfig, DevtoolsServiceInterface, WindowWithDevtools } from '@o3r/core'; +import type { Configuration, ContextualizationDataset, CustomConfig, DevtoolsServiceInterface, WindowWithDevtools } from '@o3r/core'; import { firstValueFrom } from 'rxjs'; -import { ConfigurationDevtoolsServiceOptions } from './configuration-devtools.interface'; +import { ConfigurationContextualizationDevtools, ConfigurationDevtoolsServiceOptions } from './configuration-devtools.interface'; import { OtterConfigurationDevtools } from './configuration-devtools.service'; import { OTTER_CONFIGURATION_DEVTOOLS_DEFAULT_OPTIONS, OTTER_CONFIGURATION_DEVTOOLS_OPTIONS } from './configuration-devtools.token'; @Injectable({ providedIn: 'root' }) -export class ConfigurationDevtoolsConsoleService implements DevtoolsServiceInterface { +export class ConfigurationDevtoolsConsoleService implements DevtoolsServiceInterface, ConfigurationContextualizationDevtools { /** Name of the Window property to access to the devtools */ public static readonly windowModuleName = 'configuration'; @@ -20,7 +20,13 @@ export class ConfigurationDevtoolsConsoleService implements DevtoolsServiceInter ) { this.options = { ...OTTER_CONFIGURATION_DEVTOOLS_DEFAULT_OPTIONS, ...options }; - if (this.options.isActivatedOnBootstrap) { + if ( + this.options.isActivatedOnBootstrap + || ( + this.options.isActivatedOnBootstrapWhenCMSContext + && (document.body.dataset as ContextualizationDataset).cmscontext === 'true' + ) + ) { this.activate(); } } @@ -112,7 +118,7 @@ export class ConfigurationDevtoolsConsoleService implements DevtoolsServiceInter const content = await this.configurationDevtools.getConfiguration(); console.log('BOOKMARK'); - console.log(`javascript:window._OTTER_DEVTOOLS_.loadConfiguration('${JSON.stringify(content).replace(/[']/g, '\\\'')}')`); + console.log(`javascript:window._OTTER_DEVTOOLS_.updateConfigurations('${JSON.stringify(content).replace(/[']/g, '\\\'')}')`); } /** @@ -125,10 +131,18 @@ export class ConfigurationDevtoolsConsoleService implements DevtoolsServiceInter /** * Load a json configuration - * * @param configurations configurations to load + * @deprecated please use `updateConfigurations` instead, will be removed in Otter v12. */ public loadConfiguration(configurations: string | CustomConfig[]): void { this.configurationDevtools.loadConfiguration(configurations); } + + /** + * Replace N configurations in one shot + * @param configurations array of configurations to update + */ + public updateConfigurations(configurations: string | CustomConfig[]): void { + this.configurationDevtools.loadConfiguration(configurations); + } } diff --git a/packages/@o3r/configuration/src/devkit/configuration-devtools.console.spec.ts b/packages/@o3r/configuration/src/devkit/configuration-devtools.console.spec.ts index 6f0d20b5ed..87fb31bb0a 100644 --- a/packages/@o3r/configuration/src/devkit/configuration-devtools.console.spec.ts +++ b/packages/@o3r/configuration/src/devkit/configuration-devtools.console.spec.ts @@ -86,7 +86,7 @@ describe('Configuration DevTools console', () => { it('should upsert new configurations', () => { mockStore.dispatch = jest.fn(); - service.loadConfiguration('[{"library":"@scope/package","name":"componentTest","config":{"lolProp":123}}]'); + service.updateConfigurations('[{"library":"@scope/package","name":"componentTest","config":{"lolProp":123}}]'); expect(mockStore.dispatch).toHaveBeenCalledWith(expect.objectContaining( { diff --git a/packages/@o3r/configuration/src/devkit/configuration-devtools.interface.ts b/packages/@o3r/configuration/src/devkit/configuration-devtools.interface.ts index ffc2fee689..68fc530104 100644 --- a/packages/@o3r/configuration/src/devkit/configuration-devtools.interface.ts +++ b/packages/@o3r/configuration/src/devkit/configuration-devtools.interface.ts @@ -1,18 +1,25 @@ import type { Dictionary } from '@ngrx/entity'; -import type { ConnectContentMessage, DevtoolsCommonOptions, MessageDataTypes, OtterMessageContent, RequestMessagesContentMessage } from '@o3r/core'; +import type { + Configuration, + ConnectContentMessage, + ContextualizationDevtoolsCommonOptions, + CustomConfig, + DevtoolsCommonOptions, + MessageDataTypes, + OtterMessageContent, + RequestMessagesContentMessage +} from '@o3r/core'; import type { ConfigurationModel } from '../stores/index'; /** Option for Configuration devtools service */ -export interface ConfigurationDevtoolsServiceOptions extends DevtoolsCommonOptions { +export interface ConfigurationDevtoolsServiceOptions extends DevtoolsCommonOptions, ContextualizationDevtoolsCommonOptions { /** * Default library name to use if not specified in the function call - * * @default `@o3r/components` */ defaultLibraryName: string; /** * Default JSON file name if not specified in the function - * * @default partial-static-config.json */ defaultJsonFilename: string; @@ -45,7 +52,6 @@ export type AvailableConfigurationMessageContents = /** * Determine if the given message is a Configuration message - * * @param message message to check */ export const isConfigurationMessage = (message: any): message is AvailableConfigurationMessageContents => { @@ -55,3 +61,14 @@ export const isConfigurationMessage = (message: any): message is AvailableConfig message.dataType === 'requestMessages' || message.dataType === 'connect'); }; + +/** + * Contextualization devtools exposed for configuration in CMS integration + */ +export interface ConfigurationContextualizationDevtools { + /** + * Replace N configurations in one shot + * @param configs array of configurations to update + */ + updateConfigurations: (configurations: CustomConfig[]) => void; +} diff --git a/packages/@o3r/configuration/src/devkit/configuration-devtools.token.ts b/packages/@o3r/configuration/src/devkit/configuration-devtools.token.ts index 64e533ad72..987e3a2a10 100644 --- a/packages/@o3r/configuration/src/devkit/configuration-devtools.token.ts +++ b/packages/@o3r/configuration/src/devkit/configuration-devtools.token.ts @@ -4,7 +4,8 @@ import { ConfigurationDevtoolsServiceOptions } from './configuration-devtools.in export const OTTER_CONFIGURATION_DEVTOOLS_DEFAULT_OPTIONS: ConfigurationDevtoolsServiceOptions = { defaultLibraryName: '@o3r/components', defaultJsonFilename: 'partial-static-config.json', - isActivatedOnBootstrap: false + isActivatedOnBootstrap: false, + isActivatedOnBootstrapWhenCMSContext: true }; // eslint-disable-next-line max-len diff --git a/packages/@o3r/core/src/core/devkit/devtools.interface.ts b/packages/@o3r/core/src/core/devkit/devtools.interface.ts index 6da74b45a6..5d02002fbc 100644 --- a/packages/@o3r/core/src/core/devkit/devtools.interface.ts +++ b/packages/@o3r/core/src/core/devkit/devtools.interface.ts @@ -8,13 +8,31 @@ export interface WindowWithDevtools extends Window { /** Common option used by the different DevKit services */ export interface DevtoolsCommonOptions { /** - * Activated the application bootstrap + * Activated on the application bootstrap * * @default false */ isActivatedOnBootstrap: boolean; } +/** Common option used by the different Contextualization DevKit services */ +export interface ContextualizationDevtoolsCommonOptions { + /** + * Activated on the application bootstrap when integrated in CMS context + * + * @default true + */ + isActivatedOnBootstrapWhenCMSContext: boolean; +} + +/** + * Dataset injected on the page when in CMS context + */ +export interface ContextualizationDataset { + /** `"true"` when in CMS context */ + cmscontext?: string; +} + /** Interface describing an Otter Devtools service */ export interface DevtoolsServiceInterface { /** Activate the devtools service */ diff --git a/packages/@o3r/localization/schematics/ng-add/helpers/devtools-registration.ts b/packages/@o3r/localization/schematics/ng-add/helpers/devtools-registration.ts index 46aa0d0446..f13be9ad5e 100644 --- a/packages/@o3r/localization/schematics/ng-add/helpers/devtools-registration.ts +++ b/packages/@o3r/localization/schematics/ng-add/helpers/devtools-registration.ts @@ -1,9 +1,10 @@ -import { Rule } from '@angular-devkit/schematics'; +import { chain, Rule } from '@angular-devkit/schematics'; import * as path from 'node:path'; import type { NgAddSchematicsSchema } from '../schema'; const DEVTOOL_MODULE_NAME = 'LocalizationDevtoolsModule'; -const DEVTOOL_SERVICE_NAME = 'LocalizationDevtoolsService'; +const MESSAGE_DEVTOOL_SERVICE_NAME = 'LocalizationDevtoolsMessageService'; +const CONSOLE_DEVTOOL_SERVICE_NAME = 'LocalizationDevtoolsConsoleService'; const PACKAGE_NAME: string = require(path.resolve(__dirname, '..', '..', '..', 'package.json')).name; /** @@ -15,10 +16,18 @@ const PACKAGE_NAME: string = require(path.resolve(__dirname, '..', '..', '..', ' */ export const registerDevtools = async (options: NgAddSchematicsSchema): Promise => { const { registerDevtoolsToApplication } = await import('@o3r/schematics'); - return registerDevtoolsToApplication({ - moduleName: DEVTOOL_MODULE_NAME, - packageName: PACKAGE_NAME, - serviceName: DEVTOOL_SERVICE_NAME, - projectName: options.projectName - }); + return chain([ + registerDevtoolsToApplication({ + moduleName: DEVTOOL_MODULE_NAME, + packageName: PACKAGE_NAME, + serviceName: MESSAGE_DEVTOOL_SERVICE_NAME, + projectName: options.projectName + }), + registerDevtoolsToApplication({ + moduleName: DEVTOOL_MODULE_NAME, + packageName: PACKAGE_NAME, + serviceName: CONSOLE_DEVTOOL_SERVICE_NAME, + projectName: options.projectName + }) + ]); }; diff --git a/packages/@o3r/localization/src/devkit/localization-devkit.interface.ts b/packages/@o3r/localization/src/devkit/localization-devkit.interface.ts index f6bb0614db..fa5efd5b2e 100644 --- a/packages/@o3r/localization/src/devkit/localization-devkit.interface.ts +++ b/packages/@o3r/localization/src/devkit/localization-devkit.interface.ts @@ -1,6 +1,14 @@ -import type { ConnectContentMessage, DevtoolsCommonOptions, MessageDataTypes, OtterMessageContent, RequestMessagesContentMessage } from '@o3r/core'; +import type { + ConnectContentMessage, + ContextualizationDevtoolsCommonOptions, + DevtoolsCommonOptions, + MessageDataTypes, + OtterMessageContent, + RequestMessagesContentMessage +} from '@o3r/core'; +import { Subscription } from 'rxjs'; -export interface LocalizationDevtoolsServiceOptions extends DevtoolsCommonOptions { +export interface LocalizationDevtoolsServiceOptions extends DevtoolsCommonOptions, ContextualizationDevtoolsCommonOptions { } /** Display localization key message content */ @@ -20,3 +28,46 @@ export type AvailableLocalizationMessageContents = | LocalizationMessageContents | ConnectContentMessage | RequestMessagesContentMessage; + + +/** + * Contextualization devtools exposed for localization in CMS integration + */ +export interface LocalizationContextualizationDevtools { + /** + * Show localization keys + * @param value value enforced by the DevTools extension + */ + showLocalizationKeys: (value?: boolean) => void | Promise; + + /** + * Returns the current language + */ + getCurrentLanguage: () => string | Promise; + + /** + * Switch the current language to the specified value + * @param language new language to switch to + */ + switchLanguage: (language: string) => Promise<{ previous: string; requested: string; current: string }>; + + /** + * Setup a listener on language change + * @param fn called when the language is changed in the app + */ + onLanguageChange: (fn: (language: string) => any) => Subscription; + + /** + * Updates the specified localization key/values for the current language + * @param keyValues key/values to update + * @param language if not provided, the current language value + */ + updateLocalizationKeys: (keyValues: { [key: string]: string }, language?: string) => void | Promise; + + /** + * Reload a language from the language file + * @see https://github.com/ngx-translate/core/blob/master/packages/core/lib/translate.service.ts#L490 + * @param language language to reload + */ + reloadLocalizationKeys: (language?: string) => Promise; +} diff --git a/packages/@o3r/localization/src/devkit/localization-devtools.console.service.ts b/packages/@o3r/localization/src/devkit/localization-devtools.console.service.ts index 2020184be3..d40600e019 100644 --- a/packages/@o3r/localization/src/devkit/localization-devtools.console.service.ts +++ b/packages/@o3r/localization/src/devkit/localization-devtools.console.service.ts @@ -1,21 +1,32 @@ /* eslint-disable no-console */ -import { Inject, Injectable, Optional } from '@angular/core'; -import type { DevtoolsServiceInterface, WindowWithDevtools } from '@o3r/core'; -import { LocalizationDevtoolsServiceOptions } from './localization-devkit.interface'; +import { ApplicationRef, Inject, Injectable, Optional } from '@angular/core'; +import type { ContextualizationDataset, DevtoolsServiceInterface, WindowWithDevtools } from '@o3r/core'; +import { lastValueFrom, Subscription } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { LocalizationService } from '../tools'; +import { LocalizationContextualizationDevtools, LocalizationDevtoolsServiceOptions } from './localization-devkit.interface'; import { OtterLocalizationDevtools } from './localization-devtools.service'; import { OTTER_LOCALIZATION_DEVTOOLS_DEFAULT_OPTIONS, OTTER_LOCALIZATION_DEVTOOLS_OPTIONS } from './localization-devtools.token'; @Injectable() -export class LocalizationDevtoolsConsoleService implements DevtoolsServiceInterface { +export class LocalizationDevtoolsConsoleService implements DevtoolsServiceInterface, LocalizationContextualizationDevtools { /** Name of the Window property to access to the devtools */ public static readonly windowModuleName = 'localization'; constructor( private readonly localizationDevtools: OtterLocalizationDevtools, + private readonly localizationService: LocalizationService, + private readonly appRef: ApplicationRef, @Optional() @Inject(OTTER_LOCALIZATION_DEVTOOLS_OPTIONS) private options: LocalizationDevtoolsServiceOptions = OTTER_LOCALIZATION_DEVTOOLS_DEFAULT_OPTIONS ) { - if (this.options.isActivatedOnBootstrap) { + if ( + this.options.isActivatedOnBootstrap + || ( + this.options.isActivatedOnBootstrapWhenCMSContext + && (document.body.dataset as ContextualizationDataset).cmscontext === 'true' + ) + ) { this.activate(); } } @@ -32,11 +43,64 @@ export class LocalizationDevtoolsConsoleService implements DevtoolsServiceInterf } /** - * Show localization keys - * - * @param value value enforced by the DevTools extension + * @inheritdoc */ - public showLocalizationKeys(value?: boolean): void { + public showLocalizationKeys(value?: boolean): void | Promise { this.localizationDevtools.showLocalizationKeys(value); + this.appRef.tick(); + } + + /** + * @inheritdoc + */ + public getCurrentLanguage(): string | Promise { + const currentLanguage = this.localizationDevtools.getCurrentLanguage(); + return currentLanguage; + } + + /** + * @inheritdoc + */ + public async switchLanguage(language: string): Promise<{ previous: string; requested: string; current: string }> { + const previous = this.localizationDevtools.getCurrentLanguage(); + await lastValueFrom(this.localizationService.useLanguage(language)); + this.appRef.tick(); + const current = this.localizationDevtools.getCurrentLanguage(); + return { + requested: language, + previous, + current + }; + } + + /** + * @inheritdoc + */ + public onLanguageChange(fn: (language: string) => any): Subscription { + return this.localizationDevtools.onLanguageChange(fn); + } + + /** + * @inheritdoc + */ + public updateLocalizationKeys(keyValues: { [key: string]: string }, language?: string): void | Promise { + Object.entries(keyValues).map(([key, value]) => { + this.localizationService.getTranslateService().set(key, value, language || this.localizationDevtools.getCurrentLanguage()); + }); + this.appRef.tick(); + } + + /** + * @inheritdoc + */ + public async reloadLocalizationKeys(language?: string) { + const lang = language || this.localizationDevtools.getCurrentLanguage(); + const initialLocs = await lastValueFrom( + this.localizationService + .getTranslateService() + .reloadLang(lang) + .pipe(take(1)) + ); + return this.updateLocalizationKeys(initialLocs, lang); } } diff --git a/packages/@o3r/localization/src/devkit/localization-devtools.service.ts b/packages/@o3r/localization/src/devkit/localization-devtools.service.ts index cc8b0f1522..14755f99e7 100644 --- a/packages/@o3r/localization/src/devkit/localization-devtools.service.ts +++ b/packages/@o3r/localization/src/devkit/localization-devtools.service.ts @@ -1,11 +1,10 @@ import { Injectable } from '@angular/core'; +import { Subscription } from 'rxjs'; import { LocalizationService } from '../tools'; @Injectable() export class OtterLocalizationDevtools { - - constructor(private localizationService: LocalizationService) { - } + constructor(private localizationService: LocalizationService) {} /** * Show localization keys @@ -15,4 +14,24 @@ export class OtterLocalizationDevtools { public showLocalizationKeys(value?: boolean): void { this.localizationService.toggleShowKeys(value); } + + /** + * Returns the current language + */ + public getCurrentLanguage() { + return this.localizationService.getCurrentLanguage(); + } + + /** + * Setup a listener on language change + * @param fn called when the language is changed in the app + */ + public onLanguageChange(fn: (language: string) => any): Subscription { + return this.localizationService + .getTranslateService() + .onLangChange + .subscribe(({ lang }) => { + fn(lang); + }); + } } diff --git a/packages/@o3r/localization/src/devkit/localization-devtools.token.ts b/packages/@o3r/localization/src/devkit/localization-devtools.token.ts index 8f843ab203..ceb2fe2923 100644 --- a/packages/@o3r/localization/src/devkit/localization-devtools.token.ts +++ b/packages/@o3r/localization/src/devkit/localization-devtools.token.ts @@ -2,7 +2,8 @@ import { InjectionToken } from '@angular/core'; import { LocalizationDevtoolsServiceOptions } from './localization-devkit.interface'; export const OTTER_LOCALIZATION_DEVTOOLS_DEFAULT_OPTIONS: LocalizationDevtoolsServiceOptions = { - isActivatedOnBootstrap: false + isActivatedOnBootstrap: false, + isActivatedOnBootstrapWhenCMSContext: true }; export const OTTER_LOCALIZATION_DEVTOOLS_OPTIONS: InjectionToken = new InjectionToken('Otter Localization Devtools options'); diff --git a/packages/@o3r/rules-engine/schematics/ng-add/helpers/devtools-registration.ts b/packages/@o3r/rules-engine/schematics/ng-add/helpers/devtools-registration.ts index 74c4af1cc7..736807f35a 100644 --- a/packages/@o3r/rules-engine/schematics/ng-add/helpers/devtools-registration.ts +++ b/packages/@o3r/rules-engine/schematics/ng-add/helpers/devtools-registration.ts @@ -1,9 +1,10 @@ -import { Rule } from '@angular-devkit/schematics'; +import { chain, Rule } from '@angular-devkit/schematics'; import * as path from 'node:path'; import type { NgAddSchematicsSchema } from '../schema'; const DEVTOOL_MODULE_NAME = 'RulesEngineDevtoolsModule'; -const DEVTOOL_SERVICE_NAME = 'RulesEngineDevtoolsService'; +const MESSAGE_DEVTOOL_SERVICE_NAME = 'RulesEngineDevtoolsMessageService'; +const CONSOLE_DEVTOOL_SERVICE_NAME = 'RulesEngineDevtoolsConsoleService'; const PACKAGE_NAME: string = require(path.resolve(__dirname, '..', '..', '..', 'package.json')).name; /** @@ -15,10 +16,18 @@ const PACKAGE_NAME: string = require(path.resolve(__dirname, '..', '..', '..', ' */ export const registerDevtools = async (options: NgAddSchematicsSchema): Promise => { const { registerDevtoolsToApplication } = await import('@o3r/schematics'); - return registerDevtoolsToApplication({ - moduleName: DEVTOOL_MODULE_NAME, - packageName: PACKAGE_NAME, - serviceName: DEVTOOL_SERVICE_NAME, - projectName: options.projectName - }); + return chain([ + registerDevtoolsToApplication({ + moduleName: DEVTOOL_MODULE_NAME, + packageName: PACKAGE_NAME, + serviceName: MESSAGE_DEVTOOL_SERVICE_NAME, + projectName: options.projectName + }), + registerDevtoolsToApplication({ + moduleName: DEVTOOL_MODULE_NAME, + packageName: PACKAGE_NAME, + serviceName: CONSOLE_DEVTOOL_SERVICE_NAME, + projectName: options.projectName + }) + ]); }; diff --git a/packages/@o3r/schematics/src/rule-factories/dev-tools/devtools-registration.spec.ts b/packages/@o3r/schematics/src/rule-factories/dev-tools/devtools-registration.spec.ts new file mode 100644 index 0000000000..63af0b5de4 --- /dev/null +++ b/packages/@o3r/schematics/src/rule-factories/dev-tools/devtools-registration.spec.ts @@ -0,0 +1,143 @@ +import { callRule, Tree } from '@angular-devkit/schematics'; +import { lastValueFrom } from 'rxjs'; +import { injectServiceInMain } from './devtools-registration'; + +const projectName = 'projectName'; +const mainFilePath = 'main.ts'; +const fakeContext = { logger: { debug: jest.fn() } } as any; + +describe('Devtools Registration', () => { + let initialTree: Tree; + + beforeEach(() => { + initialTree = Tree.empty(); + initialTree.create(mainFilePath, ` +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; +import { AppModule } from './app/app.module'; + +platformBrowserDynamic().bootstrapModule(AppModule).catch(err => console.error(err)); + `); + initialTree.create('angular.json', JSON.stringify({ projects: { [projectName]: { architect: { build: { options: { main: mainFilePath } } } } } })); + }); + + it('should inject service in the main file', async () => { + const serviceName = 'ServiceName'; + const tree = await lastValueFrom( + callRule( + injectServiceInMain({ + moduleName: 'ModuleName', + packageName: '@scope/package-name', + projectName, + serviceName + }), + initialTree, + fakeContext + ) + ); + expect(tree.readText(mainFilePath)).toContain(`.then((m) => { runInInjectionContext(m.injector, () => { inject(${serviceName}); }); return m; })`); + }); + + + it('should inject a service if `bootstrapApplication` found', async () => { + const serviceName = 'ServiceName'; + initialTree.overwrite(mainFilePath, ` +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, appConfig) + .catch((err) => console.error(err)); + `); + const tree = await lastValueFrom( + callRule( + injectServiceInMain({ + moduleName: 'ModuleName', + packageName: '@scope/package-name', + projectName, + serviceName + }), + initialTree, + fakeContext + ) + ); + expect(tree.readText(mainFilePath)).toContain(`.then((m) => { runInInjectionContext(m.injector, () => { inject(${serviceName}); }); return m; })`); + }); + + it('should not reinject a service', async () => { + const serviceName = 'ServiceName'; + const treeFirstAdd = await lastValueFrom( + callRule( + injectServiceInMain({ + moduleName: 'ModuleName', + packageName: '@scope/package-name', + projectName, + serviceName + }), + initialTree, + fakeContext + ) + ); + const treeSecondAdd = await lastValueFrom( + callRule( + injectServiceInMain({ + moduleName: 'ModuleName', + packageName: '@scope/package-name', + projectName, + serviceName + }), + treeFirstAdd, + fakeContext + ) + ); + expect(Array.from(treeSecondAdd.readText(mainFilePath).matchAll(/inject\(\w+\)/g)).length).toBe(1); + }); + + it('should not inject a service if no name provided', async () => { + const tree = await lastValueFrom( + callRule( + injectServiceInMain({ + moduleName: 'ModuleName', + packageName: '@scope/package-name', + projectName, + serviceName: undefined + }), + initialTree, + fakeContext + ) + ); + expect(tree.readText(mainFilePath)).not.toContain('inject'); + }); + + it('should not inject a service if no main file found', async () => { + const tree = await lastValueFrom( + callRule( + injectServiceInMain({ + moduleName: 'ModuleName', + packageName: '@scope/package-name', + projectName: undefined, + serviceName: 'ServiceName' + }), + initialTree, + fakeContext + ) + ); + expect(tree.readText(mainFilePath)).not.toContain('inject'); + }); + + it('should not inject a service if no `bootstrapModule` found', async () => { + initialTree.overwrite(mainFilePath, ''); + const tree = await lastValueFrom( + callRule( + injectServiceInMain({ + moduleName: 'ModuleName', + packageName: '@scope/package-name', + projectName, + serviceName: 'ServiceName' + }), + initialTree, + fakeContext + ) + ); + expect(tree.readText(mainFilePath)).not.toContain('inject'); + }); +}); diff --git a/packages/@o3r/schematics/src/rule-factories/dev-tools/devtools-registration.ts b/packages/@o3r/schematics/src/rule-factories/dev-tools/devtools-registration.ts index 16008deb95..08a1f407d8 100644 --- a/packages/@o3r/schematics/src/rule-factories/dev-tools/devtools-registration.ts +++ b/packages/@o3r/schematics/src/rule-factories/dev-tools/devtools-registration.ts @@ -1,9 +1,10 @@ import { chain, Rule } from '@angular-devkit/schematics'; import * as ts from 'typescript'; -import { getDecoratorMetadata, isImported } from '@schematics/angular/utility/ast-utils'; -import { getAppModuleFilePath, getDefaultOptionsForSchematic, getWorkspaceConfig } from '../../utility'; +import { getDecoratorMetadata, insertImport, isImported } from '@schematics/angular/utility/ast-utils'; +import { getAppModuleFilePath, getDefaultOptionsForSchematic, getMainFilePath, getWorkspaceConfig } from '../../utility'; import type { WorkspaceSchematics } from '../../interfaces'; import { addImportToModuleFile as o3rAddImportToModuleFile } from '../../utility'; +import { applyToUpdateRecorder, InsertChange } from '@schematics/angular/utility/change'; /** Options for the devtools register rule factory */ export interface DevtoolRegisterOptions { @@ -52,6 +53,64 @@ const registerModule = (options: DevtoolRegisterOptions): Rule => (tree, context return tree; }; +/** + * Rule to inject a service after `bootstrapModule` or `bootstrapApplication` + * @param options + */ +export const injectServiceInMain = (options: DevtoolRegisterOptions): Rule => (tree, context) => { + if (!options.serviceName) { + return tree; + } + + const mainFilePath = getMainFilePath(tree, context, options.projectName); + + if (!mainFilePath || !tree.exists(mainFilePath)) { + return tree; + } + + const content = tree.readText(mainFilePath); + if (content.includes(`inject(${options.serviceName})`)) { + return tree; + } + + const match = /bootstrap(Module|Application)\([^)]*\)/.exec(content); + if (!match) { + return tree; + } + + const sourceFile = ts.createSourceFile( + mainFilePath, + content, + ts.ScriptTarget.ES2015, + true + ); + + const recorder = tree.beginUpdate(mainFilePath); + const changes = []; + + if (!isImported(sourceFile, options.serviceName, options.packageName)) { + changes.push(insertImport(sourceFile, mainFilePath, options.serviceName, options.packageName)); + } + if (!isImported(sourceFile, 'inject', '@angular/core')) { + changes.push(insertImport(sourceFile, mainFilePath, 'inject', '@angular/core')); + } + if (!isImported(sourceFile, 'runInInjectionContext', '@angular/core')) { + changes.push(insertImport(sourceFile, mainFilePath, 'runInInjectionContext', '@angular/core')); + } + + changes.push( + new InsertChange( + mainFilePath, + match.index + match[0].length, + `\n .then((m) => { runInInjectionContext(m.injector, () => { inject(${options.serviceName}); }); return m; })` + ) + ); + + applyToUpdateRecorder(recorder, changes); + tree.commitUpdate(recorder); + return tree; +}; + /** * Register Devtools to the application * @param tree @@ -75,6 +134,7 @@ export const registerDevtoolsToApplication = (options: DevtoolRegisterOptions): return chain([ registerModule(options), + injectServiceInMain(options), (_, ctx) => ctx.logger.info( `The devtools module ${options.moduleName} has been registered and automatically activated${options.serviceName ? ', its activation can be driven via ' + options.serviceName : ''}.` )