From f44d5547538061b130ec46f9ed2430b6914d1a6a Mon Sep 17 00:00:00 2001 From: Kilian Panot Date: Wed, 10 Apr 2024 18:42:48 +0900 Subject: [PATCH] feat(components): expose the placeholder service to be ready to be used without rule-engine --- apps/showcase/package.json | 1 + .../placeholder/placeholder-pres.component.ts | 4 +- docs/rules-engine/how-to-use/placeholders.md | 69 ++++++- .../placeholder.action-handler.ts | 171 +--------------- .../rules-engine/placeholder.interfaces.ts | 7 +- .../placeholder.rules-engine.module.ts | 7 +- packages/@o3r/components/src/stores/index.ts | 1 + .../src/stores/placeholder-effect/index.ts | 1 + .../placeholder.effect.spec.ts} | 10 +- .../placeholder-effect/placeholder.effect.ts} | 86 +++++--- .../components/src/tools/placeholder/index.ts | 2 + .../placeholder/placeholder.component.ts | 21 +- .../placeholder/placeholder.interface.ts | 14 ++ .../tools/placeholder/placeholder.module.ts | 10 +- .../tools/placeholder/placeholder.service.ts | 184 ++++++++++++++++++ .../src/tools/placeholder/placeholder.spec.ts | 7 +- packages/@o3r/core/package.json | 4 +- yarn.lock | 1 + 18 files changed, 358 insertions(+), 242 deletions(-) create mode 100644 packages/@o3r/components/src/stores/placeholder-effect/index.ts rename packages/@o3r/components/src/{rules-engine/placeholder.rules-engine.effect.spec.ts => stores/placeholder-effect/placeholder.effect.spec.ts} (98%) rename packages/@o3r/components/src/{rules-engine/placeholder.rules-engine.effect.ts => stores/placeholder-effect/placeholder.effect.ts} (62%) create mode 100644 packages/@o3r/components/src/tools/placeholder/placeholder.service.ts diff --git a/apps/showcase/package.json b/apps/showcase/package.json index ff9a029dcc..d95fec97a0 100644 --- a/apps/showcase/package.json +++ b/apps/showcase/package.json @@ -66,6 +66,7 @@ "bootstrap": "5.3.3", "highlight.js": "^11.8.0", "intl-messageformat": "~10.5.1", + "jsonpath-plus": "^8.0.0", "ngx-highlightjs": "^10.0.0", "pixelmatch": "^5.2.1", "pngjs": "^7.0.0", diff --git a/apps/showcase/src/components/showcase/placeholder/placeholder-pres.component.ts b/apps/showcase/src/components/showcase/placeholder/placeholder-pres.component.ts index fdd5015df6..9747b5a201 100644 --- a/apps/showcase/src/components/showcase/placeholder/placeholder-pres.component.ts +++ b/apps/showcase/src/components/showcase/placeholder/placeholder-pres.component.ts @@ -1,7 +1,7 @@ import { formatDate } from '@angular/common'; import { ChangeDetectionStrategy, Component, type OnDestroy, ViewEncapsulation } from '@angular/core'; import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; -import { PlaceholderModule } from '@o3r/components'; +import { PlaceholderComponent } from '@o3r/components'; import { O3rComponent } from '@o3r/core'; import { RulesEngineRunnerModule } from '@o3r/rules-engine'; import { Subscription } from 'rxjs'; @@ -19,7 +19,7 @@ const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000; encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ - PlaceholderModule, + PlaceholderComponent, ReactiveFormsModule, RulesEngineRunnerModule, DatePickerInputPresComponent diff --git a/docs/rules-engine/how-to-use/placeholders.md b/docs/rules-engine/how-to-use/placeholders.md index 94a6744cff..bd8be5fe08 100644 --- a/docs/rules-engine/how-to-use/placeholders.md +++ b/docs/rules-engine/how-to-use/placeholders.md @@ -22,9 +22,11 @@ export class SearchModule {} ``` Then add the placeholder in your HTML with a unique id + ```html Placeholder loading ... ``` + The loading message is provided by projection. Feel free to provide a spinner if you need. Once your placeholder has been added, you will need to manually create the metadata file and add the path to the extract-components property in your angular.json @@ -44,6 +46,7 @@ Metadata file example: } ] ``` + And then, in the `angular.json` file: ```json @@ -64,8 +67,10 @@ And then, in the `angular.json` file: The placeholders will be merged inside the component metadata file that will be sent to the CMS. ### Inside a library component + Add the module and the placeholder to your HTML the same way as before but this time you need to create the metadata file in an associated package. Metadata file example: + ```json [ { @@ -80,27 +85,31 @@ Metadata file example: } ] ``` + And then in the angular.json: + ```json ... - "extract-components": { - "builder": "@o3r/components:extractor", - "options": { - "tsConfig": "modules/@scope/components/tsconfig.metadata.json", - "configOutputFile": "modules/@scope/components/dist/component.config.metadata.json", - "componentOutputFile": "modules/@scope/components/dist/component.class.metadata.json", - "placeholdersMetadataFilePath": "placeholders.metadata.json" - } - }, + "extract-components": { + "builder": "@o3r/components:extractor", + "options": { + "tsConfig": "modules/@scope/components/tsconfig.metadata.json", + "configOutputFile": "modules/@scope/components/dist/component.config.metadata.json", + "componentOutputFile": "modules/@scope/components/dist/component.class.metadata.json", + "placeholdersMetadataFilePath": "placeholders.metadata.json" + } + }, ... ``` ## Supported features (check how-it-works section for more details) + * HTML limited to Angular sanitizer supported behavior * URLs (relative ones will be processed to add the `dynamic-media-path`) * Facts references ### Static localization + The first choice you have when you want to localize your template is the static localization. You need to create a localized template for each locale and provide the template URL with `[LANGUAGE]` (ex: *assets/placeholders/[LANGUAGE]/myPlaceholder.json*) The rules engine service will handle the replacement of [LANGUAGE] for you, and when you change language a new call will be performed to the new 'translated' URL. @@ -109,6 +118,7 @@ Note that the URL caching mechanism is based on the url NOT 'translated', meanin This behavior is based on the fact that a real user rarely goes back and forth with the language update. ### Multiple templates in same placeholder + You can use placeholder actions to target the same placeholderId with different template URLs. It groups the rendered templates in the same placeholder, and you can choose the order by using the `priority` attribute in the action. If not specified, the priority defaults to 0. Then the higher the number, the higher the priority. The final results are displayed in descending order of priority. @@ -116,13 +126,16 @@ The placeholder component waits for all the calls to be resolved (not pending) t The placeholder component ignores a template if the application failed to retrieve it. ## Investigate issues + If the placeholder is not rendered properly, you can perform several checks to find out the root cause, simply looking at the store state. Example: ![store-state.png](../../../.attachments/screenshots/rules-engine-debug/store_state.png) ## Reference CSS classes from AEM Editor + You need to reference one or several CSS files from your application in the `cms.json` file: + ```json { "assetsFolder": "dist/assets", @@ -135,15 +148,51 @@ You need to reference one or several CSS files from your application in the `cms ] } ``` + Those files will be loaded by the CMS to show the placeholder preview. Note that you could provide an empty file and update it with the dynamic content mechanism from AEM, to be able to reference the new classes afterwards. There is just no user-friendly editor available yet. You can include this file in your application using the style loader service in your app component: + ```typescript this.styleLoader.asyncLoadStyleFromDynamicContent({id: 'placeholders-styling', href: 'assets/rules/placeholders.css'}); ``` ### How to create placeholders from AEM + For this part, please refer to the Experience Fragments in DES documentation: -https://dev.azure.com/AmadeusDigitalAirline/DES%20Platform/_wiki/wikis/DES%20Documentation/1964/Experience-Fragments-in-DES + +## Manual usage of the Placeholder + +The Placeholder does not require the Rules Engine to be used and can be integrated in your application independently. + +To do so you will need to import the `PlaceholderModule` in your application (as described in the [previous section](#/inside-an-application)) and describe the template to set your application placeholders in the following manner: + +```typescript +import { EffectsModule } from '@ngrx/effects'; +import { PlaceholderService, PlaceholderTemplateResponseEffect } from '@o3r/components'; + +@NgModule({ + import: [ + EffectsModule.forFeature([PlaceholderTemplateResponseEffect]) + ], + declaration: [ + MyApplication + ] +}) +class MyMainModule { +} + +@Component() +class MyApplication { + constructor(readonly placeholderService: PlaceholderService) { + placeholderService.updatePlaceholderTemplateUrls([ + { + placeholderId: 'pl2358lv-2c63-42e1-b450-6aafd91fbae8', + value: 'https://url-to-my-template' + } + ]); + } +} +``` diff --git a/packages/@o3r/components/src/rules-engine/placeholder.action-handler.ts b/packages/@o3r/components/src/rules-engine/placeholder.action-handler.ts index cb9a7808bd..b66adc7ff5 100644 --- a/packages/@o3r/components/src/rules-engine/placeholder.action-handler.ts +++ b/packages/@o3r/components/src/rules-engine/placeholder.action-handler.ts @@ -1,183 +1,22 @@ -import { Injectable, Injector, OnDestroy, Optional } from '@angular/core'; -import { select, Store } from '@ngrx/store'; +import { Injectable } from '@angular/core'; import type { RulesEngineActionHandler } from '@o3r/core'; -import { - deletePlaceholderTemplateEntity, - PlaceholderRequestReply, - PlaceholderTemplateStore, - selectPlaceholderRequestEntities, - selectPlaceholderTemplateEntities, - setPlaceholderRequestEntityFromUrl, - setPlaceholderTemplateEntity, - updatePlaceholderRequestEntity -} from '@o3r/components'; -import { DynamicContentService } from '@o3r/dynamic-content'; -import { LocalizationService } from '@o3r/localization'; -import { LoggerService } from '@o3r/logger'; -import { combineLatest, distinctUntilChanged, firstValueFrom, map, of, startWith, Subject, Subscription, withLatestFrom } from 'rxjs'; import { ActionUpdatePlaceholderBlock, RULES_ENGINE_PLACEHOLDER_UPDATE_ACTION_TYPE } from './placeholder.interfaces'; +import { PlaceholderService } from '@o3r/components'; /** * Service to handle async PlaceholderTemplate actions */ @Injectable() -export class PlaceholderRulesEngineActionHandler implements OnDestroy, RulesEngineActionHandler { - - protected subscription = new Subscription(); - - protected placeholdersActions$: Subject<{ placeholderId: string; templateUrl: string; priority: number }[]> = new Subject(); +export class PlaceholderRulesEngineActionHandler implements RulesEngineActionHandler{ /** @inheritdoc */ public readonly supportingActions = [RULES_ENGINE_PLACEHOLDER_UPDATE_ACTION_TYPE] as const; - constructor( - store: Store, - private readonly logger: LoggerService, - private readonly injector: Injector, - @Optional() translateService?: LocalizationService - ) { - const lang$ = translateService ? translateService.getTranslateService().onLangChange.pipe( - map(({ lang }) => lang), - startWith(translateService.getCurrentLanguage()), - distinctUntilChanged() - ) : of(null); - - const filteredActions$ = combineLatest([ - lang$, - this.placeholdersActions$.pipe( - distinctUntilChanged((prev, next) => JSON.stringify(prev) === JSON.stringify(next)) - ) - ]).pipe( - withLatestFrom( - combineLatest([store.pipe(select(selectPlaceholderTemplateEntities)), store.pipe(select(selectPlaceholderRequestEntities))]) - ), - map(([langAndTemplatesUrls, storedPlaceholdersAndRequests]) => { - const [lang, placeholderActions] = langAndTemplatesUrls; - const storedPlaceholders = storedPlaceholdersAndRequests[0] || {}; - const storedPlaceholderRequests = storedPlaceholdersAndRequests[1] || {}; - const placeholderNewRequests: { rawUrl: string; resolvedUrl: string }[] = []; - // Stores all raw Urls used from the current engine execution - const usedUrls: Record = {}; - // Get all Urls that needs to be resolved from current rules engine output - const placeholdersTemplates = placeholderActions.reduce((acc, placeholderAction) => { - const placeholdersTemplateUrl = { - rawUrl: placeholderAction.templateUrl, - priority: placeholderAction.priority - }; - if (acc[placeholderAction.placeholderId]) { - acc[placeholderAction.placeholderId].push(placeholdersTemplateUrl); - } else { - acc[placeholderAction.placeholderId] = [placeholdersTemplateUrl]; - } - const resolvedUrl = this.resolveUrlWithLang(placeholderAction.templateUrl, lang); - // Filters duplicates and resolved urls that are already in the store - if (!usedUrls[placeholderAction.templateUrl] && (!storedPlaceholderRequests[placeholderAction.templateUrl] - || storedPlaceholderRequests[placeholderAction.templateUrl]!.resolvedUrl !== resolvedUrl)) { - placeholderNewRequests.push({ - rawUrl: placeholderAction.templateUrl, - resolvedUrl: this.resolveUrlWithLang(placeholderAction.templateUrl, lang) - }); - } - usedUrls[placeholderAction.templateUrl] = true; - return acc; - }, {} as { [key: string]: { rawUrl: string; priority: number }[] }); - // Urls not used anymore and not already disabled - const placeholderRequestsToDisable: string[] = []; - // Urls used that were disabled - const placeholderRequestsToEnable: string[] = []; - Object.keys(storedPlaceholderRequests).forEach((storedPlaceholderRequestRawUrl) => { - const usedFromEngineIteration = usedUrls[storedPlaceholderRequestRawUrl]; - const usedFromStore = (storedPlaceholderRequests && storedPlaceholderRequests[storedPlaceholderRequestRawUrl]) ? storedPlaceholderRequests[storedPlaceholderRequestRawUrl]!.used : false; - if (!usedFromEngineIteration && usedFromStore) { - placeholderRequestsToDisable.push(storedPlaceholderRequestRawUrl); - } else if (usedFromEngineIteration && !usedFromStore) { - placeholderRequestsToEnable.push(storedPlaceholderRequestRawUrl); - } - }); - // Placeholder that are no longer filled by the current engine execution output will be cleared - const placeholdersTemplatesToBeCleanedUp = Object.keys(storedPlaceholders) - .filter(placeholderId => !placeholdersTemplates[placeholderId]); - - const placeholdersTemplatesToBeSet = Object.keys(placeholdersTemplates).reduce((changedPlaceholderTemplates, placeholderTemplateId) => { - // Caching if the placeholder template already exists with the same urls - if (!storedPlaceholders[placeholderTemplateId] || - !(JSON.stringify(storedPlaceholders[placeholderTemplateId]!.urlsWithPriority) === JSON.stringify(placeholdersTemplates[placeholderTemplateId]))) { - changedPlaceholderTemplates.push({ - id: placeholderTemplateId, - urlsWithPriority: placeholdersTemplates[placeholderTemplateId] - }); - } - return changedPlaceholderTemplates; - }, [] as { id: string; urlsWithPriority: { rawUrl: string; priority: number }[] }[]); - return { - placeholdersTemplatesToBeCleanedUp, - placeholderRequestsToDisable, - placeholderRequestsToEnable, - placeholdersTemplatesToBeSet, - placeholderNewRequests - }; - }) - ); - this.subscription.add(filteredActions$.subscribe((placeholdersUpdates) => { - placeholdersUpdates.placeholdersTemplatesToBeCleanedUp.forEach(placeholderId => - store.dispatch(deletePlaceholderTemplateEntity({ - id: placeholderId - })) - ); - placeholdersUpdates.placeholdersTemplatesToBeSet.forEach(placeholdersTemplateToBeSet => { - store.dispatch(setPlaceholderTemplateEntity({ entity: placeholdersTemplateToBeSet })); - }); - placeholdersUpdates.placeholderRequestsToDisable.forEach(placeholderRequestToDisable => { - store.dispatch(updatePlaceholderRequestEntity({ entity: { id: placeholderRequestToDisable, used: false } })); - }); - placeholdersUpdates.placeholderRequestsToEnable.forEach(placeholderRequestToEnable => { - store.dispatch(updatePlaceholderRequestEntity({ entity: { id: placeholderRequestToEnable, used: true } })); - }); - placeholdersUpdates.placeholderNewRequests.forEach(placeholderNewRequest => { - store.dispatch(setPlaceholderRequestEntityFromUrl({ - resolvedUrl: placeholderNewRequest.resolvedUrl, - id: placeholderNewRequest.rawUrl, - call: this.retrieveTemplate(placeholderNewRequest.resolvedUrl) - })); - }); - })); - } - - /** - * Localize the url, replacing the language marker - * @param url - * @param language - */ - protected resolveUrlWithLang(url: string, language: string | null): string { - if (!language && url.includes('[LANGUAGE]')) { - this.logger.warn(`Missing language when trying to resolve ${url}`); - } - return language ? url.replace(/\[LANGUAGE]/g, language) : url; - } - - /** - * Retrieve template as json from a given url - * @param url - */ - protected async retrieveTemplate(url: string): Promise { - const resolvedUrl$ = this.injector.get(DynamicContentService, null, { optional: true })?.getContentPathStream(url) || of(url); - const fullUrl = await firstValueFrom(resolvedUrl$); - return fetch(fullUrl).then((response) => response.json()); + constructor(private readonly placeholderService: PlaceholderService) { } /** @inheritdoc */ public executeActions(actions: ActionUpdatePlaceholderBlock[]) { - const templates = actions.map((action) => ({ - placeholderId: action.placeholderId, - templateUrl: action.value, - priority: action.priority || 0 - })); - - this.placeholdersActions$.next(templates); - } - - /** @inheritdoc */ - public ngOnDestroy(): void { - this.subscription.unsubscribe(); + this.placeholderService.updatePlaceholderTemplateUrls(actions); } } diff --git a/packages/@o3r/components/src/rules-engine/placeholder.interfaces.ts b/packages/@o3r/components/src/rules-engine/placeholder.interfaces.ts index f191f8f2ed..976e80f1cf 100644 --- a/packages/@o3r/components/src/rules-engine/placeholder.interfaces.ts +++ b/packages/@o3r/components/src/rules-engine/placeholder.interfaces.ts @@ -1,4 +1,5 @@ import type { RulesEngineAction } from '@o3r/core'; +import type { PlaceholderUrlUpdate } from '@o3r/components'; /** ActionUpdatePlaceholderBlock */ export const RULES_ENGINE_PLACEHOLDER_UPDATE_ACTION_TYPE = 'UPDATE_PLACEHOLDER'; @@ -6,9 +7,5 @@ export const RULES_ENGINE_PLACEHOLDER_UPDATE_ACTION_TYPE = 'UPDATE_PLACEHOLDER'; /** * Content of action that updates a placeholder */ -export interface ActionUpdatePlaceholderBlock extends RulesEngineAction { - actionType: typeof RULES_ENGINE_PLACEHOLDER_UPDATE_ACTION_TYPE; - placeholderId: string; - value: string; - priority?: number; +export interface ActionUpdatePlaceholderBlock extends RulesEngineAction, PlaceholderUrlUpdate { } diff --git a/packages/@o3r/components/src/rules-engine/placeholder.rules-engine.module.ts b/packages/@o3r/components/src/rules-engine/placeholder.rules-engine.module.ts index ddc0b45bda..c458d17b16 100644 --- a/packages/@o3r/components/src/rules-engine/placeholder.rules-engine.module.ts +++ b/packages/@o3r/components/src/rules-engine/placeholder.rules-engine.module.ts @@ -1,14 +1,15 @@ import { NgModule } from '@angular/core'; +import { PlaceholderModule, PlaceholderRequestStoreModule, PlaceholderTemplateStoreModule } from '@o3r/components'; import { EffectsModule } from '@ngrx/effects'; -import { PlaceholderRequestStoreModule, PlaceholderTemplateStoreModule } from '@o3r/components'; import { PlaceholderRulesEngineActionHandler } from './placeholder.action-handler'; -import { PlaceholderTemplateResponseEffect } from './placeholder.rules-engine.effect'; +import { PlaceholderTemplateResponseEffect } from '@o3r/components'; @NgModule({ imports: [ EffectsModule.forFeature([PlaceholderTemplateResponseEffect]), PlaceholderRequestStoreModule, - PlaceholderTemplateStoreModule + PlaceholderTemplateStoreModule, + PlaceholderModule ], providers: [ PlaceholderRulesEngineActionHandler diff --git a/packages/@o3r/components/src/stores/index.ts b/packages/@o3r/components/src/stores/index.ts index e1bc69f1a6..1057ec16d0 100644 --- a/packages/@o3r/components/src/stores/index.ts +++ b/packages/@o3r/components/src/stores/index.ts @@ -1,2 +1,3 @@ export * from './placeholder-template/index'; export * from './placeholder-request/index'; +export * from './placeholder-effect/index'; diff --git a/packages/@o3r/components/src/stores/placeholder-effect/index.ts b/packages/@o3r/components/src/stores/placeholder-effect/index.ts new file mode 100644 index 0000000000..e2535e73d8 --- /dev/null +++ b/packages/@o3r/components/src/stores/placeholder-effect/index.ts @@ -0,0 +1 @@ +export * from './placeholder.effect'; diff --git a/packages/@o3r/components/src/rules-engine/placeholder.rules-engine.effect.spec.ts b/packages/@o3r/components/src/stores/placeholder-effect/placeholder.effect.spec.ts similarity index 98% rename from packages/@o3r/components/src/rules-engine/placeholder.rules-engine.effect.spec.ts rename to packages/@o3r/components/src/stores/placeholder-effect/placeholder.effect.spec.ts index c0443851bb..fb090a939c 100644 --- a/packages/@o3r/components/src/rules-engine/placeholder.rules-engine.effect.spec.ts +++ b/packages/@o3r/components/src/stores/placeholder-effect/placeholder.effect.spec.ts @@ -3,18 +3,16 @@ import {provideMockActions} from '@ngrx/effects/testing'; import type {TypedAction} from '@ngrx/store/src/models'; import {UpdateAsyncStoreItemEntityActionPayloadWithId} from '@o3r/core'; import {firstValueFrom, of, ReplaySubject, Subject, Subscription} from 'rxjs'; -import type { - PlaceholderRequestModel, - PlaceholderRequestReply -} from '@o3r/components'; import { + type PlaceholderRequestModel, + type PlaceholderRequestReply, setPlaceholderRequestEntityFromUrl -} from '../stores'; +} from '../index'; import {DynamicContentService} from '@o3r/dynamic-content'; import {LocalizationService} from '@o3r/localization'; import {shareReplay} from 'rxjs/operators'; import { RulesEngineRunnerService } from '@o3r/rules-engine'; -import {PlaceholderTemplateResponseEffect} from './placeholder.rules-engine.effect'; +import {PlaceholderTemplateResponseEffect} from './placeholder.effect'; import {Store} from '@ngrx/store'; describe('Rules Engine Effects', () => { diff --git a/packages/@o3r/components/src/rules-engine/placeholder.rules-engine.effect.ts b/packages/@o3r/components/src/stores/placeholder-effect/placeholder.effect.ts similarity index 62% rename from packages/@o3r/components/src/rules-engine/placeholder.rules-engine.effect.ts rename to packages/@o3r/components/src/stores/placeholder-effect/placeholder.effect.ts index 8474a73985..e96e9cf084 100644 --- a/packages/@o3r/components/src/rules-engine/placeholder.rules-engine.effect.ts +++ b/packages/@o3r/components/src/stores/placeholder-effect/placeholder.effect.ts @@ -1,4 +1,4 @@ -import {Injectable, Optional} from '@angular/core'; +import {Injectable, Injector} from '@angular/core'; import {Actions, createEffect, ofType} from '@ngrx/effects'; import { cancelPlaceholderRequest, @@ -8,15 +8,12 @@ import { selectPlaceholderRequestEntityUsage, setPlaceholderRequestEntityFromUrl, updatePlaceholderRequestEntity -} from '@o3r/components'; +} from '../placeholder-request'; import {fromApiEffectSwitchMapById} from '@o3r/core'; -import {DynamicContentService} from '@o3r/dynamic-content'; -import {LocalizationService} from '@o3r/localization'; -import {RulesEngineRunnerService} from '@o3r/rules-engine'; -import {combineLatest, EMPTY, Observable, of} from 'rxjs'; -import {distinctUntilChanged, map, switchMap, take} from 'rxjs/operators'; +import {combineLatest, EMPTY, from, Observable, of} from 'rxjs'; +import {catchError, distinctUntilChanged, map, switchMap, take} from 'rxjs/operators'; import {Store} from '@ngrx/store'; -import { JSONPath } from 'jsonpath-plus'; +import { Logger } from '@o3r/logger'; /** * Service to handle async PlaceholderTemplate actions @@ -34,18 +31,42 @@ export class PlaceholderTemplateResponseEffect { fromApiEffectSwitchMapById( (templateResponse, action) => { const facts = templateResponse.vars ? Object.entries(templateResponse.vars).filter(([, variable]) => variable.type === 'fact') : []; - const factsStreamsList = this.rulesEngineService ? facts.map(([varName, fact]) => - this.rulesEngineService!.engine.retrieveOrCreateFactStream(fact.value).pipe( - map((factValue) => ({ - varName, - factName: fact.value, - // eslint-disable-next-line new-cap - factValue: (fact.path && factValue) ? JSONPath({ wrap: false, json: factValue, path: fact.path }) : factValue - })), - distinctUntilChanged((previous, current) => previous.factValue === current.factValue) - )) : []; + const factsStreamsList$ = from(import('@o3r/rules-engine')).pipe( + // eslint-disable-next-line @typescript-eslint/naming-convention + switchMap(({ RulesEngineRunnerService }) => { + const engine = this.injector.get(RulesEngineRunnerService, null, {optional: true})?.engine; + return engine && facts.length ? combineLatest( + facts.map(([varName, fact]) => + engine.retrieveOrCreateFactStream(fact.value).pipe( + switchMap(async (factValue) => { + if (fact.path) { + try { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { JSONPath } = await import('jsonpath-plus'); + return { + varName, + factName: fact.value, + // eslint-disable-next-line new-cap + factValue: factValue ? JSONPath({ wrap: false, json: factValue, path: fact.path }) : factValue + }; + } catch { + this.logger.error(`The variable ${varName} is based on a fact with Json Path parameter requiring a dependency to 'jsonpath-plus. It should be provided by the application`); + } + } - const factsStreamsList$ = factsStreamsList.length ? combineLatest(factsStreamsList) : of([]); + return { + varName, + factName: fact.value, + factValue + }; + }), + distinctUntilChanged((previous, current) => previous.factValue === current.factValue) + ) + ) + ) : of([]); + }), + catchError(() => of([])) + ); return combineLatest([factsStreamsList$, this.store.select(selectPlaceholderRequestEntityUsage(action.id)).pipe(distinctUntilChanged())]).pipe( switchMap(([factsUsedInTemplate, placeholderRequestUsage]) => { if (!placeholderRequestUsage) { @@ -75,11 +96,10 @@ export class PlaceholderTemplateResponseEffect { ); constructor( + private readonly logger: Logger, private readonly actions$: Actions, private readonly store: Store, - @Optional() private readonly rulesEngineService: RulesEngineRunnerService | null, - @Optional() private readonly dynamicContentService: DynamicContentService | null, - @Optional() private readonly translationService: LocalizationService | null) { + private readonly injector: Injector) { } /** @@ -106,10 +126,14 @@ export class PlaceholderTemplateResponseEffect { switch (vars[varName].type) { case 'relativeUrl': { replacements$.push( - this.dynamicContentService?.getMediaPathStream(vars[varName].value).pipe( - take(1), - map((value: string) => ({ejsVar, value})) - ) || of({ ejsVar, value: vars[varName].value }) + from(import('@o3r/dynamic-content')).pipe( + // eslint-disable-next-line @typescript-eslint/naming-convention + switchMap(({ DynamicContentService }) => this.injector.get(DynamicContentService, null, { optional: true })?.getMediaPathStream(vars[varName].value).pipe( + take(1), + map((value: string) => ({ ejsVar, value })) + ) || of({ ejsVar, value: vars[varName].value })), + catchError(() => of({ ejsVar, value: vars[varName].value })) + ) ); break; } @@ -132,11 +156,13 @@ export class PlaceholderTemplateResponseEffect { return acc; }, linkedVars); replacements$.push( - this.translationService ? - this.translationService.translate(vars[varName].value, linkedParams).pipe( + from(import('@o3r/localization')).pipe( + // eslint-disable-next-line @typescript-eslint/naming-convention + switchMap(({ LocalizationService }) => this.injector.get(LocalizationService, null, { optional: true })?.translate(vars[varName].value, linkedParams).pipe( map((value) => (value ? { ejsVar, value } : null)) - ) : - of(null) + ) || of(null)), + catchError(() => of(null)) + ) ); break; } diff --git a/packages/@o3r/components/src/tools/placeholder/index.ts b/packages/@o3r/components/src/tools/placeholder/index.ts index b708fb1b01..7dab859dbd 100644 --- a/packages/@o3r/components/src/tools/placeholder/index.ts +++ b/packages/@o3r/components/src/tools/placeholder/index.ts @@ -1,2 +1,4 @@ export * from './placeholder.component'; +export * from './placeholder.interface'; export * from './placeholder.module'; +export * from './placeholder.service'; diff --git a/packages/@o3r/components/src/tools/placeholder/placeholder.component.ts b/packages/@o3r/components/src/tools/placeholder/placeholder.component.ts index d261d47716..95f190a651 100644 --- a/packages/@o3r/components/src/tools/placeholder/placeholder.component.ts +++ b/packages/@o3r/components/src/tools/placeholder/placeholder.component.ts @@ -8,12 +8,14 @@ import { type Signal, ViewEncapsulation } from '@angular/core'; -import {Store} from '@ngrx/store'; -import {sendOtterMessage} from '@o3r/core'; -import {BehaviorSubject, ReplaySubject, sample, Subject, Subscription} from 'rxjs'; -import {distinctUntilChanged, filter, map, switchMap} from 'rxjs/operators'; -import {type PlaceholderMode, PlaceholderTemplateStore, selectPlaceholderTemplateMode, selectSortedTemplates} from '../../stores/placeholder-template'; -import {PlaceholderLoadingStatus, PlaceholderLoadingStatusMessage} from './placeholder.interface'; +import { Store, StoreModule } from '@ngrx/store'; +import { BehaviorSubject, ReplaySubject, sample, Subject, Subscription } from 'rxjs'; +import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; +import { PlaceholderLoadingStatus, PlaceholderLoadingStatusMessage } from './placeholder.interface'; +import { sendOtterMessage } from '@o3r/core'; +import { CommonModule } from '@angular/common'; +import { type PlaceholderMode, PlaceholderTemplateStore, PlaceholderTemplateStoreModule, selectPlaceholderTemplateMode, selectSortedTemplates } from '../../stores/placeholder-template/index'; +import { PlaceholderRequestStoreModule } from '../../stores/placeholder-request/index'; /** * Placeholder component that is bind to the PlaceholderTemplateStore to display a template based on its ID @@ -23,6 +25,13 @@ import {PlaceholderLoadingStatus, PlaceholderLoadingStatusMessage} from './place */ @Component({ selector: 'o3r-placeholder', + standalone: true, + imports: [ + CommonModule, + StoreModule, + PlaceholderTemplateStoreModule, + PlaceholderRequestStoreModule + ], templateUrl: './placeholder.template.html', styleUrl: './placeholder.style.scss', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/packages/@o3r/components/src/tools/placeholder/placeholder.interface.ts b/packages/@o3r/components/src/tools/placeholder/placeholder.interface.ts index c6b59efe2e..59d9c08f03 100644 --- a/packages/@o3r/components/src/tools/placeholder/placeholder.interface.ts +++ b/packages/@o3r/components/src/tools/placeholder/placeholder.interface.ts @@ -16,6 +16,20 @@ export interface PlaceholderLoadingStatus { placeholderId?: string; } +/** + * Information required to download and update a Placeholder + */ +export interface PlaceholderUrlUpdate { + /** ID of the placeholder for which to edit the URL */ + placeholderId: string; + + /** URL of the template */ + value: string; + + /** Priority of the template in case of multi template for a given Placeholder */ + priority?: number; +} + /** * Message to describe a placeholder's loading status: the templates to be loaded and the pending status. */ diff --git a/packages/@o3r/components/src/tools/placeholder/placeholder.module.ts b/packages/@o3r/components/src/tools/placeholder/placeholder.module.ts index 3073a5c3e4..3702fe3078 100644 --- a/packages/@o3r/components/src/tools/placeholder/placeholder.module.ts +++ b/packages/@o3r/components/src/tools/placeholder/placeholder.module.ts @@ -1,18 +1,10 @@ -import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; -import { StoreModule } from '@ngrx/store'; -import { PlaceholderTemplateStoreModule } from '../../stores/placeholder-template/index'; import { PlaceholderComponent } from './placeholder.component'; -import { PlaceholderRequestStoreModule } from '../../stores/placeholder-request/index'; @NgModule({ imports: [ - CommonModule, - StoreModule, - PlaceholderTemplateStoreModule, - PlaceholderRequestStoreModule + PlaceholderComponent ], - declarations: [PlaceholderComponent], exports: [PlaceholderComponent] }) export class PlaceholderModule { } diff --git a/packages/@o3r/components/src/tools/placeholder/placeholder.service.ts b/packages/@o3r/components/src/tools/placeholder/placeholder.service.ts new file mode 100644 index 0000000000..bd7408ddc9 --- /dev/null +++ b/packages/@o3r/components/src/tools/placeholder/placeholder.service.ts @@ -0,0 +1,184 @@ +import { Injectable, Injector, OnDestroy } from '@angular/core'; +import { LoggerService } from '@o3r/logger'; +import { catchError, combineLatest, distinctUntilChanged, firstValueFrom, from, map, of, Subject, Subscription, switchMap, withLatestFrom } from 'rxjs'; +import { + deletePlaceholderTemplateEntity, + PlaceholderRequestReply, + PlaceholderTemplateStore, + selectPlaceholderRequestEntities, + selectPlaceholderTemplateEntities, + setPlaceholderRequestEntityFromUrl, + setPlaceholderTemplateEntity, + updatePlaceholderRequestEntity +} from '../../stores/index'; +import { select, Store } from '@ngrx/store'; +import type { PlaceholderUrlUpdate } from './placeholder.interface'; + +/** + * Service to handle async PlaceholderTemplate actions + */ +@Injectable({ + providedIn: 'root' +}) +export class PlaceholderService implements OnDestroy { + + protected subscription = new Subscription(); + + protected placeholdersActions$: Subject<{ placeholderId: string; templateUrl: string; priority: number }[]> = new Subject(); + + constructor(store: Store, private readonly injector: Injector, private readonly logger: LoggerService) { + + const lang$ = from(import('@o3r/localization')).pipe( + // eslint-disable-next-line @typescript-eslint/naming-convention + switchMap(({ LocalizationService }) => + this.injector.get(LocalizationService, null)?.getTranslateService().onLangChange.pipe( + map(({ lang }) => lang), + distinctUntilChanged() + ) || of(null) + ), + catchError(() => of(null))); + + const filteredActions$ = combineLatest([ + lang$, + this.placeholdersActions$.pipe(distinctUntilChanged((prev, next) => JSON.stringify(prev) === JSON.stringify(next))) + ]).pipe( + withLatestFrom( + combineLatest([store.pipe(select(selectPlaceholderTemplateEntities)), store.pipe(select(selectPlaceholderRequestEntities))]) + ), + map(([langAndTemplatesUrls, storedPlaceholdersAndRequests]) => { + const [lang, placeholderActions] = langAndTemplatesUrls; + const storedPlaceholders = storedPlaceholdersAndRequests[0] || {}; + const storedPlaceholderRequests = storedPlaceholdersAndRequests[1] || {}; + const placeholderNewRequests: { rawUrl: string; resolvedUrl: string }[] = []; + // Stores all raw Urls used from the current engine execution + const usedUrls: Record = {}; + // Get all Urls that needs to be resolved from current rules engine output + const placeholdersTemplates = placeholderActions.reduce((acc, placeholderAction) => { + const placeholdersTemplateUrl = { + rawUrl: placeholderAction.templateUrl, + priority: placeholderAction.priority + }; + if (acc[placeholderAction.placeholderId]) { + acc[placeholderAction.placeholderId].push(placeholdersTemplateUrl); + } else { + acc[placeholderAction.placeholderId] = [placeholdersTemplateUrl]; + } + const resolvedUrl = this.resolveUrlWithLang(placeholderAction.templateUrl, lang); + // Filters duplicates and resolved urls that are already in the store + if (!usedUrls[placeholderAction.templateUrl] && (!storedPlaceholderRequests[placeholderAction.templateUrl] + || storedPlaceholderRequests[placeholderAction.templateUrl]!.resolvedUrl !== resolvedUrl)) { + placeholderNewRequests.push({ + rawUrl: placeholderAction.templateUrl, + resolvedUrl: this.resolveUrlWithLang(placeholderAction.templateUrl, lang) + }); + } + usedUrls[placeholderAction.templateUrl] = true; + return acc; + }, {} as { [key: string]: { rawUrl: string; priority: number }[] }); + // Urls not used anymore and not already disabled + const placeholderRequestsToDisable: string[] = []; + // Urls used that were disabled + const placeholderRequestsToEnable: string[] = []; + Object.keys(storedPlaceholderRequests).forEach((storedPlaceholderRequestRawUrl) => { + const usedFromEngineIteration = usedUrls[storedPlaceholderRequestRawUrl]; + const usedFromStore = (storedPlaceholderRequests && storedPlaceholderRequests[storedPlaceholderRequestRawUrl]) ? storedPlaceholderRequests[storedPlaceholderRequestRawUrl]!.used : false; + if (!usedFromEngineIteration && usedFromStore) { + placeholderRequestsToDisable.push(storedPlaceholderRequestRawUrl); + } else if (usedFromEngineIteration && !usedFromStore) { + placeholderRequestsToEnable.push(storedPlaceholderRequestRawUrl); + } + }); + // Placeholder that are no longer filled by the current engine execution output will be cleared + const placeholdersTemplatesToBeCleanedUp = Object.keys(storedPlaceholders) + .filter(placeholderId => !placeholdersTemplates[placeholderId]); + + const placeholdersTemplatesToBeSet = Object.keys(placeholdersTemplates).reduce((changedPlaceholderTemplates, placeholderTemplateId) => { + // Caching if the placeholder template already exists with the same urls + if (!storedPlaceholders[placeholderTemplateId] || + !(JSON.stringify(storedPlaceholders[placeholderTemplateId]!.urlsWithPriority) === JSON.stringify(placeholdersTemplates[placeholderTemplateId]))) { + changedPlaceholderTemplates.push({ + id: placeholderTemplateId, + urlsWithPriority: placeholdersTemplates[placeholderTemplateId] + }); + } + return changedPlaceholderTemplates; + }, [] as { id: string; urlsWithPriority: { rawUrl: string; priority: number }[] }[]); + return { + placeholdersTemplatesToBeCleanedUp, + placeholderRequestsToDisable, + placeholderRequestsToEnable, + placeholdersTemplatesToBeSet, + placeholderNewRequests + }; + }) + ); + this.subscription.add(filteredActions$.subscribe((placeholdersUpdates) => { + placeholdersUpdates.placeholdersTemplatesToBeCleanedUp.forEach(placeholderId => + store.dispatch(deletePlaceholderTemplateEntity({ + id: placeholderId + })) + ); + placeholdersUpdates.placeholdersTemplatesToBeSet.forEach(placeholdersTemplateToBeSet => { + store.dispatch(setPlaceholderTemplateEntity({ entity: placeholdersTemplateToBeSet })); + }); + placeholdersUpdates.placeholderRequestsToDisable.forEach(placeholderRequestToDisable => { + store.dispatch(updatePlaceholderRequestEntity({ entity: { id: placeholderRequestToDisable, used: false } })); + }); + placeholdersUpdates.placeholderRequestsToEnable.forEach(placeholderRequestToEnable => { + store.dispatch(updatePlaceholderRequestEntity({ entity: { id: placeholderRequestToEnable, used: true } })); + }); + placeholdersUpdates.placeholderNewRequests.forEach(placeholderNewRequest => { + store.dispatch(setPlaceholderRequestEntityFromUrl({ + resolvedUrl: placeholderNewRequest.resolvedUrl, + id: placeholderNewRequest.rawUrl, + call: this.retrieveTemplate(placeholderNewRequest.resolvedUrl) + })); + }); + })); + } + + /** + * Localize the url, replacing the language marker + * @param url + * @param language + */ + protected resolveUrlWithLang(url: string, language: string | null): string { + if (!language && url.includes('[LANGUAGE]')) { + this.logger.warn(`Missing language when trying to resolve ${url}`); + } + return language ? url.replace(/\[LANGUAGE]/g, language) : url; + } + + /** + * Retrieve template as json from a given url + * @param url + */ + protected async retrieveTemplate(url: string): Promise { + const resolvedUrl$ = from(import('@o3r/dynamic-content')).pipe( + // eslint-disable-next-line @typescript-eslint/naming-convention + switchMap(({ DynamicContentService }) => this.injector.get(DynamicContentService, null)?.getContentPathStream(url) || of(url)), + catchError(() => of(url)) + ); + const fullUrl = await firstValueFrom(resolvedUrl$); + return fetch(fullUrl).then((response) => response.json()); + } + + /** + * Update the template URLs of all the placeholders + * @param placeholderUpdates list of placeholder templates to update + */ + public updatePlaceholderTemplateUrls(placeholderUpdates: PlaceholderUrlUpdate[]) { + const templates = placeholderUpdates.map((action) => ({ + placeholderId: action.placeholderId, + templateUrl: action.value, + priority: action.priority || 0 + })); + + this.placeholdersActions$.next(templates); + } + + /** @inheritdoc */ + public ngOnDestroy(): void { + this.subscription.unsubscribe(); + } +} diff --git a/packages/@o3r/components/src/tools/placeholder/placeholder.spec.ts b/packages/@o3r/components/src/tools/placeholder/placeholder.spec.ts index bf9c200b6b..376512a58a 100644 --- a/packages/@o3r/components/src/tools/placeholder/placeholder.spec.ts +++ b/packages/@o3r/components/src/tools/placeholder/placeholder.spec.ts @@ -3,7 +3,7 @@ import {Component} from '@angular/core'; import {ComponentFixture, getTestBed, TestBed} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {BrowserDynamicTestingModule, platformBrowserDynamicTesting} from '@angular/platform-browser-dynamic/testing'; -import {Store} from '@ngrx/store'; +import {Store, StoreModule} from '@ngrx/store'; import {ReplaySubject, Subject} from 'rxjs'; import {PlaceholderComponent} from './placeholder.component'; @@ -54,13 +54,14 @@ describe('Placeholder component', () => { }; await TestBed.configureTestingModule({ imports: [ - CommonModule + StoreModule.forRoot(), + CommonModule, + PlaceholderComponent ], providers: [ {provide: Store, useValue: mockStore} ], declarations: [ - PlaceholderComponent, TestComponent ] }).compileComponents(); diff --git a/packages/@o3r/core/package.json b/packages/@o3r/core/package.json index 2e39004613..01cae2a6a2 100644 --- a/packages/@o3r/core/package.json +++ b/packages/@o3r/core/package.json @@ -152,12 +152,12 @@ "@ngrx/store-devtools": "~17.2.0", "@o3r/store-sync": "workspace:^", "@types/jest": "~29.5.2", - "nx": "~18.2.0", + "nx": "~18.3.0", "@typescript-eslint/parser": "^7.2.0", "@stylistic/eslint-plugin-ts": "^1.5.4", "cpy-cli": "^5.0.0", "eslint": "^8.57.0", - "@nx/eslint-plugin": "~18.2.0", + "@nx/eslint-plugin": "~18.3.0", "jsonc-eslint-parser": "~2.4.0", "eslint-import-resolver-node": "^0.3.9", "eslint-plugin-jest": "~27.9.0", diff --git a/yarn.lock b/yarn.lock index 6fee281f72..3bb15df81c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8609,6 +8609,7 @@ __metadata: jest-junit: "npm:~16.0.0" jest-preset-angular: "npm:~14.0.3" jsonc-eslint-parser: "npm:~2.4.0" + jsonpath-plus: "npm:^8.0.0" lighthouse: "npm:9.6.8" ngx-highlightjs: "npm:^10.0.0" pixelmatch: "npm:^5.2.1"