Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(components): expose the placeholder service in @o3r/components #1621

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/showcase/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand Down
73 changes: 61 additions & 12 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,9 +46,10 @@ Metadata file example:
}
]
```

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

```json
```json5
...
"extract-components": {
"builder": "@o3r/components:extractor",
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

```json5
...
"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,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
<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'
}
]);
}
}
```
Original file line number Diff line number Diff line change
@@ -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<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,
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<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$ = 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);
}
}
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 {
}