Sviluppo fullstack con AdonisJS parte 5: Gestione delle categorie di file

Sviluppo fullstack con AdonisJS parte 5: Gestione delle categorie di file

Come creare, modificare ed eliminare delle risorse a DB

Foto di Pavel Nekoranec su Unsplash

Premessa

Questo articolo fa parte di una serie che illustra come costruire un’applicazione con AdonisJS e VueJS. Se ti fossi perso la prima parte inizia da qui.

Introduzione

Il nostro obiettivo principale è di permettere agli utenti registrati di caricare dei file catalogandoli sotto una certa categoria per permettere poi agli amministratori di accedervi in maniera ordinata.

In questo articolo ci concentreremo sulla gestione delle categorie permettendo agli amministratori di visualizzare, creare, eliminare e modificare delle semplici categorie che in futuro saranno associate ai file caricati dagli utenti. Queste operazioni sono tipicamente chiamate con l’acronimo CRUD (Create Read Update Delete) e come vedremo si possono gestirle molto facilmente in Adonis.

Creazione del modello

Per prima cosa creiamo il modello che rappresenterà l’entità “Categoria File” e ci permetterà di interagire con i dati a DB.

node ace make:model FileCategory -m

Con il flag -m specifichiamo di creare anche il relativo file per la migrazione con il quale creeremo la tabella.

Il modello creato è già praticamente pronto, aggiungiamo solo una colonna che rappresenta il nome della categoria:

// app/Models/FileCategory.ts
import { DateTime } from 'luxon'
import { BaseModel, column } from '@ioc:Adonis/Lucid/Orm'

export default class FileCategory extends BaseModel {
  @column({ isPrimary: true })
  public id: number

  @column.dateTime({ autoCreate: true })
  public createdAt: DateTime

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  public updatedAt: DateTime

  @column()
  public name: string
}

Il comando make:model ha automaticamente generato la classe con all’interno quelle colonne che è molto probabile siano utilizzate: id come campo numerico primario, createdAt per memorizzare in automatico quando la entry è stata creata e updatedAt per tenere traccia di quando invece viene modificata.

Creazione della tabella

Anche nel file generato per la migrazione il lavoro è quasi già fatto in automatico. Aggiungiamo solo la definizione della colonna “name”:

import BaseSchema from '@ioc:Adonis/Lucid/Schema'

export default class FileCategories extends BaseSchema {
  protected tableName = 'file_categories'

  public async up () {
    this.schema.createTable(this.tableName, (table) => {
      table.increments('id')

      /**
       * Uses timestamptz for PostgreSQL and DATETIME2 for MSSQL
       */
      table.timestamp('created_at', { useTz: true })
      table.timestamp('updated_at', { useTz: true })

      table.string('name')
    })
  }

  public async down () {
    this.schema.dropTable(this.tableName)
  }
}

La migrazione ha dedotto dal nome del modello il nome della tabella da creare trasformandolo in “snake case” (con gli underscore a dividere le parole) e in forma plurale (si nota qui l’importanza dell’uso della lingua inglese per avvantaggiarsi di certe funzionalità).

Gli stessi campi di default che abbiamo trovato nel modello li ritroviamo anche nella nostra migrazione; assicuriamoci solo di aggiungere il campo “name” eseguendo il metodo table.string().

Nota: tutte le istruzioni per la generazione della tabella sono disponibili nella documentazione di Adonis sotto la sezione Table builder.

Allineiamo il database alla migrazione appena creata:

node ace migration:run

Nota: se commettete qualche errore nella scrittura della migrazione e ve ne accorgete solo dopo averla eseguita eseguite un rollback con il comando node ace migration:rollback e riprendete le modifiche da prima dell’esecuzione; resta importante la corretta definizione dell’istruzione down all’interno della migrazione che viene appunto eseguita alla richiesta di rollback.

Creazione del controller con operazioni CRUD

I controller sono delle porzioni di codice che si occupano della business logic dell’applicazione, possono interagire con i modelli (che come detto si occupano direttamente dell’interazione con i dati) ma anche eseguire altri tipi di operazioni (operazioni matematiche, operazioni su file fisici, ecc…).

Creiamo un controller che si occuperà delle diverse operazioni legate alle categorie di file:

node ace make:controller FileCategory

Ora aggiungiamo le route che punteranno al nostro controller:

// start/routes.ts
Route.group(() => {
  Route.get('about', () => {
    return [1, 2, 3, 4]
  })
  Route.resource('file-categories', 'FileCategoryController').apiOnly()
}).middleware('auth')

Notate alcuni dettagli:

  1. Abbiamo aggiunto la nuova route all’interno dello stesso gruppo di “about” in modo che erediti il middleware “auth” e sia quindi protetta da autenticazione
  2. Con la sola espressione resource andiamo ad agganciare tutti i metodi CRUD all’endpoint “file-categories”; anche se non è obbligatorio, in questo caso ci risparmia molto lavoro
  3. Con l’ulteriore istruzione apiOnly() andiamo a filtrare i soli endpoint che possono essere richiamati da un client esterno *

*= Normalmente Adonis mette a disposizione due handler associati alla creazione: create e store. Il primo con metodo GET è dedicato a fornire una pagina lato server che fornisca un form per la compilazione dei dati; il secondo con metodo POST è l’handler vero e proprio che riceve i dati e li salva a DB dopo eventuale validazione. Dato che il form sarà costruito all’interno di Vue senza passare per il rendering lato server, con apiOnly() escluderemo l’handler create. Lo stesso identico discorso vale per update e edit.

Andiamo a vedere da linea di comando quali API abbiamo esposto:

node ace list:routes

file-categories-routes.png

Come potete vedere abbiamo a disposizione diverse route con gli appropriati metodi che richiamano automaticamente le funzioni specifiche del controller. Iniziamo a creare questi metodi in modo che rispondano alle richieste del client.

Creazione di una categoria

Andiamo a creare nel nostro controller il metodo “store” per salvare nuove categorie:

// app/Controllers/Http/FileCategoryController.ts
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import { schema } from '@ioc:Adonis/Core/Validator'
import FileCategory from 'App/Models/FileCategory'

export default class FileCategoriesController {
  public async store(ctx: HttpContextContract) {
    const { request } = ctx

    const categorySchema = schema.create({
      name: schema.string({ trim: true }),
    })

    const payload = await request.validate({ schema: categorySchema })

    const category = new FileCategory()
    category.name = payload.name
    await category.save()

    return category.id
  }
}

Abbiamo creato il metodo store richiamato dal router all’endpoint “POST /file-categories”. Grazie al validatore di Adonis possiamo facilmente controllare la coerenza dei dati nella richiesta: nel caso in cui i dati non siano validi il metodo ritornerà automaticamente un errore dettagliato senza eseguire il resto del codice.

Acquisito un payload valido istanziamo una nuova categoria dal modello FileCategory, gli assegniamo il nome inviato dal client e rendiamo il dato persistente eseguendo il metodo save.

Notate che “id”, data di creazione e data di modifica sono creati automaticamente dal DB; una volta reso persistente il dato possiamo accedere all’id e fornirlo come riposta a chi effettua la richiesta.

Modulo di creazione di una nuova categoria

Passiamo al frontend e creiamo un modulo per la creazione di una nuova categoria. Aggiungiamo il file resources/js/pages/FileCategories/Create.vue:

<!-- resources/js/pages/FileCategories/Create.vue -->
<script>
import { defineComponent, ref } from 'vue'
import { useMessage, NForm, NInput, NFormItem, NButton } from 'naive-ui'

export default defineComponent({
  components: { NForm, NInput, NFormItem, NButton },
  setup(props, context) {
    const formRef = ref(null)
    const message = useMessage()

    return {
      message,
      formRef,
      formValue: ref({
        name: '',
      }),
      rules: {
        name: {
          required: true,
          message: 'Il nome è obbligatorio',
          trigger: 'blur',
        },
      },
    }
  },
  methods: {
    submit(e) {
      e.preventDefault()
      this.formRef.validate((errors) => {
        if (!errors) {
          this.$http
            .post('/file-categories', this.formValue)
            .then((response) => {
              console.log(response)
              // TODO: go to category list (optional: highlight the new category)
            })
            .catch((errors) => {
              this.showErrors(errors)
            })
        } else {
          this.showErrors(errors)
        }
      })
    },
    // TODO: share with composition or mixin
    showErrors(errors) {
      if (errors.hasOwnProperty('response')) {
        console.error(errors)
        console.error('Server Response', errors.response.data)
        const { data } = errors.response
        this.showErrors(data)
      } else if (Array.isArray(errors)) {
        errors.forEach(this.showErrors)
      } else if (errors.hasOwnProperty('errors')) {
        this.showErrors(errors.errors)
      } else if (errors.hasOwnProperty('message')) {
        let { message } = errors
        if (errors.hasOwnProperty('field')) {
          message = `${message} in field '${errors.field}'`
        }
        this.showErrors(message)
      } else if (typeof errors === 'string') {
        this.message.error(errors)
      }
    },
  },
})
</script>
<template>
  <div class="wrapper">
    <h1>Crea una nuova categoria di file</h1>
    <n-form ref="formRef" :model="formValue" :rules="rules" size="medium">
      <n-form-item label="Nome" path="name">
        <n-input v-model:value="formValue.name" />
      </n-form-item>
      <n-form-item>
        <n-button @click="submit" attr-type="submit"> Crea </n-button>
      </n-form-item>
    </n-form>
  </div>
</template>
<style lang="scss" scoped>
.wrapper {
  max-width: 480px;
}
</style>

Aggiungiamolo alle route all’interno del layout principale:

// resources/js/routes.js
const routes = [
  { path: '/login', component: Login },
  {
    path: '/',
    component: Layout,
    children: [
      { name: 'home', path: '/', component: Home },
      { path: '/about', component: About },
      { path: '/file-categories/create', component: CreateFileCategory },
    ],
  },
]

Aggiungiamo una voce di menù annidata nella Navbar:

// resources/js/components/Navbar.vue
<script>
  // ...
  import {
  Information as WorkIcon,
  Home as HomeIcon,
  Logout as LogoutIcon,
  Bookmark as BookmarkIcon,
  Add as AddIcon,
} from '@vicons/carbon'
  // ...
  
 const menuOptions = [
  // ...
  {
    label: 'Categorie file',
     key: 'file-categories',
    icon: renderIcon(BookmarkIcon),
    children: [
      {
        label: () =>
        h(
          RouterLink,
          {
            to: {
              path: '/file-categories/create',
            },
          },
          { default: () => 'Crea categoria' }
        ),
        key: 'file-categories-create',
        icon: renderIcon(AddIcon),
      },
    ],
  },
   // ...
]
</script>

Visitiamo la pagina dalla voce di menù e testiamo che funzioni tutto correttamente.

Mostrare l’elenco delle categorie

Prepariamo una pagina dove è possibile visualizzare le categorie create.

Riprendendo l’elenco delle route autogenerate come risorse vediamo che alla chiamata GET /file-categories corrisponde l’handler index che andiamo quindi a creare nel controller.

// app/Controllers/Http/FileCategoryController.ts
export default class FileCategoriesController {
  public async index() {
    const categories = await FileCategory.query().orderBy('name', 'asc')
    return categories
  }
  // ...
}

Avremmo potuto richiamare le categorie con un semplice FileCategory.all() ma l’ordinamento delle righe sarebbe stato quello di default ossia quello di inserimento. Per ordinare le righe per nome accediamo al query builder tramite il metodo query() e usiamo il metodo orderBy().

Creiamo quindi la pagina che mostrerà le categorie aggiungendo il file resources/js/pages/FileCategories/List.vue:

<!-- resources/js/pages/FileCategories/List.vue -->
<script>
import { NList, NListItem, NButton, NThing, NIcon, NSpace } from 'naive-ui'
import { TrashCan as TrashCanIcon, Edit as EditIcon } from '@vicons/carbon'

export default {
  components: { NList, NListItem, NButton, NThing, TrashCanIcon, NIcon, EditIcon, NSpace },
  data() {
    return {
      categories: [],
    }
  },
  mounted() {
    this.$http
      .get('/file-categories')
      .then((response) => {
        this.categories = response.data
      })
      .catch((errors) => {
        // TODO use shared method showErrors
        console.error(errors)
      })
  },
}
</script>
<template>
  <h1>Elenco categorie file</h1>
  <div class="container">
    <n-list bordered>
      <n-list-item v-for="category in categories" :key="category.id">
        <template #prefix>
          <n-button quaternary circle>
            <template #icon>
              <n-icon><edit-icon /></n-icon>
            </template>
          </n-button>
        </template>
        <template #suffix>
          <n-button quaternary circle>
            <template #icon>
              <n-icon><trash-can-icon /></n-icon>
            </template>
          </n-button>
        </template>
        <n-thing :title="category.name" />
      </n-list-item>
    </n-list>
  </div>
</template>
<style lang="scss" scoped>
.container {
  max-width: 480px;
}
</style>

E lo rendiamo accessibile da una nuova voce di menù:

// ...
const menuOptions = [
// ...
{
        label: 'Categorie file',
        key: 'file-categories',
        icon: renderIcon(BookmarkIcon),
        children: [
          {
            label: () =>
              h(
                RouterLink,
                {
                  to: {
                    path: '/file-categories',
                  },
                },
                { default: () => 'Elenco categorie' }
              ),
            key: 'file-categories-list',
            icon: renderIcon(ListIcon),
          },
          {
            label: () =>
              h(
                RouterLink,
                {
                  to: {
                    path: '/file-categories/create',
                  },
                },
                { default: () => 'Crea categoria' }
              ),
            key: 'file-categories-create',
            icon: renderIcon(AddIcon),
          },
        ],
      },
// ...
]

Ora accedendo dalla voce di menù “Elenco categorie” potremo visualizzare tutte le categorie create fino ad ora.

Modifichiamo il metodo submit di Crete.vue in modo che alla creazione l’utente sia reindirizzato a questa nuova pagina per avere una panoramica delle categorie create.

// ...
submit(e) {
  e.preventDefault()
  this.formRef.validate((errors) => {
    if (!errors) {
      this.$http
        .post('/file-categories', this.formValue)
        .then((response) => {
        this.$router.push('/file-categories')
      })
        .catch((errors) => {
        this.showErrors(errors)
      })
    } else {
      this.showErrors(errors)
    }
  })
},
// ...

Eliminazione di una categoria

Aggiungiamo il metodo destroy a FileCategoryController.ts:

// app/Controllers/Http/FileCategoryController.ts
// ...
public async destroy(ctx: HttpContextContract) {
  const { request } = ctx
  const id = request.param('id')
  const category = await FileCategory.findOrFail(id)
  await category.delete()
  return 'success'
}
// ...

Useremo la route DELETE /file-categories/:id dove :id è un valore dinamico che possiamo recuperare con request.param() dopo di che non ci resta che creare un’istanza della categoria da eliminare ed eseguire il metodo delete.

Ora aggiorniamo il frontend in modo che al click sul bottone con il cestino venga richiamato il metodo destroy:

<script>
import { ref } from 'vue'
import { NList, NListItem, NButton, NThing, NIcon, NSpace, useMessage } from 'naive-ui'
import { TrashCan as TrashCanIcon, Edit as EditIcon } from '@vicons/carbon'

export default {
  components: { NList, NListItem, NButton, NThing, TrashCanIcon, NIcon, EditIcon, NSpace },
  setup() {
    const message = useMessage()
    const categories = ref([])
    return {
      message,
      categories,
    }
  },
  mounted() {
    this.fetchCategories()
  },
  methods: {
    fetchCategories() {
      this.$http
        .get('/file-categories')
        .then((response) => {
          this.categories = response.data
        })
        .catch((errors) => {
          // TODO use shared method showErrors
          console.error(errors)
        })
    },
    deleteCategory(id) {
      this.$http
        .delete(`/file-categories/${id}`)
        .then((response) => {
          this.message.success('Categoria rimossa')
          this.fetchCategories()
        })
        .catch((errors) => {
          // TODO use shared method showErrors
          console.error(errors)
        })
    },
  },
}
</script>
<template>
  <h1>Elenco categorie file</h1>
  <div class="container">
    <n-list bordered>
      <n-list-item v-for="category in categories" :key="category.id">
        <template #prefix>
          <n-button quaternary circle>
            <template #icon>
              <n-icon><edit-icon /></n-icon>
            </template>
          </n-button>
        </template>
        <template #suffix>
          <n-button quaternary circle @click="deleteCategory(category.id)">
            <template #icon>
              <n-icon><trash-can-icon /></n-icon>
            </template>
          </n-button>
        </template>
        <n-thing :title="category.name" />
      </n-list-item>
    </n-list>
  </div>
</template>
<style lang="scss" scoped>
.container {
  max-width: 480px;
}
</style>

Modificare una categoria

Per modificare una categoria ci appoggeremo sulla stessa pagina di creazione estendendola in modo che se è definito un id per la categoria corrente allora attiveremo la modalità di modifica anzi che di creazione.

Ci serviranno due nuovi metodi nel controller, il primo, show, per recuperare e mostrare le informazioni della categoria esistente e il secondo, update, per aggiornarla quando l’utente avrà effettuato le modifiche.

// app/Controller/Http/FileCategoryController.ts

//...

export default class FileCategoriesController {
  
  //...
  
  public async show(ctx: HttpContextContract) {
    const { request } = ctx
    const id = request.param('id')
    const category = await FileCategory.findOrFail(id)
    return category
  }

  public async update(ctx: HttpContextContract) {
    const { request } = ctx
    const id = request.param('id')

    const payload = await this.validate(request)
    const category = await FileCategory.findOrFail(id)

    category.name = payload.name
    category.save()
    return category
  }
}

Entrambi i metodi utilizzano findOrFail per recuperare le informazioni della categoria. Il metodo update valida i valori in ingresso, aggiorna la proprietà name e salva le modifiche; per applicare la validazione sfruttiamo il lavoro fatto per la creazione e isoliamo le operazioni di validazione in un nuovo metodo validate:

// app/Controller/Http/FileCategoryController.ts

//...

export default class FileCategoriesController {
  
  //...
  
	private async validate(request: RequestContract) {
    const categorySchema = schema.create({
      name: schema.string({ trim: true }),
    })

    return await request.validate({ schema: categorySchema })
  }

  public async store(ctx: HttpContextContract) {
    const { request } = ctx

    const payload = await this.validate(request)

    const category = new FileCategory()
    category.name = payload.name
    await category.save()

    return category.id
  }
}

Ora esiste un solo metodo condiviso tra store e update che potrà essere facilmente esteso nel caso in cui lo schema di validazione necessiti di nuove regole.

Passiamo all’interfaccia: nella lista delle categorie attiviamo il bottone per la modifica in modo che porti ad un nuovo indirizzo:

<!-- resources/js/pages/List.vue -->
<script>
import { ref } from 'vue'
import { NList, NListItem, NButton, NThing, NIcon, NSpace, useMessage } from 'naive-ui'
import { TrashCan as TrashCanIcon, Edit as EditIcon } from '@vicons/carbon'

export default {
  components: { NList, NListItem, NButton, NThing, TrashCanIcon, NIcon, EditIcon, NSpace },
  setup() {
    const message = useMessage()
    const categories = ref([])
    return {
      message,
      categories,
    }
  },
  mounted() {
    this.fetchCategories()
  },
  methods: {
    fetchCategories() {
      this.$http
        .get('/file-categories')
        .then((response) => {
          this.categories = response.data
        })
        .catch((errors) => {
          // TODO use shared method showErrors
          console.error(errors)
        })
    },
    deleteCategory(id) {
      this.$http
        .delete(`/file-categories/${id}`)
        .then((response) => {
          this.message.success('Categoria rimossa')
          this.fetchCategories()
        })
        .catch((errors) => {
          // TODO use shared method showErrors
          console.error(errors)
        })
    },
    goToUpdateCategory(id) {
      this.$router.push(`/file-categories/edit/${id}`)
    },
  },
}
</script>
<template>
  <h1>Elenco categorie file</h1>
  <div class="container">
    <n-list bordered>
      <n-list-item v-for="category in categories" :key="category.id">
        <template #prefix>
          <n-button quaternary circle @click="goToUpdateCategory(category.id)">
            <template #icon>
              <n-icon><edit-icon /></n-icon>
            </template>
          </n-button>
        </template>
        <template #suffix>
          <n-button quaternary circle @click="deleteCategory(category.id)">
            <template #icon>
              <n-icon><trash-can-icon /></n-icon>
            </template>
          </n-button>
        </template>
        <n-thing :title="category.name" />
      </n-list-item>
    </n-list>
  </div>
</template>
<style lang="scss" scoped>
.container {
  max-width: 480px;
}
</style>

Rinominiamo il file resources/js/pages/Create.vue in CreateAndUpdate.vue per esplicitare che inizierà ad occuparsi anche dell’aggiornamento. Dopo di che aggiungiamo il nuovo indirizzo per le modifiche nelle route frontend:

// resources/js/routes.js
import Layout from './components/Layout'
import Home from './pages/Home'
import About from './pages/About'
import Login from './pages/Login'
import CreateAndUpdateFileCategory from './pages/FileCategories/CreateAndUpdate'
import FileCategoryList from './pages/FileCategories/List'

const routes = [
  { path: '/login', component: Login },
  {
    path: '/',
    component: Layout,
    children: [
      { name: 'home', path: '/', component: Home },
      { path: '/about', component: About },
      { path: '/file-categories/edit/:id', component: CreateAndUpdateFileCategory },
      { path: '/file-categories/create', component: CreateAndUpdateFileCategory },
      { path: '/file-categories', component: FileCategoryList },
    ],
  },
]

export default routes

Infine modifichiamo il file CreateAndUpdate.vue in modo che effettui un aggiornamento nel caso in cui nell’url sia specificato un id.

<!-- resources/js/pages/CreateAndUpdate.vue -->
<script>
import { defineComponent, ref } from 'vue'
import { useMessage, NForm, NInput, NFormItem, NButton } from 'naive-ui'
import { useRoute } from 'vue-router'

export default defineComponent({
  components: { NForm, NInput, NFormItem, NButton },
  setup(props, context) {
    const formRef = ref(null)
    const formValue = ref({
      name: '',
    })
    const message = useMessage()
    const route = useRoute()

    const { id } = route.params

    return {
      id,
      message,
      formRef,
      formValue,
      rules: {
        name: {
          required: true,
          message: 'Il nome è obbligatorio',
          trigger: 'blur',
        },
      },
    }
  },
  mounted() {
    if (this.id) this.getCategory(this.id)
  },
  computed: {
    actionText() {
      return this.id ? 'Aggiorna' : 'Crea'
    },
  },
  methods: {
    getCategory(id) {
      this.$http.get(`file-categories/${id}`).then(({ data }) => {
        console.log('getCategory', data)
        this.formValue.name = data.name
      })
    },
    submit(e) {
      e.preventDefault()
      this.formRef.validate((errors) => {
        if (!errors) {
          const action = this.id
            ? this.$http.put(`/file-categories/${this.id}`, this.formValue)
            : this.$http.post('/file-categories', this.formValue)

          action
            .then((response) => {
              this.$router.push('/file-categories')
              // TODO: go to category list (optional: highlight the new category)
            })
            .catch((errors) => {
              this.showErrors(errors)
            })
        } else {
          this.showErrors(errors)
        }
      })
    },
    // TODO: share with composition or mixin
    showErrors(errors) {
      if (errors.hasOwnProperty('response')) {
        console.error(errors)
        console.error('Server Response', errors.response.data)
        const { data } = errors.response
        this.showErrors(data)
      } else if (Array.isArray(errors)) {
        errors.forEach(this.showErrors)
      } else if (errors.hasOwnProperty('errors')) {
        this.showErrors(errors.errors)
      } else if (errors.hasOwnProperty('message')) {
        let { message } = errors
        if (errors.hasOwnProperty('field')) {
          message = `${message} in field '${errors.field}'`
        }
        this.showErrors(message)
      } else if (typeof errors === 'string') {
        this.message.error(errors)
      }
    },
  },
})
</script>
<template>
  <div class="wrapper">
    <h1>{{ actionText }} una nuova categoria di file</h1>
    <n-form ref="formRef" :model="formValue" :rules="rules" size="medium">
      <n-form-item label="Nome" path="name">
        <n-input v-model:value="formValue.name" />
      </n-form-item>
      <n-form-item>
        <n-button @click="submit" attr-type="submit"> {{ actionText }} </n-button>
      </n-form-item>
    </n-form>
  </div>
</template>
<style lang="scss" scoped>
.wrapper {
  max-width: 480px;
}
</style>

Il prossimo passo

Nel prossimo articolo interromperemo un attimo lo sviluppo di nuove funzionalità per soffermarci temporaneamente sulla personalizzazione del tema.

Commenti