2p - Componenti ed eventi dinamici in Vue

Chiara Passaro
12 min readNov 26, 2022

--

Gestiamo l’ordine e la dimensione dei nostri componenti nel Form tramite Drag&Drop

Nel precedente articolo Componenti ed eventi dinamici in Vue
abbiamo visto insieme come generare un form dinamico tramite un file di configurazione.

Possiamo ora concentrarci sulla parte sinistra del nostro layout, ovvero una interfaccia che ci consenta di trascinare gli elementi del form per modificarne l’ordine e il numero di colonne occupate.

Nello specifico vedremo:

  1. Come copiare i dati presenti in un file di configurazione eliminando la referenza all’object originario.
    - Shallow Copy con destructuring e spread operator
    - Deep Copy con una Funzione Ricorsiva
  2. Come usare dataTransferper modificare l’ordine degli elementi in un sistema drag&drop

1. Creiamo una copia di un Object/Array

Quando lavoriamo con gli Object e con gli Array dobbiamo ricordare che questi due tipi di dati non vengono passati come valore ma come referenza.
E beh adesso sì che è tutto più chiaro! 🤭

Facciamo un passo indietro.
In JS abbiamo alcuni tipi di dati che sono detti primitivi:
- string
- number
- bigint
- boolean
- symbol
- null
- undefined

Questi dati sono immutable e non possono essere mutati.
E voi mi direte: 🙄 ma come non posso modificare un numero? E le somme?

Non dobbiamo confondere il tipo di dato con la variabile che contiene quel tipo di dato.

Facciamo un esempio:
5 è un numero, pertanto è un primitivo ed è immutabile. Ovvero ha delle caratteristiche intrinseche che non potranno essere modificare.
5 è un numero e non potrà diventare mai una 🍎
Per usare il 5 solitamente assegnerò questo valore ad una variabile e questa variabile, se è una let, potrà essere riassegnata e quindi modificata nel tempo.

Quindi il concetto di immutabilità non ha a che vedere con il tipo di variabile che usiamo e con la riassegnazione del valore.

Javascript non ci consente di cambiare la natura dei dati primitivi, ma wrappa questi tipi di dati in object con dei metodi che ci aiutano ad usarli, infatti il 5, in quanto numero, avrà a disposizione il metodo toString che ci consentirà di creare una stringa dal suo valore.

Gli Object e gli Array non sono dei primitivi e sono mutable, possono essere mutati anche senza creare una nuova variabile.
Questo può essere utile la maggior parte del tempo, ma a volte può essere un ostacolo se ce ne dimentichiamo.

Facciamo un esempio:

const golden = {
'type': 'Golden',
'diameter': 20,
'weight': 150
};

const red = {
'type': 'Red',
'diameter': 25,
'weight': 200
};

const apples = [golden, red];

console.log(apples);
0: {type: ‘Golden’, diameter: 20, weight: 150}
1: {type: ‘Red’, diameter: 25, weight: 200}

Apportiamo qualche modifica al primo elemento del nostro array

apples[0].name = 'Goldie';

delete(golden.type);

console.log(apples);
0: {diameter: 20, weight: 150, name: ‘Goldie’}
1: {type: ‘Red’, diameter: 25, weight: 200}

Ristampiamo il nostro object Golden

{diameter: 20, weight: 150, name: ‘Goldie’}

Ooops! 😧

Modificando l’object nell’array abbiamo modificato anche l’object di partenza!

Lo stesso comportamento si ha con gli Array.

Torniamo quindi al nostro layout, il nostro scopo è usare una fonte dati unica e usarla per popolare due store diversi.
Ricordiamo che vogliamo modificare la Preview Form solo al click su Save Layout .

Creiamo un file configComponents

export const components = [
{
name: "Label",
gridColumn: "1/3",
order: 0,
data: {
id: "user-config",
value: "User Configuration",
},
error: false,
},
{
name: "InputName",
gridColumn: "1/3",
order: 1,
data: {
id: "name",
name: "name",
type: "text",
placeholder: "Name",
ariaLabelledBy: "user-data",
value: "",
required: true,
},
error: false,
},
{
name: "InputEmail",
gridColumn: "1/2",
order: 2,
data: {
id: "email",
name: "email",
type: "email",
placeholder: "email",
ariaLabelledBy: "user-data",
value: "",
required: true,
},
error: false,
},
{
name: "InputEmailRepeat",
gridColumn: "2/2",
order: 3,
data: {
id: "repeatEmail",
name: "repeat-email",
type: "email",
placeholder: "Repeat Email",
ariaLabelledBy: "user-data",
value: "",
required: true,
},
error: false,
},
{
name: "InputPassword",
gridColumn: "1/2",
order: 4,
data: {
id: "password",
name: "password",
type: "password",
placeholder: "password",
ariaLabelledBy: "user-data",
value: "",
required: true,
},
error: false,
},
{
name: "InputPasswordRepeat",
gridColumn: "2/2",
order: 5,
data: {
id: "repeatPassword",
name: "repeat-password",
type: "password",
placeholder: "Repeat Password",
ariaLabelledBy: "user-data",
value: "",
required: true,
},
error: false,
},
{
name: "Textarea",
gridColumn: "1/3",
order: 6,
data: {
id: "bio",
name: "bio",
placeholder: "Name",
ariaLabelledBy: "user-data",
value: "",
},
error: false,
},
{
name: "Button",
gridColumn: "1/1",
order: 7,
data: {
id: "submit",
value: "invia",
disabled: true,
},
error: false,
},
];

Creiamo un secondo store duplicando il primo e cancelliamo l’array component sostituendolo con quello importato dalla nostra config.

import { defineStore } from "pinia";
import { components } from "@/config/configComponents";

export const useComponents = defineStore("components", {
state: () => {
return {
components: components,
};
},
[...]
}
import { defineStore } from "pinia";
import { components } from "@/config/configComponents";

export const useComponentsLayout = defineStore("componentsLayout", {
state: () => {
return {
components: components,
};
},
[...]
}

Questo è l’effetto che vogliamo ottenere nel lato sinistro

Ma in questo momento abbiamo due store che puntano allo stesso object, questo cosa significa?

Come avevate supposto abbiamo dei cambiamenti anche nel secondo componente e non è quello che desideravamo.
Nasce quindi la domanda:

Come faccio a copiare il file config eliminando la referenza?

Esistono vari modi per copiare Array ed Object ma la prima cosa che dobbiamo decidere è se abbiamo bisogno di fare una copia superficiale o profonda (shallow/deep)

Torniamo ai nostri dati, abbiamo un Array di Object, e ogni Object contiene al suo interno un altro Object.

 [
{
name: "Label",
gridColumn: "1/3",
order: 0,
data: {
id: "user-config",
value: "User Configuration",
},
error: false,
}
]

Proviamo a fare una Shallow Copy

const component = 
{
name: "Label",
gridColumn: "1/3",
order: 0,
data: {
id: "user-config",
value: "User Configuration",
},
error: false,
};

//we use spread operator
const copyComponent = {... component}
console.log({component, copyComponent})
component: data: id: “user-config” value: “User Configuration” error: false gridColumn: “1/3” name: “Label” order: 0 copyComponent: data: id: “user-config” value: “User Configuration” error: false gridolumn: “1/3” name: “Label” order: 0

Cambiamo una prop nel primo livello del componente originario

//change a property in the first level
component.name = 'Input'
console.log({component, copyComponent})
Now the property name is “Input” in the original and “Label” in the copy

E poi proviamo con una prop di un object innestato

//change a property in a nested object
component.data.id = 'user-name'
console.log({component, copyComponent})
Now the data.id is also changed in the copy

😱 Oh no! La prop data.id si è modificata in entrambi gli object!

Pare evidente che nel nostro caso una Shallow Copy non sia sufficiente, dunque se Non è zuppa… è una Deep Copy!

Ci sono diversi modi per fare una Deep Copy, uno di questi è usare JSON.stringify, ma può non essere sempre una buona idea, in quanto questo metodo non funziona con alcuni tipi di dati, tra i quali le functione undefined.

Facciamo un altro esempio

const component =
{
name: "Button",
gridColumn: "1/1",
order: 7,
data: {
id: "submit",
value: "invia",
disabled: true,
},
error: false,
submitData() {
console.log('Submit');
}
};

const stringified = JSON.parse(JSON.stringify(component))
stringified.submitData()

console.log(stringified);
Uncaught TypeError: stringified.submitData is not a function at <anonymous>:18:13
data: {id: ‘submit’, value: ‘invia’, disabled: true} error: false gridColumn: “1/1” name: “Button” order: 7

😳 La nostra funzione è sparita!

Nel nostro caso non avremo metodi associati abbiamo solo due livelli quindi potremmo usare una

Shallow copy con destructuring e spread operator

const componentsData = components.map((component) => {
//destructuring the nested object
const { data, ...componentConfigLayout } = component;


return {
data: { ...data }, //data is now a copy
...componentConfigLayout, //the other props
};
});

Cicliamo con un map su tutti i components e per ognuno di essi estrapoliamo l’object contenuto con una destrutturazione, infine ricostruiamo l’object con l’utilizzo dello spread operator.

Il problema ovviamente però non è risolto se il numero dei livelli non è predefinito.

In tal caso abbiamo la necessità di capire se ogni volta che prendiamo una property abbiamo a che fare con un Array, con un Object, o un dato primitivo.

Locandina Predestination

Funzioni ricorsive

Avete presente quei film che si basano su paradossi temporali? 🤪
Le funzioni ricorsive sono identiche! 😆

Innanzitutto visualizziamo quanto vogliamo fare tramite un diagramma

La nostra funzione deepCopy() dovrà accettare come argomento un dato che può essere una collection, ovvero un Array o un Object, oppure un altro tipo di dato singolo.

Poiché in JS anche un Array, o Null sono Object, dovremo stare attenti ad intercettare ciò che ci interessa veramente.

Il nostro scopo quindi è prendere il nostro data e capire di che tipo sia, se è un dato singolo possiamo restituirlo così come è, se è un array o un object dobbiamo “spacchettarlo” e controllare elemento per elemento di che tipo di dato si tratta e farne una copia inserendolo in un accumulatore.
Questo accumulatore dovrà essere della stessa natura dell’elemento originario, quindi potrà essere un array o un object.

Una volta terminato lo “spacchettamento” possiamo ritornare al livello nel quale eravamo quando abbiamo incontrato un array/object e ripartiamo con il check e così via, finché non terminiamo gli elementi del data passato alla funzione.
Infine restituiamo la nuova copia.

Passiamo al codice

export const deepCopy = (element) => {
//There are some particular cases:
//if we have a non Object type
//if we have a null that is a particular Object
if (typeof element !== "object" || element === null) return element;

//if we have Date Set or Map, that are objects, we create new istance
if (element instanceof Date) return new Date(element);
if (element instanceof Set) return new Set(element);
if (element instanceof Map) return new Map(element);

//If we arrive here we create an empty array or other type of object
const accumulator = Array.isArray(element) ? [] : {};

//and then we cycle on all elements/props inside array/object

let key;
for (key in element) {
//We create a new element in the object/array and set the value
//this value must be checked before the assignment so we call again our function deepCopy
accumulator[key] = deepCopy(element[key]); //🫣 Boom! This is the recursion!
}

//Finally we return our accumulator that contain all the levels
return accumulator;
};

Aggiungiamo ora un po’ di console.log per capirne meglio il funzionamento.

Andiamo a stampare adesso il nostro array originario e quello ottenuto.

I nostri array sono identici 😁 ma il secondo adesso è davvero una copia.

2. Usiamo DataTransfer

Passiamo ora al layout del nostro nuovo componente `SetLayout`
Aggiungiamo per ogni componente dei div che serviranno per effettuare il drop e un wrapper che servirà per avviare il drag.
Inoltre aggiungiamo un menu nascosto che servirà per gestire la dimensione di una o due colonne.

Innanzitutto aggiorniamo il file ConfigLayout aggiungendo gridConfig e gridMap che ci serviranno per visualizzare le regole css corrette.

//config css rules for layout grid
export const gridConfig = {
gridColumns: "1fr 1fr",
gridRows: "auto",
};

//map rules with label
export const gridMap = {
"1/2": "1 col left",
"2/2": "1 col right",
"1/3": "2 col",
};

Le importeremo nello script, ma ora guardiamo il layout.

<template>
<div class="container">
<h2>Set a new layout</h2>
<div class="layout" v-if="components.length">
<!-- we have a div for each component with grid-column prop coming from the config file -->
<div
v-for="(component, index) in components"
class="column"
:key="`${index}-${component.name}`"
:style="`grid-column: ${component.gridColumn}`"
>
<!-- Drop div for the drop event -->
<div
v-if="index === 0"
class="drop"
@dragover.prevent
@dragenter.prevent
@drop="changeOrder({ evt: $event, order: component.order })"
></div>
<!-- Drag wrapper to trigger the drag event -->
<div
class="drag"
:class="{ 'z-index-99': menu === index }"
draggable="true"
@dragstart="setIndex({ evt: $event, index: index })"
>
<!-- hidden menu to change prop grid-column -->
<div class="menu-wrapper">
<span class="caret" @click="openMenu(index)"
><font-awesome-icon icon="fa-solid fa-caret-down"
/></span>
<div class="menu" v-if="menu === index">
<h3>Actual Grid Column:</h3>
<p>
{{ gridMap[component.gridColumn] }}
</p>
<h3>Set new position:</h3>
<div
class="menu-buttons"
v-for="(column, key) in gridMap"
:key="`${column}-${key}`"
>
<button
v-if="component.gridColumn != key"
@click.prevent="setColumn(component, key)"
>
Set {{ column }}
</button>
</div>
</div>
</div>
<!-- a wrapper for the component -->
<div class="component-wrapper">
<!-- this layer is used to enables drag -->
<div class="component-drag-layer"></div>
<!-- the component is disabled and used only for layout purpose -->
<component
class="component"
:is="componentsMap[component.name].component"
:value="component.name"
disabled
></component>
</div>
</div>
<!-- Drop div for the drop event -->
<div
class="drop"
@dragover.prevent
@dragenter.prevent
@drop="changeOrder({ evt: $event, order: component.order + 1 })"
></div>
</div>
</div>

<button @click.prevent="saveConfig">Save Layout</button>
</div>
</template>

Qui la parte più importante è il css.

//this style is applied when a menu is open, this is important because we have many items absolutely positioned

.z-index-99 {
z-index: 99;
}

.container {
padding: 2em 1em;
border: 1px solid grey;

.layout {
display: grid;
column-gap: 1em;
grid-template-columns: v-bind("gridConfig.gridColumns");
grid-template-rows: v-bind("gridConfig.gridRows");

.column {
//drag and drop are highlighted
.drag {
position: relative;
padding: 0.5em;
border: 1px solid grey;
}
.drop {
width: 100%;
height: 1em;
background-color: lightcyan;
}
//this is our menu that contains the hidden menu and the caret
.menu-wrapper {
position: absolute;
z-index: 10;
top: 0;
left: 0;
display: flex;
justify-content: end;
width: 100%;
padding: 0.5em;

.caret {
display: flex;
width: 0.5em;
height: 0.5em;
justify-content: center;
align-items: center;
font-size: 1.5em;
cursor: pointer;
&:hover {
color: grey;
}
}
.menu {
position: absolute;
top: 1.5em;
right: 0;
width: 80%;
padding: 1em;
border: 1px solid grey;
border-radius: 0.2em;
background-color: white;
&-buttons button {
width: 100%;
cursor: pointer;
}
}
}
.component-wrapper {
width: 100%;
//this is useful to overlay the component and allow to click
.component-drag-layer {
position: absolute;
z-index: 1;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.component {
z-index: 0;
width: 100%;
height: auto;
}
}
}
}
}

Passiamo dunque allo script.
Importiamo entrambi gli store e prendiamo i componenti dallo storeComponentsLayout.

import { computed, ref } from "vue";

import { gridConfig, gridMap, componentsMap } from "@/config/configLayout.ts";

import { useComponentsLayout } from "@/stores/componentsLayout.ts";
import { useComponents } from "@/stores/components.ts";

const storeComponentsLayout = useComponentsLayout();
const storeComponents = useComponents();

//get all components from store
const components = computed(() => storeComponentsLayout.getItems());

Passiamo poi alle funzioni che ci consentiranno di gestire l’ordine dei componenti tramite Drag&Drop.

Il funzionamento è il seguente.
Quando clicchiamo e trasciniamo su uno dei componenti copiamo l’indice di quell’elemento all’interno dell’object dataTransfer.
Al rilascio su uno dei div predisposti ad intercettare il drop, usiamo quell’indice per cercare il componente e cambiarne l’ordine, al contempo però cambiamo l’ordine di tutti gli elementi che seguono.

Il nostro getItems infatti ordina gli elementi in base alla prop order.

getItems() {
return this.components.sort((a, b) => a.order - b.order);
}

Questa soluzione è praticabile perché abbiamo pochi elementi e quindi possiamo ciclare senza appesantire troppo la nostra applicazione.

//setIndex is called with drag evt and uses dataTransfer
const setIndex = ({ evt, index }) => {
evt.dataTransfer.dropEffect = "move";
evt.dataTransfer.effectAllowed = "move";
evt.dataTransfer.setData("index", index);
};

//changeOrder uses stored dataTransfer
const changeOrder = ({ evt, order }) => {
const indexData = parseInt(evt.dataTransfer.getData("index"));
//We map the store and change order of the selected element and all the next elements.
//we can cycle on all elements because are few
//we use a foreach because we can modify directly the props of each element
storeComponentsLayout.components.foreach((element, idx) => {
if (idx === indexData) {
element.order = order;
} else if (element.order >= order) {
element.order++;
}
});
};

Il comportamento del menù è molto semplice.
Cicliamo sulla nostra config gridMap e usiamo il value per visualizzare il testo nel menù.
Ad ogni click passiamo il `component` che ha chiamato l’evento e la value alla nostra funzione setColumn

export const gridMap = {
"1/2": "1 col left",
"2/2": "1 col right",
"1/3": "2 col",
};
<div
class="menu-buttons"
v-for="(column, key) in gridMap"
:key="`${column}-${key}`"
>
<button
v-if="component.gridColumn != key"
@click.prevent="setColumn(component, key)"
>
Set {{ column }}
</button>
</div>

La setColumn modifica la prop gridColum e chiude il menù.

//menu behavior
const menu = ref(-1);
const openMenu = (index) => (menu.value = menu.value === index ? -1 : index);
const setColumn = (component, key) => {
component.gridColumn = key;
openMenu(-1);
};

Passiamo quindi al salvataggio della configurazione, qui è necessario copiare le impostazioni dello storeComponentLayout in quelle dello storeComponent, poiché dobbiamo copiare solo le prop order e gridColumn possiamo usare la destrutturazione.

//we save the configuration in the storeComponents
//we have to copy storeComponentsLayout order and gridColumn for each components
const saveConfig = () => {
storeComponents.components = storeComponents.components.map(
({ data, error, name }) => {

const { order, gridColumn } = storeComponentsLayout.findByName(name);

return {
name,
data,
error,
order,
gridColumn,
};
}
);
};

Conclusioni 😎

In questi due articoli abbiamo visto come sfruttare gli array e gli object per creare delle configurazioni riutilizzabili.

Nello specifico abbiamo visto
- vue dynamyc component e arguments
- come fare una Shallow e Deep Copy di un array/object
- come usare dataTransfer

Alla prossima! 👋🏼👋🏼👋🏼

--

--