
Nei giorni scorsi, sono stato a Roma da un cliente che sta facendo il porting di un’applicazione vb6 ad Angular. Durante la code review, mi ha chiesto di aggiungere una dashboard con widgets ridimensionabili, che possano essere posizionate sulla pagina con il classico approccio drag&drop.
Dopo qualche ricerca su Google, abbiamo scelto di partire da gridstack.js (http://gridstackjs.com/), che è una libreria JavaScript basata su jQuery e la feature di drag & drop di jQuery-UI. L’utilizzo di jQuery è oggi quasi anacronistico, ma è ancora molto utilizzato e alcuni plugin sono davvero interessanti. Questa è quindi una buona occasione per analizzare insieme alcune feature avanzate dei componenti di Angular e come integrare i plugin jQuery.
Come primo passo, creiamo un nuovo progetto con l’Angular-CLI, entriamo nella cartella del progetto, installiamo le dipendenze di gridstack, generiamo due nuovi componenti chiamati dashboard e widget, e infine apriamo Visual Studio Code sulla cartella:
- ng new dashboard;
- cd dashboard;
- npm install -save jquery jqueryui lodash gridstack;
- ng g component dashboard;
- ng g component widget;
- code.
Se preferite, potete linkare jquery, jquery-ui, lodash, e gridstack da una CDN, sulla base dei vostri requisiti, ma, se avete scaricato queste librerie, dovete aggiungerne i percorsi al file angular-cli.json:

Come descritto nella documentazione, possiamo realizzare una semplice dashboard creando un contenitore con la classe grid-stack, che racchiuda i contenitori delle widget identificati dalla classe ‘grid-stack-item’ e alcuni attributi data-* personalizzati per la posizione (data-gs-x e data-gs-y), la larghezza (data-gs-width) e l’altezza (data-gs-height). Quindi il nostro dashboard.component.html sarà come segue:

Il contenuto della widget è identificato dalla classe ‘grid-stack-item-content’, che può essere utilizzata per stilizzare l’aspetto della widget nel file dashboard.component.css:

Gridstack è un plugin jQuery, quindi abbiamo bisogno di usare jQuery nel nostro codice per inizializzare il plugin. Il problema con jQuery è che dovete fare assunzioni sulla struttura HTML per selezione l’elemento del DOM appropriato, nel nostro caso abbiamo bisogno di aggiungere la seguente riga di codice:
$(‘.grid-stack’).gridstack();
In Angular, la separazione tra la logica del componente (il file .ts) e la struttura HTML (il file .html) è molto importante, e di solito incapsuliamo le chiamate che hanno bisogno di conoscere il DOM in direttive Angular, anche perché abbiamo bisogno del DOM pronto quando eseguiamo una chiamata jQuery. Ma, in questo caso, il componente esegue solo questa chiamata, perché tutto il lavoro viene fatto dal plugin. Quindi, questo è forse l’unico caso in cui possiamo fare assunzioni sulla struttura HTML, e sporcare la logica del component con la chiamata jQuery.
Se proprio non ci piace possiamo usare il decoratore @ViewChild su una proprietà di tipo ElementRef e mettere un #gridStackContainer sul div in questione:

A questo punto, la domanda è: quando? Quando il DOM è pronto per la chiamata jQuery dal componente? Per questo scopo, i componenti Angular hanno alcuni hooks che vengono chiamati in varie fasi del ciclo di vita del componente. Maggiori informazioni su questo argomento, potete trovarle nella documentazione ufficiale (https://angular.io/guide/lifecycle-hooks). Nel nostro caso, abbiamo bisogno dell’hook AfterViewInit, che viene chiamata da Angular dopo l’inizializzazione della view del componente (e anche delle eventuali view figlie):

Ovviamente, Typescript non conosce il simbolo $ di jQuery, quindi dobbiamo dichiarare una costante per passare la traspilazione Typescript senza errori, come potete vedere dopo l’istruzione import:
declare const $: any;
Per finire questo primo step, dobbiamo cambiare app.component.html come segue:

Eseguendo l’applicazione con il comando ng serve -o, possiamo vedere il risultato:

Ok, è il momento di astrarre i nostri componenti per renderli riutilizzabili. Cominciamo spostando la struttura del widget nel componente widget, esponendone le proprietà con Input del componente. Il widget.component.ts sarà come segue:

Il widget.component.html dovrebbe essere come segue:

Ma, come ci dice il compilatore, non possiamo bindare attributi sconosciuti alle nostre proprietà, e i data-* sono troppo generici per essere conosciuti da angular. Possiamo risolvere il problema utilizzando il binding [attr.]:

Notate l’elemento ng-content: ci pemette di proiettare del contenuto nel nostro componente:

Quindi, l’elemento span contenente Widget 1 sarà proiettato nel componente widget, esattamente alla posizione ng-content. Una feature davvero interessante, possiamo usarla anche in dashboard.component.html:

Quindi, il nostro app.component.html diventerà come segue:

Ma, eseguendo l’applicazione, il risultato non è come ci aspetteremmo:

Perché? Se guardiamo la struttura del DOM attuale possiamo chiaramente vedere il problema:

Tra la dashboard e la widget, Angular posiziona un elemento con il nome del selettore del componente e questo crea problemi con molti plugin jQuery, tra cui gridstack, perché cerca il figlio diretto con la classe ‘grid-stack-item’ e gli attributi data-*, ma invece trova il nostro. Come possiamo risolvere il problema? La nostra widget non deve essere necessariamente un div, quindi possiamo spostare la classe grid-stack-item e gli attributi data-* sull’elemento usando la proprietà host del componente o, meglio, il decoratore @HostBinding():

Grazie al decoratore HostBinding possiamo aggiungere l’elemento bindato sul selettore del nostro componente, risolvendo il nostro problema:

Perfetto, ma facciamo un ulteriore step. Che succede se la definizione delle widget viene da un servizio? Come cambia la nostra implementazione? Apparentemente sembra una domanda semplice, perché dobbiamo solo simulare una chiamata a un servizio e ciclare sui risultati. Ok, proviamo a farlo. Aggiungiamo due file al componente dashboard, un dashboard.model.ts per creare un tipo Widget:

E un dashboard.service.ts, per simulare la chiamata a un servizio creando e restituendo un Subject e usando un setTimeout per ritardare di 1 secondo l’arrivo dei dati:

Iniettando il servizio nel nostro dashboard.component.ts, possiamo chiamare il servizio e salvare il risultato in un array specifico come segue:

Dashboard.component.html cambia sulla base della nuova sorgente delle widgets come segue:

Eseguendo l’applicazione, comunque, ci rendiamo conto che qualcosa non funziona e la console non ci da errori.

Il problema è che quando chiamiamo $(this.griStackContainer.nativeElement).gridstack(); la dashboard non contiene ancora le widget (abbiamo ritardato l’arrivo dei dati di 1 secondo). E anche se spostiamo la chiamata nella subscribe(), non siamo ancora pronti perché il rendering degli args associati all’array richiede un tempo. Per risolvere il problema possiamo usare un altro hook del componente, chiamato AfterViewChecked, che viene chiamato dopo ogni check di Angular sulle view del componente e dei suoi figli:

Perfetto! No? Un sospetto deve sempre affiorare alla vostra mente quando lavorate con i plugin di jQuery: che succede se i dati cambiano dopo la prima volta e il vostro codice jQuery viene chiamato di nuovo? Tipicamente smette di funzionare bene, e succede anche nel nostro caso… Se aggiungiamo un secondo setTimeout al nostro service, come segue:

Dopo 5 secondi, la nostra applicazione mostra correttamente le widgets, ma queste non sono nè draggabili nè ridimensionabili…

Se avete un po’ di esperienza con jQuery, sapete che la soluzione è richiamare l’istruzione del plugin jQuery, ma prima abbiamo bisogno di distruggere la griglia precedente per rendere funzionante il nostro codice. In linea con quanto indicato nella documentazione di gridstack, il codice cambia come segue:

Adesso funziona bene! Potete trovate il codice sulla mia pagina GitHub, ho creato una branch per ogni step (step1, step2, e step3) e mergiato lo step3 su master a questo indirizzo:
https://github.com/apomic80/dashboard
Spero vi sia utile, non solo per l’implementazione della dashboard, ma anche nel caso dobbiate integrare altri plugin jQuery.
A presto.