Skip to content

Commit

Permalink
feat(components): expose the placeholder service to be ready to be us…
Browse files Browse the repository at this point in the history
…ed without rule-engine
  • Loading branch information
kpanot committed Apr 10, 2024
1 parent 56e22af commit 232ea58
Show file tree
Hide file tree
Showing 9 changed files with 283 additions and 191 deletions.
70 changes: 60 additions & 10 deletions docs/rules-engine/how-to-use/placeholders.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ export class SearchModule {}
```

Then add the placeholder in your HTML with a unique id

```html
<o3r-placeholder messagePanel id="pl2358lv-2c63-42e1-b450-6aafd91fbae8">Placeholder loading ...</o3r-placeholder>
```

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
Expand All @@ -44,6 +46,7 @@ Metadata file example:
}
]
```

And then, in the `angular.json` file:

```json
Expand All @@ -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
[
{
Expand All @@ -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.
Expand All @@ -109,20 +118,24 @@ 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.
The placeholder component waits for all the calls to be resolved (not pending) to display the content.
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",
Expand All @@ -135,15 +148,52 @@ 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
<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 requires the Rules Engine to be used and can be integrated to your application independently.

To do so you will need to import the `PlaceholderModule` in your application (as described in the [preview section](#/inside-an-application)) and describe the template to set to your application placeholders in the following manner:

```typescript
import { EffectsModule } from '@ngrx/effects';
import { PlaceholderService } from '@o3r/components';
import { PlaceholderTemplateResponseEffect } from '@o3r/components/rules-engine';

@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'
}
]);
}
}
```
Original file line number Diff line number Diff line change
@@ -1,174 +1,22 @@
import { inject, Injectable, OnDestroy, Optional } from '@angular/core';
import { Injectable } from '@angular/core';
import type { RulesEngineActionHandler } from '@o3r/core';
import { LoggerService } from '@o3r/logger';
import { combineLatest, distinctUntilChanged, firstValueFrom, map, of, Subject, Subscription, withLatestFrom } from 'rxjs';
import {
deletePlaceholderTemplateEntity,
PlaceholderRequestReply,
PlaceholderTemplateStore,
selectPlaceholderRequestEntities,
selectPlaceholderTemplateEntities,
setPlaceholderRequestEntityFromUrl,
setPlaceholderTemplateEntity,
updatePlaceholderRequestEntity
} from '@o3r/components';
import { select, Store } from '@ngrx/store';
import { LocalizationService } from '@o3r/localization';
import { ActionUpdatePlaceholderBlock, RULES_ENGINE_PLACEHOLDER_UPDATE_ACTION_TYPE } from './placeholder.interfaces';
import { DynamicContentService } from '@o3r/dynamic-content';
import { PlaceholderService } from '@o3r/components';

/**
* Service to handle async PlaceholderTemplate actions
*/
@Injectable()
export class PlaceholderRulesEngineActionHandler implements OnDestroy, RulesEngineActionHandler<ActionUpdatePlaceholderBlock> {

protected subscription = new Subscription();

protected placeholdersActions$: Subject<{ placeholderId: string; templateUrl: string; priority: number }[]> = new Subject();
export class PlaceholderRulesEngineActionHandler implements RulesEngineActionHandler<ActionUpdatePlaceholderBlock>{

/** @inheritdoc */
public readonly supportingActions = [RULES_ENGINE_PLACEHOLDER_UPDATE_ACTION_TYPE] as const;

constructor(store: Store<PlaceholderTemplateStore>, private readonly logger: LoggerService, @Optional() translateService?: LocalizationService) {

const lang$ = translateService ? translateService.getTranslateService().onLangChange.pipe(
map(({ lang }) => lang),
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<string, boolean> = {};
// 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<PlaceholderRequestReply> {
const resolvedUrl$ = inject(DynamicContentService, { 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);
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import type { RulesEngineAction } from '@o3r/core';
import type { PlaceholderUrlUpdate } from '@o3r/components';

/** ActionUpdatePlaceholderBlock */
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<typeof RULES_ENGINE_PLACEHOLDER_UPDATE_ACTION_TYPE, string>, PlaceholderUrlUpdate {
}
Original file line number Diff line number Diff line change
@@ -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';

@NgModule({
imports: [
EffectsModule.forFeature([PlaceholderTemplateResponseEffect]),
PlaceholderRequestStoreModule,
PlaceholderTemplateStoreModule
PlaceholderTemplateStoreModule,
PlaceholderModule
],
providers: [
PlaceholderRulesEngineActionHandler
Expand Down
2 changes: 2 additions & 0 deletions packages/@o3r/components/src/tools/placeholder/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from './placeholder.component';
export * from './placeholder.service';
export * from './placeholder.module';
export * from './placeholder.interface';

0 comments on commit 232ea58

Please sign in to comment.