back to top

Applicazione per la gestione di citazioni realizzata con Vue/Vuex

In questa lezione realizzeremo un’applicazione per la raccolta di citazioni e frasi celebri utilizzando Vuex. Per semplicità useremo JSON Server per simulare il salvataggio delle citazioni su un finto server locale. JSON Server fornisce una REST API che permetterà di prelevare e salvare sul server le citazioni aggiunte attraverso un semplice form. Dietro le quinte verrà usato un file db.json come database.

wireframe applicazione vuex

Dopo aver installato globalmente JSON Server (npm i -g json-server), spostiamoci in una nuova cartella e lanciamo il comando vue create vuex-example. Selezioniamo attraverso i tasti freccia Manually select features e premiamo il tasto INVIO.

Seguiamo la medesima procedura dell’esempio visto nella precedente lezione, scegliendo le stesse funzionalità aggiuntive, ovvero Babel, Vuex e Linter/Formatter.

A questo punto possiamo spostarci all’interno della cartella base del nostro progetto per aggiungere un file db.json che sarà utilizzato come database da JSON server.

{
  "quotes": [
    {
      "id": "1kb6ktl63",
      "quoteText": "Sometimes it is the people no one can imagine anything of who do the things no one can imagine.",
      "quoteAuthor": "Alan Turing",
      "lang": "en"
    },
    {
      "id": "1tb4ktl23",
      "quoteText": "Well done is better than well said.",
      "quoteAuthor": "Benjamin Franklin",
      "lang": "en"
    },
    {
      "id": "1kb6l41ib",
      "quoteText": "Bite off more than you can chew, then chew it.",
      "quoteAuthor": "Ella Williams",
      "lang": "en"
    },
    {
      "id": "1kb6l41m0",
      "quoteText": "Never give up. Today is hard, tomorrow will be worse, but the day after tomorrow will be sunshine.",
      "quoteAuthor": "Jack Ma",
      "lang": "en"
    },
    {
      "id": "1kb6l41mf",
      "quoteText": "Nothing in life is to be feared, it is only to be understood. Now is the time to understand more, so that we may fear less.",
      "quoteAuthor": "Marie Curie",
      "lang": "en"
    },
    {
      "id": "1la4l51me",
      "quoteText": "I have not failed. I've just found 10,000 ways that won't work.",
      "quoteAuthor": "Thomas A. Edison",
      "lang": "en"
    },
    {
      "id": "1kb6l41lz",
      "quoteText": "Don't sit down and wait for the opportunities to come. Get up and make them.",
      "quoteAuthor": "Madam C. J. Walker",
      "lang": "en"
    },
    {
      "id": "1kb6l41m3",
      "quoteText": "Compi ogni azione come se fosse l'ultima della vita.",
      "quoteAuthor": "Marco Aurelio",
      "lang": "it"
    },
    {
      "id": "1kb6l41m4",
      "quoteText": "È impossibile per un uomo imparare ciò che crede di sapere già.",
      "quoteAuthor": "Epiteto",
      "lang": "it"
    },
    {
      "id": "1kb6l41m5",
      "quoteText": "Le persone perfette non combattono, non mentono, non commettono errori e non esistono.",
      "quoteAuthor": "Aristotele",
      "lang": "it"
    }
  ]
}

JSON server si occuperà di mettere a disposizione una REST API completa. Basterà spostarci nella cartella vuex-example in una nuova finestra del terminale e lanciare il comando json-server -p 5555 -d 2000 --watch db.json. In questo modo verrà avviato un server locale sulla porta 555 che potremo interpellare dalla nostra applicazione per scaricare in fase di avvio l’elenco delle citazioni già presenti nel database. Con l’opzione -d 2000 introduciamo un ritardo di due secondi nella risposta del server.

schermata di esecuzione di JSON server

Una volta lanciato JSON server, procediamo alla realizzazione della nostra applicazione.

Prima di analizzare il codice dei vari componenti, riportiamo un breve video in cui illustriamo rapidamente il funzionamento dell’applicazione terminata.

https://vimeo.com/462644347

Per questa applicazione abbiamo suddiviso lo store Vuex in moduli e abbiamo creato 7 componenti che analizzeremo nel resto della lezione. La struttura finale dell’applicazione è la seguente:

tree vuex-example -aF --dirsfirst -I node_modules
vuex-example
├── public/
│   ├── favicon.ico
│   └── index.html
├── src/
│   ├── assets/
│   │   ├── error.svg
│   │   ├── global.css
│   │   ├── logo.svg
│   │   └── ok.svg
│   ├── components/
│   │   ├── LoadingSpinner.vue
│   │   ├── QuoteFilter.vue
│   │   ├── QuoteForm.vue
│   │   ├── QuoteList.vue
│   │   ├── QuoteListItem.vue
│   │   ├── TheFooter.vue
│   │   └── TheHeader.vue
│   ├── services/
│   │   └── QuoteService.js
│   ├── store/
│   │   ├── modules/
│   │   │   ├── currentViewState.js
│   │   │   ├── editingModeState.js
│   │   │   ├── errorState.js
│   │   │   ├── hasTriedToUploadState.js
│   │   │   ├── loadingState.js
│   │   │   ├── quotesState.js
│   │   │   └── uploadingState.js
│   │   └── index.js
│   ├── App.vue
│   └── main.js
├── .browserslistrc
├── .env.development
├── .eslintrc.js
├── .gitignore
├── .prettierrc.js
├── README.md
├── babel.config.js
├── db.json
├── package-lock.json
└── package.json

7 directories, 34 files

Nel file main.js ci limitiamo ad importare global.css che contiene una serie di regole CSS valide per l’intera applicazione.

import Vue from 'vue';
import App from './App.vue';
import store from './store';

Vue.config.productionTip = false;

import '@/assets/global.css';

new Vue({
  store,
  render: (h) => h(App)
}).$mount('#app');

Sempre nel file main.js importiamo lo store esportato dal file store/index.js e lo inseriamo nell’oggetto delle opzioni usato per creare una nuova istanza di Vue. Spostiamoci allora nella cartella store/ ed analizziamo i diversi moduli che compongono lo store Vuex.

import Vue from 'vue';
import Vuex from 'vuex';

import currentViewModule from '@/store/modules/currentViewState';
import editingModeModule from '@/store/modules/editingModeState';
import errorModule from '@/store/modules/errorState';
import hasTriedToUploadModule from '@/store/modules/hasTriedToUploadState';
import loadingModule from '@/store/modules/loadingState';
import quoteModule from '@/store/modules/quoteState';
import uploadingModule from '@/store/modules/uploadingState';

Vue.use(Vuex);

export default new Vuex.Store({
  modules: {
    currentViewModule,
    editingModeModule,
    errorModule,
    hasTriedToUploadModule,
    loadingModule,
    quoteModule,
    uploadingModule
  }
});

Nel file store/index.js importiamo i diversi moduli e li passiamo alla proprietà modules dell’oggetto di configurazione dello store.

Per ciascun modulo abbiamo usato namespaced: true per limitare la visibilità di mutations, actions, state e getters. In questo modo per accedere alle funzionalità definite nel modulo, dovremo sempre scrivere l’intero nome del loro namespace.

// file: store/modules/currentViewState.js
export default {
  namespaced: true,
  state: () => ({
    currentView: 1
  }),
  mutations: {
    SET_CURRENT_VIEW(state, currentView) {
      state.currentView = currentView;
    }
  },
  actions: {
    setCurrentView({ commit }, currentView) {
      commit('SET_CURRENT_VIEW', currentView);
    }
  }
};

In store/modules/currentViewState.js definiamo una sola proprietà per lo state, ovvero currentView. Si tratta di un indice numerico che impieghiamo per decidere quali citazioni devono essere caricate nella lista. In particolare usiamo il valore 0 per le sole citazioni in inglese, 1 per tutte le citazioni e 2 per quelle in italiano.

Definiamo poi un’action e una mutation per settare il valore di state.currentView. Ricordiamo che alle Actions viene passato un primo argomento context che espone varie proprietà fra cui la funzione commit() e state. Alle actions e alle mutations possiamo passare un argomento quando invochiamo rispettivamente le funzioni dispatch() e commit().

Il secondo file che esploriamo è store/modules/editingModeState.

// file: store/modules/editingModeState.js
export default {
  namespaced: true,
  state: () => ({
    editingMode: false
  }),
  mutations: {
    SET_EDITING_MODE(state, editingMode) {
      state.editingMode = editingMode;
    }
  },
  actions: {
    setEditingMode({ commit }, value) {
      commit('SET_EDITING_MODE', value);
    }
  }
};

In questo secondo modulo, lo stato contiene pure una sola proprietà. Si tratta di un valore booleano che utilizzeremo per mostrare o nascondere il form col quale aggiungiamo una nuova citazione al database. Per entrare in modalità ‘editing’, lanceremo quindi l’Action editingModeModule/setEditingMode con l’istruzione this.$store.dispatch('editingModeModule/setEditingMode', true). Vedremo che sarà il componente TheHeader a lanciare tale Action tramite un apposito pulsante.

Il modulo store/modules/errorState mantiene le informazioni in merito ad eventuali errori. Presenta un oggetto errors con due proprietà nelle quali manteniamo eventuali messaggi di errore in fase di upload o download. Attraverso la mutazione ADD_ERROR, assegniamo un messaggio di errore ad una delle due proprietà che poi resettiamo con la mutazione CLEAR_ERROR.

// file: store/modules/errorState.js
export default {
  namespaced: true,
  state: () => ({
    errors: {
      upload: null,
      download: null
    }
  }),
  mutations: {
    ADD_ERROR(state, error) {
      state.errors[error.type] = error.msg;
    },
    CLEAR_ERROR(state, type) {
      state.errors[type] = null;
    }
  }
};

Nel file store/modules/hasTriedToUploadState.js abbiamo invece definito un altro modulo in cui è presente una proprietà booleana hasTriedToUpload che usiamo poi nel componente QuoteForm per mostrare dei messaggi di feedback all’utente dopo aver cliccato il pulsante per salvare la citazione nel database. Setteremo hasTriedToUpload al valore true non appena viene cliccato il pulsante di invio del form e per farlo ci serviremo della mutazione SET_TRIED_TO_UPLOAD.

export default {
  namespaced: true,
  state: () => ({
    hasTriedToUpload: false
  }),
  mutations: {
    SET_TRIED_TO_UPLOAD(state, value) {
      state.hasTriedToUpload = value;
    }
  }
};

Passiamo ora all’altro modulo store/modules/loadingModule. Nello stato abbiamo una proprietà booleana che consente di decidere se mostrare o meno il componente LoadingSpinner mentre si stanno scaricando le citazioni dal server.

export default {
  namespaced: true,
  state: () => ({
    loading: false
  }),
  mutations: {
    SET_LOADING_STATUS(state, value) {
      state.loading = value;
    }
  },
  actions: {
    setLoadingStatus({ commit }, value) {
      commit('ET_LOADING_STATUS', value);
    }
  }
};

In store/modules/uploadingState.js definiamo un altro modulo simile. In questo caso la proprietà booleana dello stato verrà usata per segnalare che è in corso il salvataggio di una citazione nel database.

export default {
  namespaced: true,
  state: () => ({
    uploading: false
  }),
  mutations: {
    SET_UPLOADING_STATUS(state, uploadingStatus) {
      state.uploading = uploadingStatus;
    }
  }
};

Infine nel file store/module/quoteState.js abbiamo definito il modulo che incapsula tutte le funzionalità per la gestione della lista di citazioni.

import QuoteService from '@/services/QuoteService.js';

export default {
  namespaced: true,
  state: () => ({
    quotes: []
  }),
  mutations: {
    ADD_QUOTE(state, quote) {
      state.quotes.push(quote);
    },
    SET_QUOTES(state, quotes) {
      state.quotes = quotes;
    }
  },
  actions: {
    async addQuote({ commit }, quote) {
      try {
        commit('uploadingModule/SET_UPLOADING_STATUS', true, { root: true });
        commit('hasTriedToUploadModule/SET_TRIED_TO_UPLOAD', true, {
          root: true
        });

        const response = await QuoteService.postQuote(quote);

        if (response.status !== 201) {
          throw new Error('It was not possible to save the quote');
        }

        commit('ADD_QUOTE', quote);
      } catch (error) {
        commit(
          'errorModule/ADD_ERROR',
          { type: 'upload', msg: error.message },
          { root: true }
        );
      } finally {
        commit('uploadingModule/SET_UPLOADING_STATUS', false, { root: true });
        setTimeout(() => {
          commit('hasTriedToUploadModule/SET_TRIED_TO_UPLOAD', false, {
            root: true
          });
          commit('errorModule/CLEAR_ERROR', 'upload', { root: true });
        }, 4000);
      }
    },
    async getQuotes({ commit }) {
      try {
        commit('loadingModule/SET_LOADING_STATUS', true, { root: true });
        commit('errorModule/CLEAR_ERROR', 'download', { root: true });
        
        const response = await QuoteService.getAllQuotes();
        
        commit('SET_QUOTES', response.data);
      } catch (error) {
        commit(
          'errorModule/ADD_ERROR',
          { type: 'download', msg: error.message },
          { root: true }
        );
      } finally {
        commit('loadingModule/SET_LOADING_STATUS', false, { root: true });
      }
    }
  },
  getters: {
    getCurrentViewQuotes(state, getters, rootState) {
      const filters = [
        (quote) => quote.lang === 'en',
        (quote) => quote,
        (quote) => quote.lang === 'it'
      ];
      const filter = filters[rootState.currentViewModule.currentView];
      return state.quotes.filter(filter);
    }
  }
};

Abbiamo definito un’action addQuote che esegue inizialmente le due Mutations uploadingModule/SET_UPLOADING_STATUS e hasTriedToUploadModule/SET_TRIED_TO_UPLOAD per segnalare che sta per effettuare una chiamata al server e potrebbe essere necessario del tempo prima di completare un’operazione asincrona. Invoca quindi la funzione postQuote() che abbiamo definito nel servizio QuoteService per salvare una nuova citazione sul server locale. Resta quindi in attesa che la promise restituita venga completata. Se non si verificano errori, esegue la mutazione commit('ADD_QUOTE', quote) per aggiungere la citazione alla proprietà quotes dello stato locale. In caso contrario invoca una mutazione per segnalare la presenza di un errore. In entrambi i casi, viene invocata una mutazione per indicare che il processo di salvataggio è stato completato e dopo 4 secondi viene settato il valore di hasTriedToUploadModule/hasTriedToUpload al valore false per indicare che un tentativo di invio di una citazione è stato definitivamente terminato (Vedremo come usare le varie proprietà booleane nel componente QuoteForm). Viene inoltre resettata la proprietà dello stato che registra eventuali errori.

L’altra Action è getQuotes(). Anche in questo caso usiamo async/await per gestire la promise restituita da QuoteService.getAllQuotes() che consente di recuperare tutte le citazioni dal server.

async getQuotes({ commit }) {
  try {
    commit('loadingModule/SET_LOADING_STATUS', true, { root: true });
    commit('errorModule/CLEAR_ERROR', 'download', { root: true });

    const response = await QuoteService.getAllQuotes();

    commit('SET_QUOTES', response.data);
  } catch (error) {
    commit(
      'errorModule/ADD_ERROR',
      { type: 'download', msg: error.message },
      { root: true }
    );
  } finally {
    commit('loadingModule/SET_LOADING_STATUS', false, { root: true });
  }
}

Resettiamo eventuali errori precedenti ed eseguiamo una mutazione per indicare che è in corso un’operazione che richiede del tempo ('loadingModule/SET_LOADING_STATUS'). In questo modo, potremo poi mostrare un indicatore di caricamento all’interno dell’applicazione. Inviamo una richiesta al server per recuperare tutte le citazioni e se il processo viene concluso in maniera corretta, assegniamo un array di mutazioni alla proprietà quotes dello stato locale. In caso di errori eseguiamo la mutazione 'errorModule/ADD_ERROR' passando un oggetto col tipo di operazione eseguita ed un messaggio di errore. Alla fine del processo chiamiamo la funzione commit per invocare la mutazione loadingModule/SET_LOADING_STATUS con valore false. Ricordiamo che per le mutazioni che non appartengono al modulo corrente, dobbiamo specificare il loro nome completo e passare alla funzione commit() un terzo argomento { root: true }.

Nel modulo appena descritto, abbiamo usato un servizio che abbiamo definito nel file services/QuoteService.js.

import axios from 'axios';

const { VUE_APP_REMOTE_ADDRESS: ADDRESS, VUE_APP_PORT: PORT } = process.env;

const axiosInstance = axios.create({
  baseURL: `//${ADDRESS}:${PORT}`,
  headers: {
    Accept: 'application/json',
    'Content-Type': 'application/json'
  }
});

export default {
  getAllQuotes() {
    return axiosInstance.get('/quotes');
  },
  getQuote(id) {
    return axiosInstance.get(`/quotes/${id}`);
  },
  postQuote(quote) {
    return axiosInstance.post(`/quotes`, quote);
  }
};

Nel file services/QuoteService.js creiamo una nuova istanza di Axios. Si tratta di un popolare client HTTP per il browser e Node.js che abbiamo installato con il comando npm i axios. Grazie ad Axios possiamo effettuare richieste al server locale con estrema semplicità. Abbiamo definito una configurazione comune per tutte le richieste passando le opportune opzioni al metodo axios.create() per indicare al server il tipo del contenuto del corpo delle richieste e dei dati attesi dall’applicazione. Abbiamo poi esportato tre funzioni dal modulo javascript. In particolare, in getAllQuotes() chiediamo al server di restituire tutte le citazioni del database. Per recuperarne una specifica, usiamo invece getQuote(id) a cui passiamo l’identificativo della citazione. Infine postQuote() consente di inviare una nuova citazione in formato JSON al server. Tutte le funzioni restituiscono una Promise.

Per quanto riguarda i componenti, ne abbiamo definiti ben 7 nella cartella components a cui si aggiunge App nella directory base.

Alcuni di questi sono abbastanza semplici e non presentano particolari dettagli di interesse.

Per esempio nel file TheFooter.vue abbiamo un componente che usiamo per mostrare delle banali informazioni nel footer dell’applicazione.

<template>
  <footer>
    <p>Quotes DB Example. Made with Vue & Vuex.</p>
  </footer>
</template>

<style scoped>
footer {
  text-align: center;
  margin-top: 4rem;
}

p {
  font-size: var(--font-size-xs);
  color: var(--secondary-color-600);
  padding: 1rem;
}
</style>

I componenti QuoteList e QuoteListItem si limitano ad elencare la lista delle citazioni.

// file: components/QuoteList.vue
<template>
  <div class="quote-list-container">
    <h2 id="quotes">Citazioni</h2>
    <div class="quote-filter-wrapper">
      <QuoteFilter />
    </div>
    <transition-group tag="ul" name="list">
      <QuoteListItem v-for="quote in quotes" :key="quote.id" :quote="quote" />
    </transition-group>
  </div>
</template>

<script>
import QuoteListItem from '@/components/QuoteListItem.vue';
import QuoteFilter from '@/components/QuoteFilter.vue';

export default {
  name: 'QuoteList',
  components: {
    QuoteFilter,
    QuoteListItem
  },
  props: {
    quotes: {
      type: Array,
      required: true
    }
  }
};
</script>

QuoteList importa i due componenti QuoteListItem e QuoteFilter ed aspetta di ricevere le citazioni attraverso la Prop quotes.

Nel suo template troviamo il componente QuoteFilter che permette di filtrare le citazioni da visualizzare in base alla lingua scelta. Per ciascuna citazione creiamo un’istanza di QuoteListItem usando la direttiva v-for. Per migliorare l’esperienza utente, racchiudiamo i diversi componenti di tipo QuoteListItem fra i tag <transition-group> in modo da poter applicare una transizione che definiamo nella sezione CSS del file.

// file: QuoteListItem.vue
<template>
  <li>
    <blockquote>
      <p>{{ quote.quoteText }}</p>
      <footer>{{ quote.quoteAuthor }}</footer>
    </blockquote>
  </li>
</template>

<script>
export default {
  name: 'QuoteListItem',
  props: {
    quote: {
      type: Object,
      required: true
    }
  }
};
</script>

QuoteListItem si limita poi a mostrare le informazioni presenti all’interno dell’oggetto che riceve attraverso la Prop quote.

Molto più interessante è invece App che è il componente base.

<template>
  <div id="app">
    <TheHeader />
    <div class="body-container">
      <div class="form-container" ref="formContainer">
        <QuoteForm />
      </div>
      <div class="content-wrapper" ref="contentWrapper">
        <transition name="fade" mode="out-in">
          <LoadingSpinner v-if="isLoading" color="hsl(158, 58%, 62%)" />
          <div v-else-if="downloadError" class="download-error">
            <ErrorIcon />
            <p>
              Oops... Si &egrave; verificato un errore <br />
              Non &egrave; stato possibile scaricare <br />
              le citazioni dal server :-(
            </p>
          </div>
          <div v-else class="quote-list-wrapper">
            <QuoteList :quotes="quotes" />
          </div>
        </transition>
      </div>
    </div>
    <TheFooter />
  </div>
</template>

<script>
import TheHeader from '@/components/TheHeader.vue';
import TheFooter from '@/components/TheFooter.vue';
import LoadingSpinner from '@/components/LoadingSpinner.vue';
import QuoteForm from '@/components/QuoteForm.vue';
import QuoteList from '@/components/QuoteList.vue';
import ErrorIcon from '@/assets/error.svg?inline';

export default {
  name: 'App',
  components: {
    ErrorIcon,
    TheHeader,
    TheFooter,
    LoadingSpinner,
    QuoteForm,
    QuoteList
  },
  data() {
    return {
      formContainer: null,
      contentWrapper: null
    };
  },
  computed: {
    downloadError() {
      return this.$store.state.errorModule.errors.download;
    },
    isLoading() {
      return this.$store.state.loadingModule.loading;
    },
    quotes() {
      return this.$store.getters['quoteModule/getCurrentViewQuotes'];
    }
  },
  watch: {
    '$store.state.editingModeModule.editingMode': function() {
      this.formContainer.style.opacity = 1;
      this.toggleForm();
    }
  },
  created() {
    this.$store.dispatch('quoteModule/getQuotes');
  },
  mounted() {
    this.formContainer = this.$refs.formContainer;
    this.contentWrapper = this.$refs.contentWrapper;
    this.toggleForm();
  },
  methods: {
    toggleForm() {
      const { height: y } = this.formContainer.getBoundingClientRect();
      const editMode = this.$store.state.editingModeModule.editingMode;
      this.formContainer.style.transform = `scaleY(${+editMode})`;
      this.contentWrapper.style.transform = `translateY(-${y}px)`;
    }
  }
};
</script>

In App.vue importiamo ben 5 dei componenti da noi definiti. Per usare poi l’icona ErrorIcon come se fosse un componente, sfruttiamo il plugin vue cli plugin svg che abbiamo precedentemente installato lanciando il comando vue add svg nella cartella base.

Nel template del componente App inseriamo <TheHeader> in alto, <TheFooter> in basso. Aggiungiamo il componente <QuoteForm> racchiuso in un elemento <div class="form-container"> del quale salviamo un riferimento accessibile tramite this.$refs. All’interno di un altro <div>, al quale applichiamo l’attributo ref="contentWrapper", inseriamo un elemento <transition> che racchiude tre possibili elementi. Se l’applicazione sta scaricando le citazioni, visualilzziamo il componente <LoadingSpinner>, in caso contrario visualizziamo il componente <QuoteList>.

Se invece si verifica un errore mostriamo un messaggio il quale comunica all’utente che non è stato possibile scaricare le citazioni dal server.

schermata applicazione in caso di errore nell'accesso al server

Al componente QuoteList passiamo una Prop quotes il cui valore è una computed property ottenuta dallo store Vuex tramite il getter 'quoteModule/getCurrentViewQuotes'. Ciò vuol dire che se vengono aggiunte delle citazioni, il componente App riceve in automatico il nuovo array aggiornato che passa poi al componente QuoteList. Per recuperare tutte le citazioni del database lanciamo un’Action nel lifecycle hook created(). In mounted() otteniamo invece i riferimenti ai due <div> sui quali avevamo applicato l’attributo ref. Invochiamo poi il metodo toggleForm() che utilizza il metodo getBoundingClintRect() per ottenere l’altezza del <div> che contiene il form. In base al valore di editingModeModule.editingMode scegliamo se nascondere o mostrare il form e traslare <div class="content-wrapper"> verso l’altro o verso il basso. Per nascondere <div class="form-container"> riduciamo la sua altezza tramite this.formContainer.style.transform = `scaleY(${+editMode})`; (editMode è un valore booleano che trasformiamo in valore numerico 0 o 1 anteponendo il segno ‘+’, +true === 1, +false === 0). Nella sezione CSS, che abbiamo omesso, abbiamo spostato l’origine di eventuali transizioni nella parte superiore dell’elemento tramite la regola (.form-container {transform-origin: top center;}).

Registriamo anche un watcher per osservare eventuali modifiche della proprietà $store.state.editingModeModule.editingMode dello store Vuex. Ogni volta che questa cambia, invochiamo toggleForm() per mostrare (editingModeModule.editingMode === true ) o nascondere il form a seconda della modalità in cui si trova l’applicazione.

Per modificare il valore di $store.state.editingModeModule.editingMode, lanciamo un’Action dal componente TheHeader.

// file: src/components/TheHeader.vue
<template>
  <header>
    <h1>
      <a href="/" title="back to Homepage">
        <QuotesLogo />
      </a>
    </h1>
    <transition name="fade">
      <button
        v-if="isLoading"
        @click="onClick"
        class="btn btn-secondary"
        :class="editingClass"
      >
        {{ buttonLabel }}
      </button>
    </transition>
  </header>
</template>

<script>
import QuotesLogo from '@/assets/logo.svg?inline';

export default {
  name: 'TheHeader',
  components: {
    QuotesLogo
  },
  computed: {
    editingClass() {
      return { editing: this.$store.state.editingModeModule.editingMode };
    },
    isLoading() {
      return !this.$store.state.loadingModule.loading;
    },
    buttonLabel() {
      return this.$store.state.editingModeModule.editingMode
        ? 'nascondi form'
        : 'crea nuova citazione';
    }
  },
  methods: {
    onClick() {
      this.$store.dispatch(
        'editingModeModule/setEditingMode',
        !this.$store.state.editingModeModule.editingMode
      );
    }
  }
};
</script>

Nel componente TheHeader inseriamo un pulsante che servirà per mostrare o nascondere il form. Per far ciò, viene lanciata un’Action che mira ad invertire il valore corrente di $store.state.editingModeModule.editingMode.

Quando $store.state.editingModeModule.editingMode è pari a true, aggiungiamo anche un’ulteriore classe CSS .editing usando la direttiva v-bind applicata all’attributo class

Nel file QuoteFilter.vue inseriamo tre pulsanti di tipo radio. Selezionando uno dei tre, viene invocata la funzione onChange() che lancia un’Action currentViewModule/setCurrentView passando come valore l’indice associato al pulsante. In questo modo viene cambiato l’indice della view corrente e viene quindi automaticamente ricalcolata la computed property quotes del componente App il cui valore è pari a quello restituito dal Getter quoteModule/getCurrentViewQuotes.

// file: src/components/QuoteFilter.vue
<template>
  <div class="filter-container">
    <div class="filter-wrapper">
      <span class="highlight" :style="translate"></span>
      <div class="label-wrapper">
        <template v-for="(filter, index) in filters">
          <input
            type="radio"
            :id="filter"
            :value="index"
            @change="onChange"
            :checked="picked === index"
            :key="'radio' + index"
          />
          <label :for="filter" :key="'label' + index">{{ filter }}</label>
        </template>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      picked: 1,
      filters: ['inglese', 'tutte', 'italiano']
    };
  },
  computed: {
    translate() {
      let distance = this.picked * 100;
      return `transform: translateX(${distance}px)`;
    }
  },
  methods: {
    onChange(event) {
      this.picked = +event.target.value;
      this.$store.dispatch('currentViewModule/setCurrentView', this.picked);
    }
  }
};
</script>

Selezionando uno dei pulsanti radio viene anche ricalcolata la computed propery translate che usiamo per traslare orizzontalmente l’elemento <span> con classe highlight. Così facendo, evidenziamo l’elemento che è attualmente attivo.

Dal momento che vogliamo lanciare un’Action al verificarsi dell’evento change dei vari pulsanti di tipo radio, abbiamo utilizzato le direttive v-on e v-bind per registrare una funzione in risposta dell’evento change e per settare il valore dell’attributo checked, applicato al pulsante selezionato, pari a true.

Per quanto riguarda il componente QuoteForm, nel suo template inseriamo un form che contiene un campo per il nome dell’autore il quale è associato alla proprietà quoteAuthor tramite la direttiva v-model. È anche presente una textarea per il testo dela citazione (associata a quoteText sempre tramite v-model) e due pulsanti di tipo radio per selezionare la lingua anch’essi collegati alla proprietà quoteLanguage.

<template>
  <form @submit.prevent="formHandler">
    <div class="input-wrapper">
      <label class="text-field" for="quoteText">testo</label>
      <textarea
        placeholder="Inserisci il testo della citazione"
        name="quoteText"
        id="quoteText"
        v-model="quoteText"
      ></textarea>
    </div>
    <div class="input-wrapper">
      <label class="text-field" for="quoteAuthor">autore</label>
      <input
        placeholder="Inserisci il nome dell'autore"
        type="text"
        name="quoteAuthor"
        id="quoteAuthor"
        v-model="quoteAuthor"
      />
    </div>
    <div class="input-wrapper">
      <input type="radio" id="en" value="en" v-model="quoteLanguage" />
      <label class="radio-label" for="en">EN</label>
      <input type="radio" id="it" value="it" v-model="quoteLanguage" />
      <label class="radio-label" for="it">IT</label>
    </div>
    <transition name="fade" mode="out-in">
      <div key="uploading" v-if="isUploading" class="uploading-wrapper">
        <LoadingSpinner color="hsl(210, 31%, 80%)" />
        <p>Salvataggio in corso...</p>
      </div>
      <div key="not-uploading" v-else class="button-wrapper">
        <transition name="fade" mode="out-in">
          <div v-if="hasTriedToUpload" class="form-feedback">
            <div v-if="uploadError" class="feedback-error">
              <ErrorIcon />
              <span>Impossibile salvare le informazioni.</span>
            </div>
            <div v-else class="feedback-ok">
              <SuccessIcon />
              <span>Salvataggio completato</span>
            </div>
          </div>
          <button
            v-else
            :disabled="!isValidForm"
            type="submit"
            class="btn btn-primary"
          >
            Aggiungi al database
          </button>
        </transition>
      </div>
    </transition>
  </form>
</template>

<script>
import uniqid from 'uniqid';

import LoadingSpinner from '@/components/LoadingSpinner.vue';
import SuccessIcon from '@/assets/ok.svg?inline';
import ErrorIcon from '@/assets/error.svg?inline';

export default {
  components: {
    ErrorIcon,
    LoadingSpinner,
    SuccessIcon
  },
  data() {
    return {
      quoteText: '',
      quoteAuthor: '',
      quoteLanguage: 'en'
    };
  },
  computed: {
    isValidForm() {
      return this.quoteText && this.quoteAuthor;
    },
    isUploading() {
      return this.$store.state.uploadingModule.uploading;
    },
    hasTriedToUpload() {
      return this.$store.state.hasTriedToUploadModule.hasTriedToUpload;
    },
    uploadError() {
      return this.$store.state.errorModule.errors.upload;
    }
  },
  methods: {
    formHandler() {
      if (this.quoteText && this.quoteAuthor) {
        const quote = {
          id: uniqid(),
          quoteText: this.quoteText,
          quoteAuthor: this.quoteAuthor,
          lang: this.quoteLanguage
        };
        this.$store.dispatch('quoteModule/addQuote', quote);
        this.quoteText = '';
        this.quoteAuthor = '';
      }
    }
  }
};
</script>

Se è stato cliccato il pulsante per inviare le informazioni al server, si verifica l’evento submit del form in risposta del quale eseguiamo il metodo formHandler. All’elemento form abbiamo infatti applicato la direttiva v-on:submit con modificatore prevent per evitare il comportamento predefinito che causerebbe un nuovo caricamento dell’intera pagina. Nel metodo formHandler creiamo un nuovo oggetto con le informazioni relative alla citazione che vogliamo aggiungere al database e lanciamo l’Action quoteModule/addQuote che setta isUploading al valore true. Per assegnare un identificativo univoco all’oggetto della citazione, abbiamo usato il package uniqid. Mentre è in corso la procedura di salvataggio, mostriamo il componente LoadingSpinner. Non appena la computed property isUploading è pari a false, ovvero quando l’Action quoteModule/addQuote sta per terminare ed esegue la Mutation che setta $store.state.uploadingModule.uploading al valore false, viene mostrato un messaggio il quale comunica se l’operazione di salvataggio è avvenuta correttamente o meno (in caso di errore l’Action quoteModule/addQuote esegue una mutazione per assegnare a state.errorModule.errors.upload un messaggio di errore). Ricordiamo che nell’Action quoteModule/addQuote invocavamo alla fine la funzione setTimeout() in modo da cambiare state.hasTriedToUploadModule.hasTriedToUpload che diventa pari a false. Così facendo, la computed property hasTriedToUpload diventa pari a false e vengono quindi nascosti i messaggi di feedback per far posto nuovamente al pulsante del form.

La versione finale dell’applicazione è disponibile su Bitbucket.

Riepilogo

In questa lezione abbiamo realizzato un esempio un po’ più articolato usando Vuex.

Finora abbiamo visto soltanto applicazioni con una sola view. Nella prossima lezione illustreremo invece il funzionamento di Vue Router che rappresenta un altro argomento fondamentale per lo sviluppo di applicazioni SPA (single-page application) nel mondo reale.

Pubblicitร