diff --git a/apps/webcomponents/project.json b/apps/webcomponents/project.json index 8da07826e5..cc786d0503 100644 --- a/apps/webcomponents/project.json +++ b/apps/webcomponents/project.json @@ -14,15 +14,7 @@ "main": "apps/webcomponents/src/main.ts", "polyfills": "apps/webcomponents/src/polyfills.ts", "tsConfig": "apps/webcomponents/tsconfig.app.json", - "assets": [ - "apps/webcomponents/src/favicon.ico", - "apps/webcomponents/src/assets", - { - "glob": "*", - "input": "translations", - "output": "assets/i18n/" - } - ], + "assets": [], "styles": [], "scripts": [], "allowedCommonJsDependencies": [ diff --git a/apps/webcomponents/src/app/components/gn-map-viewer/gn-map-viewer.component.ts b/apps/webcomponents/src/app/components/gn-map-viewer/gn-map-viewer.component.ts index c9b383e42d..136ab6f275 100644 --- a/apps/webcomponents/src/app/components/gn-map-viewer/gn-map-viewer.component.ts +++ b/apps/webcomponents/src/app/components/gn-map-viewer/gn-map-viewer.component.ts @@ -1,7 +1,6 @@ import { ChangeDetectionStrategy, Component, - Injector, ViewEncapsulation, } from '@angular/core' import { BaseComponent } from '../base.component' diff --git a/apps/webcomponents/src/app/webcomponents.module.ts b/apps/webcomponents/src/app/webcomponents.module.ts index f94d42dbff..d5274d25eb 100644 --- a/apps/webcomponents/src/app/webcomponents.module.ts +++ b/apps/webcomponents/src/app/webcomponents.module.ts @@ -18,7 +18,7 @@ import { import { EffectsModule } from '@ngrx/effects' import { StoreModule } from '@ngrx/store' import { StoreDevtoolsModule } from '@ngrx/store-devtools' -import { TranslateModule } from '@ngx-translate/core' +import { TranslateLoader, TranslateModule } from '@ngx-translate/core' import { AppComponent } from './app.component' import { AppOverlayContainer } from './AppOverlayContainer' import { apiConfiguration, BaseComponent } from './components/base.component' @@ -31,6 +31,7 @@ import { GnMapViewerComponent } from './components/gn-map-viewer/gn-map-viewer.c import { FeatureMapModule } from '@geonetwork-ui/feature/map' import { GnDatasetViewChartComponent } from './components/gn-dataset-view-chart/gn-dataset-view-chart.component' import { FeatureDatavizModule } from '@geonetwork-ui/feature/dataviz' +import { EmbeddedTranslateLoader } from '@geonetwork-ui/util/i18n' const CUSTOM_ELEMENTS: [new (...args) => BaseComponent, string][] = [ [GnFacetsComponent, 'gn-facets'], @@ -68,7 +69,13 @@ const CUSTOM_ELEMENTS: [new (...args) => BaseComponent, string][] = [ StoreDevtoolsModule.instrument(), EffectsModule.forRoot(), UtilI18nModule, - TranslateModule.forRoot(TRANSLATE_DEFAULT_CONFIG), + TranslateModule.forRoot({ + ...TRANSLATE_DEFAULT_CONFIG, + loader: { + provide: TranslateLoader, + useClass: EmbeddedTranslateLoader, + }, + }), MatIconModule, FeatureDatavizModule, ], diff --git a/docs/guide/webcomponents.md b/docs/guide/webcomponents.md index d9e080944e..20a241292a 100644 --- a/docs/guide/webcomponents.md +++ b/docs/guide/webcomponents.md @@ -4,6 +4,175 @@ outline: deep # Web components -## Chapter 1 +Visit the online [demo page](https://geonetwork.github.io/geonetwork-ui/main/demo/webcomponents/). +This directory contains [Web Components](https://developer.mozilla.org/en-US/docs/Web/Web_Components) relying on the same code as the full GeoNetwork UI, and which are available for use in third-party apps. -## Chapter 2 +Web Components are published through an Angular application `webcomponents` hosted in [`apps/webcomponents/src`](https://github.com/geonetwork/geonetwork-ui/tree/main/apps/webcomponents/src) folder. It's a common Angular application, the only difference is that all Angular components +are registered as Web Components in the application module. + +All Web Components are prefixed with `gn-`. + +## Use + +Web Components are made to be easily included in any context. To do so, you have to: + +- import the Web Component script exported by Angular (available via jsdelivr) +- include your Web Component in the HTML content. + +```html + +... + +``` + +## Build + +All Angular custom elements are served by the same application `webcomponents`. + +Therefore, there is only one build and one javascript file for all web components called `gn-wc.js`. + +``` +npm run build:demo +``` + +You'll find the built files in `dist/demo/webcomponents` folder + +## Run + +To test your Web Component in a real production context + +```shell script +npm run demo +``` + +**Important:** The components are built in `production` mode. + +You can go to http://localhost:8001/ to visit GeoNetwork-UI Web Components demo pages. + +You'll be able to test your Web Components on `http://localhost:8001/webcomponents/{name_of_sample_file}` + +e.g: http://localhost:8001/webcomponents/gn-results-list.sample.html + +## Create a new Web Component + +The architecture is designed so you can export an Angular component to a custom element (eg Web Component), +that is encapsulated with its style in a shadow DOM element, and can be embedded in any website. + +To export content as a Web Component you have to: + +- create a new folder in [`/apps/webcomponents/src/app/components`](https://github.com/geonetwork/geonetwork-ui/tree/main/apps/webcomponents/src/app/components), the folder name must start with `gn-` +- create a new component in this folder, with same name, that will be exported, this component must have the following properties in the metadata decorator: + +```typescript +{ + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.ShadowDom +} +``` + +- add your component in the application module [`webcomponents.module.ts`](https://github.com/geonetwork/geonetwork-ui/blob/main/apps/webcomponents/src/app/webcomponents.module.ts) `declarations` list. +- register your component as a custom element in the `CUSTOM_ELEMENTS` array in application module [`webcomponents.module.ts`](https://github.com/geonetwork/geonetwork-ui/blob/main/apps/webcomponents/src/app/webcomponents.module.ts), the custom element identifier (i.e Web Component tag name) _must_ be the same as the component folder name + +```typescript +const CUSTOM_ELEMENTS: any[] = [ + [GnFacetsComponent, 'gn-facets'], + [GnResultsListComponent, 'gn-results-list'], + [GnAggregatedRecordsComponent, 'gn-aggregated-records'], +] +} +``` + +- Add stories for storybook to run it (angular and element stories) +- Add a sample HTML file to show how to use it in a third party web page `${webcomponent_name}.sample.html` eg. gn-results-list.sample.html + +## Update Web Component inputs + +You can handle angular custom elements input changes exactly as it's done for Angular component: within the `onChanges` implementation. + +Update Web Component input values from the source page: + +```html +
+ +
+ + + +``` + +In your angular component, listen to these changes + +```typescript + private setSearch_() { + this.store.dispatch( + new SetSearch({ filters: { any: this.filter }, size: this.size }) + ) + } + + ngOnChanges(changes: SimpleChanges): void { + super.ngOnChanges(changes) + this.setSearch_() + } +``` + +This process must follow some rules: + +- Don't call api request before the Web Component has initialized `API_BASE_PATH` +- `ngOnChanges` is called the first time before `ngOnInit`, so put your init code in `ngOnchanges` instead. +- Be sure to trigger the change detection when it is expected, because the Web Component execution (even though it's in an angular custom element) is outside an Angular zone, meaning the change detection is not triggered. + +```typescript + constructor( + private changeDetector: ChangeDetectorRef + ) { + super() + } + + ngOnInit(): void { + super.ngOnInit() + setTimeout(() => { + // Be sure to update the source page when the state is updated + this.store.pipe(select(getSearchResultsLoading)).subscribe((v) => { + this.changeDetector.detectChanges() + }) + }) + } +``` + +## HTML embedder + +The file [`wc-embedder.html`](https://github.com/geonetwork/geonetwork-ui/blob/main/tools/webcomponent/wc-embedder.html) can be used to wrap a geonetwork-ui Web Component into a full HTML page, +for example to be used in an [iframe](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe). + +To use it, specify the name and attributes of the Web Component to be created when accessing the page: + +``` +wc-embedder.html?e=gn-dataset-view-table&a=api-url=https://dev.geo2france.fr/geonetwork/srv/api&a=primary-color=%230f4395&a=secondary-color=%238bc832&a=main-color=%23555&a=background-color=%23fdfbff +``` + +> Note the `#` being encoded to `%23` + +The following query parameters are supported: + +- `e` (single): element name, such as `gn-results-list` +- `a` (multiple): attributes, specified in the following format: `a=attribute-name=attribute-value` + +The created element will be sized to take the full width and height of the page, thus allowing precise sizing when used in an iframe. + +The Web Components used are the latest ones distributed on the [`wc-dist` branch](https://github.com/geonetwork/geonetwork-ui/blob/wc-dist). + +The HTML Embedder is available in all docker images on the following path: + +http://localhost:8080/APP_NAME/wc-embedder.html diff --git a/docs/index.md b/docs/index.md index caae8debdc..d8b80d55bd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,7 +20,7 @@ features: details: Connect geonetwork-ui platform to your favorite dataset - icon: 📦 title: Prepare - details: Arrange the dataset to make it talks. + details: Arrange the dataset to make it talk. - icon: 💫 title: Publish details: Make your dataset public and available. diff --git a/libs/util/i18n/src/index.ts b/libs/util/i18n/src/index.ts index 44e4a25c1a..538d5aa5a5 100644 --- a/libs/util/i18n/src/index.ts +++ b/libs/util/i18n/src/index.ts @@ -3,3 +3,4 @@ export * from './lib/i18n.constants' export * from './lib/i18n.interceptor' export * from './lib/lang.service' export * from './lib/testing/test.translate.module' +export * from './lib/embedded.translate.loader' diff --git a/libs/util/i18n/src/lib/embedded.translate.loader.spec.ts b/libs/util/i18n/src/lib/embedded.translate.loader.spec.ts new file mode 100644 index 0000000000..b9ec171d32 --- /dev/null +++ b/libs/util/i18n/src/lib/embedded.translate.loader.spec.ts @@ -0,0 +1,38 @@ +import { firstValueFrom } from 'rxjs' +import { EmbeddedTranslateLoader } from './embedded.translate.loader' + +jest.mock( + '../../../../../translations/en.json', + () => ({ + 'first.label': 'First Label.', + 'second.label': 'Second Label.', + }), + { virtual: true } +) +jest.mock( + '../../../../../translations/fr.json', + () => ({ + 'first.label': '', + 'second.label': 'Deuxième libellé.', + }), + { virtual: true } +) +describe('EmbeddedTranslateLoader', () => { + let loader: EmbeddedTranslateLoader + beforeEach(() => { + loader = new EmbeddedTranslateLoader() + }) + it('should create an instance', () => { + expect(loader).toBeTruthy() + }) + it('uses only 2 letter code (ignore regional code)', async () => { + const translation = await firstValueFrom(loader.getTranslation('en_US')) + expect(translation['first.label']).toEqual('First Label.') + }) + it('filters out empty translations', async () => { + const translation = await firstValueFrom(loader.getTranslation('fr')) + expect(translation).toEqual({ + 'second.label': 'Deuxième libellé.', + }) + }) +}) diff --git a/libs/util/i18n/src/lib/embedded.translate.loader.ts b/libs/util/i18n/src/lib/embedded.translate.loader.ts new file mode 100644 index 0000000000..c83ad8ea2a --- /dev/null +++ b/libs/util/i18n/src/lib/embedded.translate.loader.ts @@ -0,0 +1,18 @@ +import { TranslateLoader } from '@ngx-translate/core' +import { Observable, map, of } from 'rxjs' +import { dropEmptyTranslations } from './translate.loader.utils' +import de from '../../../../../translations/de.json' +import en from '../../../../../translations/en.json' +import es from '../../../../../translations/es.json' +import fr from '../../../../../translations/fr.json' +import it from '../../../../../translations/it.json' +import nl from '../../../../../translations/nl.json' +import pt from '../../../../../translations/pt.json' + +export class EmbeddedTranslateLoader implements TranslateLoader { + getTranslation(lang: string): Observable { + const langs = { de, en, es, fr, it, nl, pt } + const translations = langs[lang.substr(0, 2)] + return of(translations).pipe(map(dropEmptyTranslations)) + } +} diff --git a/libs/util/i18n/src/lib/file.translate.loader.ts b/libs/util/i18n/src/lib/file.translate.loader.ts index f54d4d2fe7..bfe962ebbd 100644 --- a/libs/util/i18n/src/lib/file.translate.loader.ts +++ b/libs/util/i18n/src/lib/file.translate.loader.ts @@ -4,6 +4,7 @@ import { } from '@geonetwork-ui/util/app-config' import { TranslateHttpLoader } from '@ngx-translate/http-loader' import { map } from 'rxjs/operators' +import { dropEmptyTranslations } from './translate.loader.utils' /** * This loader will rely on JSON files in the app assets, as well as an app config @@ -14,7 +15,7 @@ export class FileTranslateLoader extends TranslateHttpLoader { getTranslation(lang: string) { const baseLang = lang.substr(0, 2) // removing the right part of e.g. en_EN return super.getTranslation(baseLang).pipe( - map(this.transform), + map(dropEmptyTranslations), map((translations) => { if (isConfigLoaded()) { return { ...translations, ...getCustomTranslations(baseLang) } @@ -23,16 +24,4 @@ export class FileTranslateLoader extends TranslateHttpLoader { }) ) } - - private transform(translations) { - // filter out empty keys: this should let us fallback on the default lang or - // untranslated key, instead of having a blank space - return Object.keys(translations).reduce( - (prev, curr) => - translations[curr].trim().length - ? { ...prev, [curr]: translations[curr] } - : prev, - {} - ) - } } diff --git a/libs/util/i18n/src/lib/i18n.constants.ts b/libs/util/i18n/src/lib/i18n.constants.ts index c6406821c6..ef21f4b854 100644 --- a/libs/util/i18n/src/lib/i18n.constants.ts +++ b/libs/util/i18n/src/lib/i18n.constants.ts @@ -57,11 +57,12 @@ export function HttpLoaderFactory(http: HttpClient) { export function getLangFromBrowser() { return navigator.language.substr(0, 2) } +const COMPILER_CONFIG = { + provide: TranslateCompiler, + useClass: TranslateMessageFormatCompiler, +} export const TRANSLATE_DEFAULT_CONFIG = { - compiler: { - provide: TranslateCompiler, - useClass: TranslateMessageFormatCompiler, - }, + compiler: COMPILER_CONFIG, loader: { provide: TranslateLoader, useFactory: HttpLoaderFactory, diff --git a/libs/util/i18n/src/lib/translate.loader.utils.spec.ts b/libs/util/i18n/src/lib/translate.loader.utils.spec.ts new file mode 100644 index 0000000000..7e8b8c8018 --- /dev/null +++ b/libs/util/i18n/src/lib/translate.loader.utils.spec.ts @@ -0,0 +1,13 @@ +import { dropEmptyTranslations } from './translate.loader.utils' + +const FR = { + 'first.label': '', + 'second.label': 'Deuxième libellé.', +} +describe('TranslateLoaderUtils', () => { + it('should filter out empty translations', () => { + expect(dropEmptyTranslations(FR)).toEqual({ + 'second.label': 'Deuxième libellé.', + }) + }) +}) diff --git a/libs/util/i18n/src/lib/translate.loader.utils.ts b/libs/util/i18n/src/lib/translate.loader.utils.ts new file mode 100644 index 0000000000..b502983504 --- /dev/null +++ b/libs/util/i18n/src/lib/translate.loader.utils.ts @@ -0,0 +1,11 @@ +export function dropEmptyTranslations(translations) { + // filter out empty keys: this should let us fallback on the default lang or + // untranslated key, instead of having a blank space + return Object.keys(translations).reduce( + (prev, curr) => + translations[curr].trim().length + ? { ...prev, [curr]: translations[curr] } + : prev, + {} + ) +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 2e284c8548..26a040752b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -18,6 +18,7 @@ "strictFunctionTypes": false, "strictPropertyInitialization": false, "noImplicitAny": false, + "resolveJsonModule": true, "esModuleInterop": true, "paths": { "@geonetwork-ui/data-access/datafeeder": [