Skip to content

POC Report: WebComponents

Stéphane Brunner edited this page Nov 26, 2021 · 43 revisions

Introduction

We archive a POC with those libraries:

We choose to write the new components in TypeScript.

We use the Chromatic service to review and test the examples.

Lit element / HTML

The main addition of this POC is the use of Web Components on top of services. For the needs of templating and code structure, we choose to write Web-Components using a library: Lit. This is a small footprint library.

Lit (historically called lit-element) is built around the standards of Web Components.

This is well supported in modern libraries or even native JavaScript. Using such design insures us to get some interoperability for the future and keep the existing code functioning with the rapid evolution of the front-end ecosystem. "More standards, the better".

Web components, and it's Lit implementation consists of three main technologies, which can be used together to create versatile custom elements with encapsulated functionality that can be reused wherever you like without fear of code collisions:

  • Custom elements
  • Shadow DOM
  • HTML Templates

Here is a basic example to create a Web Component using Lit (auth.ts):

import {html, css, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators.js';

@customElement('auth')
export class Auth extends LitElement {
  static styles = css`p { color: blue }`;

  @property()
  login = 'Username';

  render() {
    return html`<p>Hello, ${this.login}!</p>`;
  }
}

Then we have a HMTL file (index.html):

<!DOCTYPE html>
<head>
  <script type="module" src="./auth.ts"></script>
</head>
<body>
  <auth login="Someone"></auth>
</body>

Advantage against using HTMLelement in vanilla JavaScript

If we go on this page https://webcomponents.dev/blog/all-the-ways-to-make-a-web-component/ and compare the HTMLelement example and the Lit (new) TypeScript example we can see that the Lit example is a little shorter but not enough to be a good argument, if we see the changes, in the vanilla example we can see:

  <button id="dec">-</button>

...

    this.shadowRoot.getElementById("dec").onclick = () => this.dec();

And in Lit:

      <button @click="${this.dec}">-</button>

With is more convenient for the developer, and it prevents some issues.

We will have the same thing with data-binding.

See: Expression.

We have some available decorator for the properties_

  • @state to define a property that should be listened to trigger a new render of the component.
  • @property state and expose the property an attribute in the HTML, with some converter functions.

The static style is provided in a separate property, and it can be provided as an array, then we can do something like that:

  static style = [
    ...BaseElement.styles,
    css`
      <my custom styles>
    `
  ];

With is also more convenient.

And finally an important point Lit detect the changes in the rendered HTML to recuse the operation on the DOM and improving the performances.

Integration with AngularJS

The attributes are a little bit renamed:

ng-on-close-panel="mainCtrl.ngeoAuthActive = $event.detail" => Code called, then the close-panel event is dispatched from the component.

ng-prop-login_info_message="mainCtrl.loginInfoMessage" => set the property loginInfoMessage of the WebComponent with custom object.

Refer to ngOn and ngProp in the AngularJS documentation for full details.

Store RXJS

A store, not a state. Mainly based on https://rxjs.dev/api/index/class/BehaviorSubject, a multicasted observable with a default value. But There are other "Subject" possibilities.

Works well and without bad surprise. Easy to access, easy to set. Light to implement.

The code must be simple. One set function should emit one thing.

The integrity of a stored value must be well checked by the store.

RXJS offer a lot of operators to play with Observable (see https://rxmarbles.com). From experience, it's better not to use too much of them (the code becomes quickly hard to follow and to change).

Using observable allows use to remove our old events and centralize the state of the application.

I18n

Evaluate i18next and lit-localize

Choice for i18next because with lit-localize we can’t translate text out of the component, and we need to translate some part of the HTML like the page title.

Translate the tag content in the HTML:

<title data-i18n="Alternative Desktop Application">GeoMapFish</title>

Translate a tag attribute in the HTML:

<button data-i18n="[tooltip]Draw and Measure"/>

Translate just a string in the HTML: Not possible

Translate just a string in the JavaScript(WebComponent):

import i18next from 'i18next';
html`${i18next.t("Loading themes, please wait...")}`

Plural in the JavaScript(WebComponent):

import i18next from 'i18next';
const i18nextConf = {count: themes.length};
html`${i18next.t("Loading {{count}} themes, please wait...", i18nextConf)}`

But this can't be used because it breaks the English translating who actually is in the sources... Same issue for context: https://www.i18next.com/translation-function/context

Config

The configuration will be dispatched by RXJS, and be fully typed, see src/state/config.ts.

Usage:

import configuration, {Configuration} from 'ngeo/store/config';

this.subscriptions_.push(
  configuration.getConfig().subscribe({
    next: (configuration: Configuration) => {
      // Use the configuration
    },
  })
);

TypeScript

We choose TypeScript to write the new component as it's largely accepted by lit-element, lit-element itself is written in TypeScript. Other motivation is that we get some limitations in typing in the JSdoc.

Translate JavaScrypt to TypeScript, see: https://github.com/camptocamp/ngeo/blob/poc_2.7/docs/codeshift.md

Service migration

The principle is to have a class instantiate (a single time) at the end of the file. Here is the canvas of a example service:

export class MyService {

  property: string;

  constructor() {
    this.property = 'something';
  }

  someFunction(param: string): string {
    this.property = param;
    return this.property;
  }
}

const ngeoMyService = new MyService();
export default ngeoMyService;

As it is instantiate in the service file itself then exported in a ECMAScript module, we can now import and use it in other files as follow:

import ngeoMyService from 'path/file';

ngeoMyService.someFunction('hello world');

Separate build

TODO: Finish

We provide the following element throw a Singleton available in window, then we can access to the in a separate build. In this way, by default the HTML page will be compiled by the ngeo build (not modifiable in the project).

The states are exposed to be able to use the out of the script (currently: user, config, OpenLayers map).

The goal of the simple application mode is to simplify the migration.

The general idea for the simple application is to completely separate the project and the GeoMapFish code. For the backend by creating a separate container. For the frontend by providing in the config container a JavaScript and a CSS, in the addition of what's already in the vars.yaml in particular the CSS variable.

See: https://github.com/camptocamp/demo_geomapfish/pull/255

The main things that's not possible in this architecture is the authentication (LDAP, ...), and extending the GeoMapFish data model (but it's difficult to maintain). For having a separate class that didn't have to be visible in the admin interface, it can be done in the additional container. But for that, we didn't need to have an advance application we can just extend the image and just override some files.

For the authentication, we can imagine to support SAML 2.0 SSO, and for LDAP we should investigate if it's possible to have a generic implementation.

Finally, here https://camptocamp.github.io/c2cgeoportal/master/integrator/extend_application.html you have all the documentation on what and how you can extend the simple application (additional service, additional web component, custom authentication).

Component extension

Looks working as before except for the CSS that is slightly harder to modify with the Shadow DOM elements but still possible.

See:

Note: To facilitate customization of existing classes we should:

  • Always provide use the CSS injection mechanisme in the render template (or in a method in the component).
  • Split the render methods to multiple smaller methods.
  • Never have long constructor method (It's not possible to override them !)
  • Static properties and lit-element properties looks not possible to be overridden neither.
  • Keep generally every method as short as possible or split them into multiple smaller one.
  • Continue to export everything that could make sense to be overridden (almost everything).

Example / StoryBook

We will use StoryBook for the new examples. StoryBook is more than providing the example, with that, we can also do test and review with Chromatic It make a little documentation of a web component. It will also a base for the tests.

Tests

Finally, Cypress is implemented. It has a good end-to-end tests as good unit tests possibilities, embeded ui, good cli... and gives a good feeling (Even if the doc is not so clear if you don't use React).

Testing models and services are mandatory.

As we use Storybook, we don't need the (beta) extension to test components, we can use "the standard way to test" on Storybook pages. Using Chromatic, we have a good check on examples. Therefore, testing our component with Cypress is not so useful. But If the evolution of the examples make them more complex, like including a real authentication for the auth component, we should test that with Cypress, via the example. Otherwise, we should use Cypress for more end-to-end tests directly on the apps.

The alternatives would have been Jest, Web Test Runner...

All change that affect the project

  • All the specific views should be moved to a container that will be completely manage by the project. Example-based documentations will be provided to create an image based on Pyramid and cornice.
    • In the docker-compose.yaml you will add your new service with a build's configuration to be able to easily build your component with the ./build command.
  • All the UI components should be migrated to web components in a separate build. Example-based documentations will be provided to create a components based on Lit, and build with vite.
    • To integrate your JavaScript file in the configuration image, you will add a completely independent section using the multi-stage build.
  • The main web page will be simplified by using a component that provided the page structure and the components will be placed by using slots and without attribute, like that:
<gmf-desktop-canvas>
  ...
  <!-- Login -->
  <gmf-auth-button slot="tool-button"></gmf-auth-button>
  <gmf-auth-panel slot="tool-panel-auth"></gmf-auth-panel>
  ...
</gmf-desktop-canvas>
  • The srcapi (or gmfapi) will contains component with more stable API and can be used in the custom build without adding them, but throw the window object.
  • We will not have any more SCSS in ngeo, then the project can't set the SCSS variable. We also use shadow DOM then the global CSS isn't used in the web components. The solution is to use the cssVars where it's possible, note that it pass the shadow DOM, and the custom CSS to be able to add some CSS to a specific component.
  • We provide some base components (BaseElement, ToolButtonElement, ToolPanelElement), that already contains the Bootstrap and FontAwesome CSS, we probably need to duplicate them when we will migrate to a new version of bootstrap and maintain tow version of those components.
  • GeoMapFish will probably not use any framework like Vue it you require one, you can add it in the custom build.
  • To e.g. change the pyramid authentication you will be required to manually extend the GeoMapFish geoportal image and replace the application initializing file.

All those changes in the desktop interface can be done from the version 2.7 of GeoMapFish.

Clone this wiki locally