Vue (2.x), Storybook (5.x), Web Components e nient'altro
Tags: vue, storybook, webcomponents, testing
Published: 2020-09-28 06:49:56

[English version](https://dev.to/lucabro81/vue-2-x-storybook-5-x-web-components-e-nothing-else-5ak6)

---

* [Intro](#intro)
  * [Cos'è Vue.js?](#vue-js)
  * [Cosa sono i Web Components?](#web-components)
  * [Cos'è Storybook?](#storybook)
* [Definizione problema](#problem)
  * [Creazione progetto di test](#test-project)
  * [Aggiunta di Storybook](#add-storybook)
  * [Creazione di un Web Component](#create-web-component)
  * [Problema con gli stili](#css-problem)
* [Ipotesi sulla soluzione](#hypothesis)
* [implementazione di una soluzione](#solution)
  * [vue_config.js](#vue-config)
  * [Includere il web component nella story](#web-component-in-story)
  * [Registrare il componente](#register-component)
  * [Integrare l'interfaccia delle stories](#integrate-interface)
* [Conclusioni e credits](#credits)

---

# Intro <a name="vue-js"></a>

### Cos'è [Vue.js](https://vuejs.org/)? <a name="intro"></a>

Vediamo cosa dice la documentazione:

> Vue (pronounced /vjuː/, like view) is a progressive framework for building user interfaces. Unlike other monolithic frameworks, Vue is designed from the ground up to be incrementally adoptable. The core library is focused on the view layer only, and is easy to pick up and integrate with other libraries or existing projects. On the other hand, Vue is also perfectly capable of powering sophisticated Single-Page Applications when used in combination with modern tooling and supporting libraries.
[...]

In atre parole, Vue è framework javascript da utilizzarsi nella realizzazione di frontend. Dalla sua ha la semplicità d'uso e di setup, il template code richiesto è minimo ed è comunque performante, tanto da essere riuscito a ritagliarsi nel tempo un proprio rispettabilissimo spazio accanto a framework decisamente molto più noti ed utilizzati (sì ovviamente sto parlando di Angular e React). Niente di più, niente di meno.

### Cosa sono i [Web Components](https://www.webcomponents.org/)? <a name="web-components"></a>

Se ne è scritto e tutt'ora se ne scrive (e spero se ne continuerà a scrivere) tantissimo, mi limiterò a fornire una piccola sintesi: i web components, in breve, non sono altro che componenti frontend i quali, una volta registrati dal browser e quindi da questo riconosciuti, possono essere usati come normali tag html con i propri attributi, parametri e comportamento peculiare. 
Possono essere definiti tramite classi in js vanilla o usando un framework che li supporti, nello specifico, come è facile intuire, in questo articolo si parlerà di web component definiti utilizzando Vue.js

### Cos'è [Storybook](https://storybook.js.org/)? <a name="storybook"></a>

Storybook è un eccellente tool per il testing visuale di componenti UI, compatibile con tutti i maggiori framework js ed utilizzabile anche con js vanilla. Tutto quello che si deve fare è specificare quale componente renderizzare, fornire dei mock data e lasciare che storybook istanzi il nostro componente in un proprio iframe e il gioco è fatto. La criticità con vue nasce dalla difficoltà di poter istanziare semplici web components senza utilizzare altre dipendenze.

---

# Definizione del problema <a name="problem"></a>

### Creazione progetto di test <a name="test-project"></a>

Creare web components con Vue non è un problema, tanto che la sua cli permette di specificare [un target apposito per questo compito](https://cli.vuejs.org/guide/build-targets.html#web-component) e, con alcuni accorgimenti, è possibile testarli anche con il server di sviluppo.

Cerchiamo ora di andare un po' più nel dettaglio, la procedura per definire un web component in Vue è decisamente banale, partiamo da un normale progetto Vue:

```
vue create vue-webcomponent-storybook-test
```

la mia configurazione custom è stata **typescript, babel, scss (dart-sass) e basic linter on save**. 
Ciò che si otterrà sarà un'alberatura di questo tipo:

```
├── dist
├── node_modules
├── public
│   ├── favicon.ico
│   └── index.html
├── src
│   ├── assets
│   │   └── logo.png
│   ├── components
│   │   └── HelloWorld.vue
│   ├── App.vue
│   ├── main.ts
│   ├── shims-tsx.d.ts
│   └── shims-vue.d.ts
├── .gitignore
├── babel.config.js
├── package.json
├── README.md
├── tsconfig.json
├── vue.config.js
└── yarn.lock
```

Se tutto è andato liscio, da terminale, lanciando `yarn serve`, potremo vedere la nostra app con il componente `HelloWorld.vue` di test, fare bella mostra di se su `http://localhost:8080/`.

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/czzjw7civ3cvw2ksxrg3.png)

### Aggiunta di Storybook <a name="add-storybook"></a>

Il secondo passo consiste nell'installare [Storybook](https://github.com/storybookjs/vue-cli-plugin-storybook) attraverso il plugin manager di Vue, anche qui l'operazione non è particolarmente impegnativa:

```
vue add storybook
```

Storybook aggiungerà alcuni file e cartelle:

```
├── config
│   └── storybook
│       └── storybook.js
├── dist
├── node_modules
├── public
│   ├── favicon.ico
│   └── index.html
├── src
│   ├── assets
│   │   └── logo.png
│   ├── components
│   │   ├── Helloworld.vue
│   │   └── MyButton.vue
│   ├── stories
│   │   ├── index.stories.js
│   │   └── index.stories.mdx
│   ├── App.vue
│   ├── main.ts
│   ├── shims-tsx.d.ts
│   └── shims-vue.d.ts
├── .gitignore
├── babel.config.js
├── package.json
├── README.md
├── tsconfig.json
├── vue.config.js
└── yarn.lock
```

Possiamo eliminare tranquillamente il componente `src/components/MyButton.vue` e la story `src/stories/index.stories.mdx`, non saranno necessari al nostro progetto. 

All'interno di `src/stories/index.stories.js` creiamo una story per il nostro componente `App.vue`:

{% gist https://gist.github.com/lucabro81/fc4f7b840576a691f2e9cf688d0ca7d9 %}

Ora lanciando il task `storybook:serve`, si avvierà un server di test che permetterà di eseguire storybook e testare il nostor componente:

```
npm run storybook:serve
```


![Storybook first run](https://dev-to-uploads.s3.amazonaws.com/i/ap8xjr30hwfcxkhavltf.png)


(Al momento della scrittura sembra che avviare storybook con yarn non sia possibile).

### Creazione di un Web Component <a name="create-web-component"></a>

Il secondo passo consiste nel wrappare il nostro componente (lavoreremo con il componente root di default, `App.vue`, questo ci permetterà di vedere l'inclusione di altri componenti e come si comportano i loro stili, tutto è ovviamente replicabile per qualunque componente) all'interno di una classe che estende `HTMLElement` (link approfondimento), questo non verrà fatto direttamente da noi, ma attraverso una api fornita da Vue. Alla fine di questo step il file `main.ts` avrà questo aspetto:

{% gist https://gist.github.com/lucabro81/6a1421d0c77a4fa7976490d0023cc3cc %}

`customElements.define` fa parte dell'api js che materialmente permette di registrare il nostro componente al browser con il tag name `my-web-component`.
Una piccola nota, se usate typescript come il sottoscritto, potreste dover aggiungere al file `shim-vue.d.ts` la definizione del modulo per `@vue/web-component-wrapper`:

```
declare module '@vue/web-component-wrapper';
```

Questo per evitare l'errore `Could not find a declaration file for module '@vue/web-component-wrapper'.` che su ide come IntelliJ e simili potrebbe essere apparso, strano non sia presente un d.ts già preinstallato che risolve il problema.

A questo punto nell'`index.html` del nostro progetto (in `public/index.html`) dovremo liberarci del root component predefinito (il div con `id="app"`) e sostituirlo con il nostro componente appena registrato. Il nostro index, quindi, sarà:

{% gist https://gist.github.com/lucabro81/1113639ee40e3d49ea8d3e7d434ca79e %}

### Problema con gli stili <a name="css-problem"></a>

Lanciando ora il comando `yarn serve` vedremo in nostro componente funzionare alla grande, no?

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/xkeey38gwtb42324rmjr.png)

Bhè no...

Cioè sì... ma in verità non proprio... dove diavolo sono finiti gli stili??

Il guaio è che Vue ha incluso gli stili nel tag `<head>` della pagina come farebbe normalmente, ma il nostro componente è rinchiuso dentro ad uno **shadow dom** (https://w3c.github.io/webcomponents/spec/shadow/), una sorta di orizzonte degli eventi attraverso cui è difficile (non impossibile, qualcosa passa tutto sommato) far passare informazione.

Con Storybook invece? bhe le cose non migliorano di molto, anzi il problema si ripropone. Modificando il nostro `index.stories.js`

{% gist https://gist.github.com/lucabro81/7bfddff6e9548071648d50cf0034a2e4 %}

Registrando il componente prima di utilizzarlo (storybook attualmente sembra non utilizzare quanto definito nel `main.ts`), è possibile renderizzarlo, ma non sono applicati gli stili:

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/8hctdcrn5gui2nxmchr4.png)

---

# Ipotesi sulla soluzione <a name="hypothesis"></a>

Una possibile soluzione è descritta [qui] (https://github.com/vuejs/vue-web-component-wrapper/issues/12#issuecomment-385141573), a quanto pare l'opzione `shodowMode` di [vue-loader] (https://vue-loader.vuejs.org/) è settata a `false` di [default] (https://github.com/vuejs/vue-loader/blob/bcee5b9127800070fe5797fe4be466641b88acc8/docs/options.md#shadowmode), da qui il cursioso comportamente riscontrato. A questo punto mettere a `true` quella proprietà dovrebbe risolvere il problema.

### vue_config.js <a name="vue-config"></a>

Tutto ciò di cui abbiamo bisogno ora è il file [vue.config.js](https://cli.vuejs.org/config/#vue-config-js) nella root del nostro progetto; se ancora non esiste, creiamolo.

Per sapere con cosa riempire il nostro file è necessario ispezionare la configurazione webpack del nostro progetto con il comando:

```
vue inspect
```

Il risultato assomiglierà a questo:

{% gist https://gist.github.com/lucabro81/dc2cf337b78afb2dd539a23381832ec6 %}

Se guardiamo attentamente questo output, possiamo notare alcuni commenti interessanti, per esempio:

```
/* config.module.rule('css').oneOf('vue').use('vue-style-loader') */
```

che illustrano l'api necessaria a generare quel determinato pezzetto di configurazione, questa api, infatti, è parte di `webpack-chain` (https://github.com/neutrinojs/webpack-chain) tool utilizzato per facilitare la stesura di file di configurazione per webpack. Visto che è già installato nel nostro progetto, lo possiamo usare a nostro favore.

Ovviamente le parti della configurazione che interessano a noi sono quelle in cui appare la proprietà `shadowmode: false`, qui sotto l'estratto delle parti interessate:

{% gist https://gist.github.com/lucabro81/126ddadada54a9e44cc84ffdbf5712c6 %}

Ora, ciò che mettiamo nel `vue_config.js` verrà intercettato da webpack e integrato nel processo di compilazione, e alla fine dovrebbe essere una cosa di questo genere:

{% gist https://gist.github.com/lucabro81/e5a2c9f6b252350828ff2adffe47ec6f %}

questo script aggiunge `shadowMode=false`ovunque sia necessario e permette a webpack di procedere con la compilazione, finalmente quello che si avrà sarà un web component correttamente renderizzato che incapsula tutti i suoi stili:

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/upz6otlx1t19p159axo1.png)

### Includere il web component nella story (<a name="web-component-in-story"></a>)

Se lanciamo storybook ora, vedremo che anche lì il nostro componente sarà corretamente renderizzato, tuttavia l'api di storybook in questo caso non ci aiuta: come facciamo a passare dati al nostro componente in modo efficiente? Se questi dati sono oggetti complessi? Come si può interfacciare il nostro web component con l'api esposta dall'addon knobs?

Ok andiamo con ordine:

### Registrare il componente <a name="register-component"></a>

Questa è facile, ogni componente deve essere registrato come abbbiamo detto, un a possibilità è implementare una funzione che controlli se già il componetne non sia stato registrato e in caso contrario proceda di conseguenza, qualcosa del genere:

{% gist https://gist.github.com/lucabro81/5aead907eabc6f0483356afc9b51523a %}

Davvero molto semplice, gli elementi non registrati hanno come constructor `HTMLElement()`, è sufficente fare un check e il gioco è fatto.

Successivamente, il componente va registrato:

{% gist https://gist.github.com/lucabro81/0703f02d93ebc8b7ad788739c0f617d3 %}

anche qui nulla di nuovo, la procedura è quella vista poco sopra, soltanto chiusa dentro una funzione.

### Integrare l'interfaccia delle stories <a name="integrate-interface"></a>

Ora dobbiamo fare in modo di poter usare l'`addon-knobs` per poter passare dati al nostro componente e renderli reattivi ai cambiamenti che possiamo fare durante i test, la mia soluzione è stata costruire una funzione che restituisse un componente e succesivamente ne recuperasse il riferimento per potergli passare eventuali dati:

{% gist https://gist.github.com/lucabro81/110dd7dd6afaa7f7bceb3949475b54cd %}

Cerchiamo di capire cosa questo script effettivamente fa:

```
export const webComponentWrapper = ({props, template}) => {
...
```

In ingresso ci si aspetta un oggetto, per esempio:

```
props: {
  test: [
    ['test', true, 'GROUP-ID1'],
    boolean
  ],
},
template: '<test-component></test-component>'
```

formato dalla proprietà `props` che sarà un altro oggetto, i suoi elementi avranno come chiave il nome della proprietà del nostro componente e per valore un array dove il primo elemento sarà un ulteriore array formato da 
  * nome proprietà (sì c'è un po' di ridondanza di cui è possibile liberarsi), 
  * valore che si dovrà considerare 
  * e l'etichetta che vogliamo dare al gruppo di dati di quello specifico knob. 

Il secondo valore, invece, la funzione dell'addon knobs che verrà utilizzata per trattare quello specifico tipo di dato (in questo caso `boolean`). 
`template` invece è una stringa che rappresenta il nostro componente e quello che contiene.

---

```
...
const id = generateRandomNumber(0, 10 ** 16);
...
```

Qui generiamo un id casuale che verrà utilizzato poi per applicarlo al componente e recuperarne il riferimento, io ho creato una funzione apposta, ma in effetti può essere benissimo un timestamp qualunque.

---

```
...
for (const key in props) {
  if (Object.hasOwnProperty.call(props, key)) {

    const old = key + 'Old' + id;
    const value = key + 'Value' + id;

    props[old] = null;
    props[value] = () => (props[old] !== null) ? props[old] : props[key][0][1];
  }
}
...
```

Ora cominciamo a lavorare sui dati da passare al componente: prima di tutto prendiamo la proprietà `props` e ne scorriamo il contenuto, per ogni elemento preso in considerazione, lo arricchiamo di altre due proprietà (le variabili `old` e `value`), alla prima diamo `null` alla seconda una funzione che ritornerà il vecchio valore (`old`) o quello di 'default' passato assieme alle proprietà (per capirci, il valore `true` nel `['test', true, 'GROUP-ID1']` di cui abbiamo parlato più su) a seconda che il vecchio valore esista o meno. 

Ogni volta che in Storybook selezioniamo un certo componente questo viene reinizializzato, con questo sistema riusciamo a passare sempre l'ultimo valore usato nei knobs, diversamente ritornando su un componente perderemmo le modifiche fatte durante i nostri test e vedremmo sempre il primo valore passato.

---

```
return () => {
  setTimeout(() => {

    const root = document.getElementById(id.toString());
    const old = 'Old' + id;
    const value = 'Value' + id;

    for (const key in props) {

      if (Object.prototype.hasOwnProperty.call(props, key) && !key.includes(old) && !key.includes(value)) {

        const knobsParams = props[key][0];
        const knobsFunction = props[key][1];
        const tagElem = props[key][2];

        knobsParams[1] = props[key + value]();
        props[key + old] = props[key][1](...knobsParams);

        if (tagElem) {
          const elems = root.getElementsByTagName(tagElem)
          elems.forEach((item) => {
            item[key] = props[key + old];
          })
        }
        else {
          root[key] = props[key + old];
        }
      }
    }

  });

  return newTemplate;
}
```

la funzione ritornata è quella che verrà eseguita da Storybook ogni volta che si selezionerà quel determinato componente. 

Prima che questa ritorni il template (nulla più che una stringa del tipo `<my-web-component></my-web-component>`), si esegue un timeout privo dei milliscondi di durata, questo permette all'handler di rientare nella coda del loop event appena sarà possibile (più informazioni qui), in questo caso appena il template diventa un elemento della pagina.

Viene recuperato il riferimento del componente tramite l'id calcolato prima, dopo di che vengono recuperati i dati dall'oggetto passato alla funzione e passati al componente. Come detto prima, il dato viene salvato nella proprietà aggiunta prima (qui `props[key + old] = props[key][1](...knobsParams);`).

---

# Conclusioni e credits <a name="credits"></a>

E questo è quanto, mettendo tutto assieme, si riesce ad avere un progetto Vue per testare web components (e non solo normali classi Vue) con Storybook e il dev server incluso. [Qui](https://github.com/lucabro81/vue-webcomponent-storybook-test) trovate un repository con un progetto di test completo e funzionante.

Fonti:
* [Get started with Vue web components]
(https://medium.com/@royprins/get-started-with-vue-web-components-593b3d5b3200)
* [Create & Publish Web Components With Vue CLI 3]
(https://vuejsdevelopers.com/2018/05/21/vue-js-web-component/)
* [How to define style within a web component?]
(https://github.com/vuejs/vue-web-component-wrapper/issues/12#issuecomment-385141573)
* [What is Vue Loader?]
(https://vue-loader.vuejs.org/#what-is-vue-loader)
* [Configuration Reference]
(https://cli.vuejs.org/config/#global-cli-config)
* [How to get list of registered custom elements]
(https://stackoverflow.com/questions/27334365/how-to-get-list-of-registered-custom-elements/28210364#28210364)