Uno dei problemi più diffusi e comuni nello sviluppo di software è quello della creazione di applicazioni che permettano di gestire e manipolare dei dati persistenti. Solitamente applicazioni di questo tipo, sono chiamate CRUD (che significa semplicemente create, read, update, e delete). In questo articolo costruiremo una semplice app dinamica che permetta di effettuare le operazioni CRUD, utilizzando un’interfaccia moderna React. Il progetto in questione sarà quindi un mix di tecniche già mostrate in alcuni articoli precedenti che sono disponibili cliccando i link seguenti:
- Symfony 5, MySql e React. Creare una Single Page Application dinamica
- API Platform e Symfony: come creare delle API RESTful con operazioni CRUD
Symfony è un framework PHP utilizzato per sviluppare applicazioni web, API, microservizi e servizi web. Rappresenta uno dei principali framework PHP per la creazione di siti web e applicazioni web. React o anche chiamato React.js o Reactjs è una libreria JavaScript gratuita e open source utilizzata per la creazione di interfacce utente (UI). È una delle librerie JavaScript più popolari per la creazione del front-end. React è creato da Facebook e gestito da Facebook.
Creiamo il progetto Symfony
Ricordiamoci di aver installato una versione di PHP pari o superiore a 8.0 e Composer, ed inoltre aver installato Node e NPM e YARN.
Selezioniamo una cartella in cui desideriamo installare Symfony, quindi eseguiamo questo comando sul Terminale (start, esegui, cmd) per l’installazione:
composer create-project symfony/website-skeleton symfony-6-react-crud
Composer si occuperà di installare tutte le librerie necessarie al corretto funzionamento. Alla domanda:
Do you want to include Docker configuration from recipes?
possiamo rispondere anche N. Non ci interessa una configurazione per Docker.
Attendiamo quindi il completamento dell’installazione che avverrà in qualche decina di secondi.
Impostiamo il database
Per configure il database che conterrà i nostri dati, apririamo il file .env che si trova nella root dell’app e impostiamo le credenziali del database e il nome. Useremo MySQL in questo tutorial, quindi sarà necessario togliere il commento nella riga la variabile DATABASE_URL relativa a MySQL e aggiornare le sua configurazione. Assicuriamoci di aver commentato le altre variabili DATABASE_URL, altrimenti riceveremo un errore, specie se Symfony non dovesse trovare gli altri motori di database (ad esempio Postgres).
###> doctrine/doctrine-bundle ### # Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url # IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml # # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8&charset=utf8mb4" # DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=14&charset=utf8" ###< doctrine/doctrine-bundle ###
Dopo aver configurato il database, eseguiamo questo comando per creare il database:
php bin/console doctrine:database:create
Creiamo le Entità e migriamole sul DB
Eseguiamo questo comando per creare una nuova Entità di nome Progetto con due campi di tipo testo chiamati nome e descrizione:
php bin/console make:entity
New property name (press <return> to stop adding fields): > nome Field type (enter ? to see all types) [string]: > Field length [255]: > Can this field be null in the database (nullable) (yes/no) [no]: > updated: src/Entity/Progetto.php Add another property? Enter the property name (or press <return> to stop adding fields): > descrizione Field type (enter ? to see all types) [string]: > Field length [255]: > Can this field be null in the database (nullable) (yes/no) [no]: > updated: src/Entity/Progetto.php Add another property? Enter the property name (or press <return> to stop adding fields): > Success! Next: When you're ready, create a migration with php bin/console make:migration
A questo punto, come già ci suggerisce Symfony, possiamo generare il file di migrazione e migrare l’entità Progetto sul nostro DB attraverso i comandi:
php bin/console make:migration php bin/console doctrine:migrations:migrate
Creiamo i Controller
Un Controller è il responsabile della ricezione delle Request e della restituzione della Response. Creiamo il Controller responsabile della logica API. Tramite riga di comando eseguiamo il seguente comando:
php bin/console make:controller ProgettoController
Quindi, apriamo il file generato in src/Controller/ProjectController.php e aggiungiamo queste righe di codice:
namespace App\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Doctrine\Persistence\ManagerRegistry; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\HttpFoundation\Request; use App\Entity\Progetto; #[Route('/api', name: 'api_')] class ProgettoController extends AbstractController { #[Route('/progetto', name: 'app_progetto', methods: array('GET'))] public function index(ManagerRegistry $doctrine, Request $request): Response { $progetti = $doctrine ->getRepository(Progetto::class) ->findAll(); $data = []; foreach ($progetti as $project) { $data[] = [ 'id' => $project->getId(), 'nome' => $project->getNome(), 'descrizione' => $project->getDescrizione(), ]; } return $this->json($data); } #[Route('/progetto', name: 'progetto_new', methods: array('POST'))] public function new(ManagerRegistry $doctrine, Request $request): Response { $entityManager = $doctrine->getManager(); $project = new Progetto(); $project->setNome($request->request->get('nome')); $project->setDescrizione($request->request->get('descrizione')); $entityManager->persist($project); $entityManager->flush(); return $this->json('Nuovo progetto creato con id ' . $project->getId()); } #[Route('/progetto/{id}', name: 'progetto_show', methods: array('GET'))] public function show(ManagerRegistry $doctrine, int $id): Response { $project = $doctrine->getRepository(Progetto::class)->find($id); if (!$project) { return $this->json('Nessun progetto trovato con id' . $id, 404); } $data = [ 'id' => $project->getId(), 'nome' => $project->getNome(), 'descrizione' => $project->getDescrizione(), ]; return $this->json($data); } #[Route('/progetto/{id}', name: 'progetto_edit', methods: array('PUT', 'PATCH'))] public function edit(ManagerRegistry $doctrine, Request $request, int $id): Response { $entityManager = $doctrine->getManager(); $project = $entityManager->getRepository(Progetto::class)->find($id); if (!$project) { return $this->json('Nessun progetto trovato con id' . $id, 404); } $content = json_decode($request->getContent()); $project->setNome($content->nome); $project->setDescrizione($content->descrizione); $entityManager->flush(); $data = [ 'id' => $project->getId(), 'nome' => $project->getNome(), 'descrizione' => $project->getDescrizione(), ]; return $this->json($data); } #[Route('/progetto/{id}', name: 'progetto_delete', methods: array('DELETE'))] public function delete(ManagerRegistry $doctrine, int $id): Response { $entityManager = $doctrine->getManager(); $project = $entityManager->getRepository(Progetto::class)->find($id); if (!$project) { return $this->json('Nessun progetto trovato con id' . $id, 404); } $entityManager->remove($project); $entityManager->flush(); return $this->json('Cancellato progetto con id ' . $id); } }
Adesso creiamo un altro controller che che caricherà l’app di React, con il seguente comando:
php bin/console make:controller SpaController
Anche in questo caso, bisognerà modificare il file creato in /src/Controller/SpaController.php con queste righe di codice:
<?php namespace App\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; class SpaController extends AbstractController { #[Route('/{reactRouting}', name: 'app_spa', requirements: array("reactRouting"=>"^(?!api).+"), defaults: array("reactRouting"=>null))] public function index(): Response { return $this->render('spa/index.html.twig', [ 'controller_name' => 'SpaController', ]); } }
Aggiorniamo i template
Aggiorniamo ora i template di visualizzazione. Innanzitutto, modifichiamo la base /templates/base.html.twig incollando il codice qui sotto, andando a sostituire tutto ciò che c’era prima:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>{% block title %}Symfony React SPA!{% endblock %}</title> {% block stylesheets %} {{ encore_entry_link_tags('app') }} {% endblock %} <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous"> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script> </head> <body> {% block body %}{% endblock %} {% block javascripts %} {{ encore_entry_script_tags('app') }} {% endblock %} </body> </html>
Quindi, aggiorniamo pure il template che è stato generato quando abbiamo creato il controller della Spa in /templates/spa/index.html.twig:
{% extends 'base.html.twig' %} {% block body %} <div id="app"></div> {% endblock %}
Ora che abbiamo impostato il back-end, procediamo con il front-end.
Installiamo Encore e le dipendenze React
Installiamo il Symfony Webpack Encore Bundle con questi comandi per installare le dipendenze PHP e JavaScript:
composer require symfony/webpack-encore-bundle
yarn install
Quindi installiamo le dipendenze per la nostra React:
yarn add @babel/preset-react --dev
yarn add react-router-dom
yarn add --dev react react-dom prop-types axios
yarn add @babel/plugin-proposal-class-properties @babel/plugin-transform-runtime
E una libreria per dare un tocco di classe alle finestre di dialogo:
npm install sweetalert2
Aggiorniamo adesso la configurazione di webpack.config.js nella root del progetto. Si notino i commenti aggiungi al file per capire quali modifiche eseguire al file:
const Encore = require('@symfony/webpack-encore');
// Manually configure the runtime environment if not already configured yet by the "encore" command.
// It's useful when you use tools that rely on webpack.config.js file.
if (!Encore.isRuntimeEnvironmentConfigured()) {
Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev');
}
Encore
// directory where compiled assets will be stored
.setOutputPath('public/build/')
// public path used by the web server to access the output path
.setPublicPath('/build')
.enableReactPreset() // RIGA DA AGGIUNGERE
// only needed for CDN's or sub-directory deploy
//.setManifestKeyPrefix('build/')
/*
* ENTRY CONFIG
*
* Each entry will result in one JavaScript file (e.g. app.js)
* and one CSS file (e.g. app.css) if your JavaScript imports CSS.
*/
.addEntry('app', './assets/app.js')
// enables the Symfony UX Stimulus bridge (used in assets/bootstrap.js)
.enableStimulusBridge('./assets/controllers.json')
// When enabled, Webpack "splits" your files into smaller pieces for greater optimization.
.splitEntryChunks()
// will require an extra script tag for runtime.js
// but, you probably want this, unless you're building a single-page app
.enableSingleRuntimeChunk()
/*
* FEATURE CONFIG
*
* Enable & configure other features below. For a full
* list of features, see:
* https://symfony.com/doc/current/frontend.html#adding-more-features
*/
.cleanupOutputBeforeBuild()
.enableBuildNotifications()
.enableSourceMaps(!Encore.isProduction())
// enables hashed filenames (e.g. app.abc123.css)
.enableVersioning(Encore.isProduction())
// configure Babel // RIGA DA DECOMMENTARE E MODIFICARE RISPETTO A QUELLA DI DEFAULT
.configureBabel((config) => {
config.plugins.push('@babel/plugin-proposal-class-properties');
})
// enables and configure @babel/preset-env polyfills
.configureBabelPresetEnv((config) => {
config.useBuiltIns = 'usage';
config.corejs = '3.23';
})
// enables Sass/SCSS support
//.enableSassLoader()
// uncomment if you use TypeScript
//.enableTypeScriptLoader()
// uncomment if you use React
//.enableReactPreset()
// uncomment to get integrity="..." attributes on your script & link tags
// requires WebpackEncoreBundle 1.4 or higher
//.enableIntegrityHashes(Encore.isProduction())
// uncomment if you're having problems with a jQuery plugin
//.autoProvidejQuery()
;
module.exports = Encore.getWebpackConfig();
Creiamo i file React
Iniziamo ora a creare i nostri file di React. Ma prima eseguiamo questo comando per compilare i file di React in tempo reale e guardare le modifiche ai file JavaScript:
yarn encore dev --watch
Creeremo questi file all’interno della cartella /assets. I file (semplici file di testo) possono essere già creati vuoti e man mano li andremo a riempire. La struttura delle cartelle e dei files dovrà essere uguale a questa:
Innanzitutto creiamo il file che gestisce il routing, Main.js:
import React from 'react'; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import ProgettoElenco from "./pages/ProgettoElenco" import ProgettoNuovo from "./pages/ProgettoNuovo" import ProgettoModifica from "./pages/ProgettoModifica" import ProgettoMostra from "./pages/ProgettoMostra" function Main() { return ( <Router> <Routes> <Route exact path="/" element={<ProgettoElenco/>} /> <Route path="/nuovo" element={<ProgettoNuovo/>} /> <Route path="/modifica/:id" element={<ProgettoModifica/>} /> <Route path="/mostra/:id" element={<ProgettoMostra/>} /> </Routes> </Router> ); } export default Main; if (document.getElementById('app')) { const rootElement = document.getElementById("app"); const root = createRoot(rootElement); root.render( <StrictMode> <Main /> </StrictMode> ); }
quindi aggiorniamo il file app.js, aggiungendo alla fine questa riga di codice:
require('./Main');
Man mano che effettueremo le modifiche, il Webpack Encore ci avviserà della corretta compilazione dei file JS. Proseguiamo dunque a impostare gli altri file, partendo dal file creato in /components e chiamato Layout.js
import React from 'react'; const Layout =({children}) =>{ return( <div className="container"> {children} </div> ) } export default Layout;
Adesso è il turno dei file posizionati nella cartella /pages, che rappresentano ognuna le singole schermate da far visualizzare nel browser agli utenti:
- ProgettoNuovo.js
- ProgettoModifica.js
- ProgettoElenco.js
- ProgettoMostra.js
/assets/pages/ProgettoNuovo.js
import React, {useState} from 'react'; import { Link } from "react-router-dom"; import Layout from "../components/Layout" import Swal from 'sweetalert2' import axios from 'axios'; function ProjectNuovo() { const [nome, setNome] = useState(''); const [descrizione, setDescrizione] = useState('') const [isSaving, setIsSaving] = useState(false) const handleSave = () => { setIsSaving(true); let formData = new FormData() formData.append("nome", nome) formData.append("descrizione", descrizione) axios.post('/api/progetto', formData) .then(function (response) { Swal.fire({ icon: 'success', title: 'Progetto salvato!', showConfirmButton: false, timer: 1500 }) setIsSaving(false); setNome('') setDescrizione('') }) .catch(function (error) { Swal.fire({ icon: 'error', title: 'Errore!', showConfirmButton: false, timer: 1500 }) setIsSaving(false) }); } return ( <Layout> <div className="container"> <h2 className="text-center mt-5 mb-3">Crea Nuovo Progetto</h2> <div className="card"> <div className="card-header"> <Link className="btn btn-outline-info float-right" to="/">Guarda tutti i progetti </Link> </div> <div className="card-body"> <form> <div className="form-group"> <label htmlFor="nome">Nnme</label> <input onChange={(event)=>{setNome(event.target.value)}} value={nome} type="text" className="form-control" id="nome" name="nome"/> </div> <div className="form-group"> <label htmlFor="descrizione">Descrizione</label> <textarea value={descrizione} onChange={(event)=>{setDescrizione(event.target.value)}} className="form-control" id="descrizione" rows="3" name="descrizione"></textarea> </div> <button disabled={isSaving} onClick={handleSave} type="button" className="btn btn-outline-primary mt-3"> Save Project </button> </form> </div> </div> </div> </Layout> ); } export default ProjectNuovo;
/assets/pages/ProgettoModifica.js
import React, { useState, useEffect } from 'react';
import { Link, useParams } from "react-router-dom";
import Layout from "../components/Layout"
import Swal from 'sweetalert2'
import axios from 'axios';
function ProgettoModifica() {
const [id, setId] = useState(useParams().id)
const [nome, setNome] = useState('');
const [descrizione, setDescrizione] = useState('')
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
axios.get(`/api/progetto/${id}`)
.then(function (response) {
let project = response.data
setNome(project.nome);
setDescrizione(project.descrizione);
})
.catch(function (error) {
Swal.fire({
icon: 'error',
title: 'Errore!',
showConfirmButton: false,
timer: 1500
})
})
}, [])
const handleSave = () => {
setIsSaving(true);
axios.patch(`/api/progetto/${id}`, {
nome: nome,
descrizione: descrizione
})
.then(function (response) {
Swal.fire({
icon: 'success',
title: 'Progetto aggiornato!',
showConfirmButton: false,
timer: 1500
})
setIsSaving(false);
})
.catch(function (error) {
Swal.fire({
icon: 'error',
title: 'Errore!',
showConfirmButton: false,
timer: 1500
})
setIsSaving(false)
});
}
return (
<Layout>
<div className="container">
<h2 className="text-center mt-5 mb-3">Modifica progetto</h2>
<div className="card">
<div className="card-header">
<Link
className="btn btn-outline-info float-right"
to="/">Guarda tutti i progetti
</Link>
</div>
<div className="card-body">
<form>
<div className="form-group">
<label htmlFor="nome">Nome</label>
<input
onChange={(event)=>{setNome(event.target.value)}}
value={nome}
type="text"
className="form-control"
id="nome"
name="nome"/>
</div>
<div className="form-group">
<label htmlFor="descrizione">Description</label>
<textarea
value={descrizione}
onChange={(event)=>{setDescrizione(event.target.value)}}
className="form-control"
id="descrizione"
rows="3"
name="descrizione"></textarea>
</div>
<button disabled={isSaving}
onClick={handleSave}
type="button"
className="btn btn-outline-success mt-3">
Aggiorna progetto
</button>
</form>
</div>
</div>
</div>
</Layout>
);
}
export default ProgettoModifica;
/assets/pages/ProgettoElenco.js
import React,{ useState, useEffect} from 'react';
import { Link } from "react-router-dom";
import Layout from "../components/Layout"
import Swal from 'sweetalert2'
import axios from 'axios';
function ProgettoElenco() {
const [elencoProgetti, setProgettoElenco] = useState([])
useEffect(() => {
fetchProgettoElenco()
}, [])
const fetchProgettoElenco = () => {
axios.get('/api/progetto')
.then(function (response) {
setProgettoElenco(response.data);
})
.catch(function (error) {
console.log(error);
})
}
const handleDelete = (id) => {
Swal.fire({
title: 'Sei sicuro?',
text: "E' un'operazione irreversibile!",
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Si, cancella!'
}).then((result) => {
if (result.isConfirmed) {
axios.delete(`/api/progetto/${id}`)
.then(function (response) {
Swal.fire({
icon: 'success',
title: 'Progetto cancellato!',
showConfirmButton: false,
timer: 1500
})
fetchProgettoElenco()
})
.catch(function (error) {
Swal.fire({
icon: 'error',
title: 'Errore!',
showConfirmButton: false,
timer: 1500
})
});
}
})
}
return (
<Layout>
<div className="container">
<h2 className="text-center mt-5 mb-3">Manager Progetti</h2>
<div className="card">
<div className="card-header">
<Link
className="btn btn-outline-primary"
to="/nuovo">Crea nuovo Progetto
</Link>
</div>
<div className="card-body">
<table className="table table-bordered">
<thead>
<tr>
<th>Nome</th>
<th>Descrizione</th>
<th width="240px">Action</th>
</tr>
</thead>
<tbody>
{elencoProgetti.map((project, key)=>{
return (
<tr key={key}>
<td>{project.nome}</td>
<td>{project.descrizione}</td>
<td>
<Link to={`/mostra/${project.id}`}
className="btn btn-outline-info mx-1">
Mostra
</Link>
<Link to={`/modifica/${project.id}`}
className="btn btn-outline-success mx-1">
Modifica
</Link>
<button onClick={()=>handleDelete(project.id)}
className="btn btn-outline-danger mx-1">
Cancella
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
</Layout>
);
}
export default ProgettoElenco;
/assets/pages/ProgettoMostra.js
import React, {useState, useEffect} from 'react';
import { Link, useParams } from "react-router-dom";
import Layout from "../components/Layout"
import axios from 'axios';
function ProgettoMostra() {
const [id, setId] = useState(useParams().id)
const [project, setProgetto] = useState({nome:'', descrizione:''})
useEffect(() => {
axios.get(`/api/progetto/${id}`)
.then(function (response) {
setProgetto(response.data)
})
.catch(function (error) {
console.log(error);
})
}, [])
return (
<Layout>
<div className="container">
<h2 className="text-center mt-5 mb-3">Mostra Progetto</h2>
<div className="card">
<div className="card-header">
<Link
className="btn btn-outline-info float-right"
to="/"> View All Projects
</Link>
</div>
<div className="card-body">
<b className="text-muted">Nome:</b>
<p>{project.nome}</p>
<b className="text-muted">Descrizione:</b>
<p>{project.descrizione}</p>
</div>
</div>
</div>
</Layout>
);
}
export default ProgettoMostra;
Eseguiamo l’applicazione
Dopo aver completato i passaggi precedenti, possiamo eseguire l’applicazione con il comando seguente:
symfony server:start
Se tutto è stato impostato correttamente, l’app sarà disponibile al seguente URL: http://localhost:8000