Form nei presenter

Nette Forms semplifica notevolmente la creazione e l'elaborazione dei form web. In questo capitolo imparerete a usare i form dentro i presenter.

Se vi interessa usarli in modo completamente autonomo, senza il resto del framework, c'è una guida sull'uso autonomo.

Il primo form

Proviamo a scrivere un semplice form di registrazione. Il suo codice sarà questo:

use Nette\Application\UI\Form;

$form = new Form;
$form->addText('name', 'Nome:');
$form->addPassword('password', 'Password:');
$form->addSubmit('send', 'Registrati');
$form->onSuccess[] = $this->formSucceeded(...);

e nel browser verrà mostrato così:

Un form in un presenter è un oggetto della classe Nette\Application\UI\Form; il suo predecessore Nette\Forms\Form è destinato all'uso autonomo. Vi abbiamo aggiunto i controlli name, password e un pulsante di invio. Infine la riga $form->onSuccess dice che dopo l'invio e la validazione riuscita deve essere chiamato il metodo $this->formSucceeded().

Dal punto di vista del presenter, il form è un normale componente. Viene quindi trattato come un componente e integrato nel presenter con un metodo factory. Avrà questo aspetto:

use Nette;
use Nette\Application\UI\Form;

class HomePresenter extends Nette\Application\UI\Presenter
{
	protected function createComponentRegistrationForm(): Form
	{
		$form = new Form;
		$form->addText('name', 'Nome:');
		$form->addPassword('password', 'Password:');
		$form->addSubmit('send', 'Registrati');
		$form->onSuccess[] = $this->formSucceeded(...);
		return $form;
	}

	private function formSucceeded(Form $form, $data): void
	{
		// qui elaboreremo i dati inviati dal form
		// $data->name contiene il nome
		// $data->password contiene la password
		$this->flashMessage('Vi siete registrati con successo.');
		$this->redirect('Home:');
	}
}

E nel template il form si disegna con il tag {control}:

<h1>Registrazione</h1>

{control registrationForm}

E in sostanza è tutto :-) Abbiamo un form funzionante e perfettamente protetto.

Ora starete pensando che è andata troppo in fretta e vi chiederete come sia possibile che il metodo formSucceeded() venga chiamato e quali parametri riceva. Sì, avete ragione, la cosa merita una spiegazione.

Nette introduce un meccanismo rinfrescante, chiamato stile hollywoodiano. Invece che voi, come sviluppatori, dobbiate chiedere di continuo se è successo qualcosa (“il form è stato inviato?”, “è stato inviato in modo valido?” e “non è stato falsificato?”), dite al framework “quando il form è compilato validamente, chiama questo metodo” e gli lasciate il lavoro successivo. Se programmate in JavaScript conoscete benissimo questo stile di programmazione: scrivete funzioni che vengono chiamate quando si verifica un certo evento. E il linguaggio passa loro gli argomenti appropriati.

È esattamente così che è costruito il codice del presenter qui sopra. L'array $form->onSuccess rappresenta un elenco di callback PHP che Nette chiama nel momento in cui il form viene inviato e compilato correttamente (cioè è valido). Nel ciclo di vita del presenter si tratta di un cosiddetto segnale, quindi vengono chiamate dopo il metodo action* e prima del metodo render*. E a ogni callback passa come primo parametro il form stesso e come secondo i dati inviati, come oggetto ArrayHash (oppure stdClass, oppure una classe personalizzata). Potete omettere il primo parametro se non vi serve l'oggetto form. Il secondo parametro può essere più intelligente, ma ne parliamo più avanti.

L'oggetto $data contiene le proprietà name e password con i dati inseriti dall'utente. Di solito inviamo i dati direttamente a un'ulteriore elaborazione, che può essere per esempio l'inserimento in un database. Durante l'elaborazione può però verificarsi un errore, per esempio se il nome utente è già occupato. In tal caso passiamo l'errore al form con addError() e lo facciamo disegnare di nuovo, insieme al messaggio di errore.

$form->addError('Spiacenti, questo nome utente è già in uso.');

Oltre a onSuccess esiste anche onSubmit: le callback vengono chiamate ogni volta che il form viene inviato, anche se non è compilato correttamente. E anche onError: le callback vengono chiamate solo se l'invio non è valido. Vengono chiamate anche se invalidiamo il form dentro onSuccess con addError().

Dopo aver elaborato il form reindirizziamo a un'altra pagina. Questo impedisce il reinvio indesiderato del form usando il pulsante aggiorna, il pulsante indietro oppure navigando nella cronologia del browser.

Se il form viene inviato via AJAX, di norma invece di reindirizzare ridisegnate uno snippet con il form disegnato di nuovo.

Provate ad aggiungere anche altri controlli.

Accesso ai controlli

Il form è un componente del presenter, nel nostro caso chiamato registrationForm (dal nome del metodo factory createComponentRegistrationForm), quindi in qualsiasi punto del presenter potete accedere al form così:

$form = $this->getComponent('registrationForm');
// sintassi alternativa: $form = $this['registrationForm'];

Anche i singoli controlli del form sono componenti, quindi potete accedervi allo stesso modo:

$input = $form->getComponent('name'); // oppure $input = $form['name'];
$button = $form->getComponent('send'); // oppure $button = $form['send'];

I controlli si rimuovono con unset:

unset($form['name']);

Regole di validazione

Abbiamo usato la parola valido, ma il form non ha ancora alcuna regola di validazione. Rimediamo.

Il nome sarà obbligatorio, quindi lo contrassegniamo con il metodo setRequired(). Il suo argomento è il testo del messaggio di errore mostrato se l'utente non compila il nome. Se l'argomento viene omesso, viene usato il messaggio di errore predefinito.

$form->addText('name', 'Nome:')
	->setRequired('Inserite il vostro nome.');

Provate a inviare il form senza compilare il nome e vedrete comparire un messaggio di errore; il browser o il server lo rifiuteranno finché non compilate il campo.

Allo stesso tempo non potete imbrogliare il sistema inserendo nel campo, per esempio, solo degli spazi. Niente da fare. Nette elimina automaticamente gli spazi iniziali e finali. Provate. È una cosa che dovreste sempre fare con ogni campo a riga singola, ma che spesso si dimentica. Nette la fa automaticamente. (Potete provare a ingannare il form inviando come nome una stringa su più righe. Anche qui Nette non si farà ingannare e gli a capo verranno convertiti in spazi.)

Il form viene sempre validato sul lato server, ma viene generata anche la validazione JavaScript, che gira all'istante e permette all'utente di conoscere l'errore subito, senza dover inviare il form al server. Se ne occupa lo script netteForms.js. Includetelo nel vostro template di layout:

<script src="https://unpkg.com/nette-forms@3"></script>

Se guardate il codice sorgente della pagina con il form, noterete forse che Nette racchiude i controlli obbligatori in elementi con la classe CSS required. Provate ad aggiungere al template il foglio di stile seguente e l'etichetta “Nome” sarà rossa. È un modo elegante di evidenziare i campi obbligatori per gli utenti:

<style>
.required label { color: maroon }
</style>

Aggiungiamo altre regole di validazione con il metodo addRule(). Il primo parametro è la regola, il secondo è di nuovo il testo del messaggio di errore, e può seguire un argomento della regola di validazione. Cosa significa?

Estendiamo il form con un nuovo campo facoltativo “età”, che deve essere un numero intero (addInteger()) e rientrare anche in un intervallo consentito ($form::Range). Qui useremo il terzo parametro del metodo addRule() per passare al validatore l'intervallo richiesto come coppia [min, max]:

$form->addInteger('age', 'Età:')
	->addRule($form::Range, 'L\'età deve essere compresa tra 18 e 120.', [18, 120]);

Se l'utente non compila il campo, le regole di validazione non verranno controllate, perché l'elemento è facoltativo.

Questo apre spazio a un piccolo refactoring. Nel messaggio di errore e nel terzo parametro i numeri sono duplicati, il che non è ideale. Se creassimo form multilingue e il messaggio contenente i numeri venisse tradotto in più lingue, cambiare i valori diventerebbe difficile. Per questo si possono usare i segnaposto %d, che Nette sostituirà con i valori:

	->addRule($form::Range, 'L\'età deve essere compresa tra %d e %d anni.', [18, 120]);

Torniamo al controllo password, rendiamolo obbligatorio e verifichiamo anche la lunghezza minima della password ($form::MinLength), usando di nuovo un segnaposto nel messaggio:

$form->addPassword('password', 'Password:')
	->setRequired('Scegliete una password')
	->addRule($form::MinLength, 'La vostra password deve essere lunga almeno %d caratteri.', 8);

Aggiungiamo al form un altro campo passwordVerify, in cui l'utente inserisce di nuovo la password per conferma. Con le regole di validazione controlliamo che le due password siano uguali ($form::Equal). Come argomento indichiamo un riferimento alla prima password usando le parentesi quadre:

$form->addPassword('passwordVerify', 'Password di nuovo:')
	->setRequired('Inserite di nuovo la password per controllare eventuali errori di battitura')
	->addRule($form::Equal, 'Le password non coincidono.', $form['password'])
	->setOmitted();

Con setOmitted() abbiamo contrassegnato un controllo il cui valore non ci interessa davvero e che esiste solo a scopo di validazione. Il suo valore non viene passato a $data.

Con questo abbiamo un form pienamente funzionante, con validazione sia in PHP sia in JavaScript. Le capacità di validazione di Nette sono molto più ampie: si possono creare condizioni, mostrare o nascondere parti della pagina in base a esse e altro ancora. Imparerete tutto nel capitolo sulla validazione dei form.

Valori predefiniti

Impostiamo comunemente valori predefiniti per i controlli del form:

$form->addEmail('email', 'Email')
	->setDefaultValue($lastUsedEmail);

Spesso è utile impostare i valori predefiniti di tutti i controlli in una volta. Per esempio quando il form serve a modificare dei record. Leggiamo il record dal database e ne impostiamo i valori predefiniti:

// $row = ['name' => 'John', 'age' => '33', /* ... */];
$form->setDefaults($row);

Chiamate setDefaults() dopo aver definito i controlli.

Su un form già inviato setDefaults() non ha effetto: non sovrascrive ciò che l'utente ha compilato, quindi è sicuro chiamarlo incondizionatamente nella factory del form. Se dovete forzare i valori anche dopo l'invio, usate invece setValues().

Disegnare il form

Per impostazione predefinita il form viene disegnato come una tabella. I singoli controlli rispettano le regole di base dell'accessibilità web: tutte le etichette sono scritte come elementi <label> e associate ai rispettivi controlli. Cliccando sull'etichetta il cursore si posiziona automaticamente nel campo del form.

A ogni controllo possiamo impostare attributi HTML qualsiasi. Aggiungiamo per esempio un placeholder:

$form->addInteger('age', 'Età:')
	->setHtmlAttribute('placeholder', 'Inserite l\'età');

I modi di disegnare un form sono davvero tanti, quindi al rendering è dedicato un capitolo a parte.

Mappatura sulle classi

Torniamo al metodo formSucceeded(), che riceve nel secondo parametro $data i dati inviati, come oggetto ArrayHash (o stdClass). Poiché si tratta di una classe generica, simile a stdClass, lavorandoci ci mancano certe comodità, come il completamento automatico delle proprietà negli editor o l'analisi statica del codice. Lo si potrebbe risolvere avendo una classe specifica per ogni form, le cui proprietà rappresentino i singoli controlli. Per esempio:

class RegistrationFormData
{
	public string $name;
	public ?int $age;
	public string $password;
}

In alternativa potete usare un costruttore:

class RegistrationFormData
{
	public function __construct(
		public string $name,
		public ?int $age,
		public string $password,
	) {
	}
}

Le proprietà della classe dei dati possono essere anche enum, e verranno mappate automaticamente.

Come diciamo a Nette di restituire i dati come oggetti di questa classe? Più facile di quanto pensiate. Basta indicare la classe come tipo del parametro $data nel metodo gestore:

public function formSucceeded(Form $form, RegistrationFormData $data): void
{
	// $data è un'istanza di RegistrationFormData
	$name = $data->name;
	// ...
}

Come tipo potete indicare anche array, e allora i dati verranno passati come array.

Allo stesso modo potete usare il metodo getValues(), passando come parametro il nome della classe o un oggetto da riempire:

$data = $form->getValues(RegistrationFormData::class);
$name = $data->name;

Se dovete leggere i valori prima che il form venga validato, di norma dentro un gestore onValidate, usate invece il metodo getUntrustedValues(). Accetta gli stessi parametri di getValues(), ma restituisce i valori inviati senza garantire che abbiano superato la validazione.

Se i form hanno una struttura a più livelli composta da container, create una classe separata per ognuno:

$form = new Form;
$person = $form->addContainer('person');
$person->addText('firstName');
/* ... */

class PersonFormData
{
	public string $firstName;
	public string $lastName;
}

class RegistrationFormData
{
	public PersonFormData $person;
	public ?int $age;
	public string $password;
}

La mappatura deduce allora, dal tipo della proprietà $person, che deve mappare il container sulla classe PersonFormData. Se la proprietà dovesse contenere un array di container, indicate il tipo array e passate la classe da mappare direttamente al container:

$person->setMappedType(PersonFormData::class);

Potete generare una proposta della classe dei dati del form con il metodo Nette\Forms\Blueprint::dataClass($form), che la stampa nella pagina del browser. Poi vi basta selezionare con un clic e copiare il codice nel vostro progetto.

Più pulsanti di invio

Se il form ha più di un pulsante, di norma dobbiamo distinguere quale sia stato premuto. Possiamo creare una funzione gestore separata per ogni pulsante. La impostiamo come gestore dell'evento onClick:

$form->addSubmit('save', 'Salva')
	->onClick[] = $this->saveButtonPressed(...);

$form->addSubmit('delete', 'Elimina')
	->onClick[] = $this->deleteButtonPressed(...);

Un gestore si può passare al pulsante anche direttamente, come terzo argomento del metodo addSubmit().

Questi gestori vengono chiamati solo se il form è compilato validamente (a meno che per il pulsante la validazione non sia disattivata), come per l'evento onSuccess. La differenza è che come primo parametro può essere passato l'oggetto del pulsante di invio invece del form, a seconda del tipo che dichiarate:

private function saveButtonPressed(Nette\Forms\Controls\Button $button, $data)
{
	$form = $button->getForm();
	// ...
}

Quando il form viene inviato premendo il tasto Invio, viene trattato come se fosse stato inviato dal primo pulsante di invio.

Evento onAnchor

Quando costruite un form in un metodo factory (come createComponentRegistrationForm), esso non sa ancora se sia stato inviato né con quali dati. Ci sono però casi in cui abbiamo bisogno di conoscere i valori inviati: magari l'aspetto del form dipende da essi, oppure servono per select box dipendenti e così via.

Potete quindi far chiamare il codice che costruisce il form solo quando esso è “ancorato”, cioè è già collegato al presenter e conosce i propri dati inviati. Collocate questo codice nell'array $onAnchor:

$country = $form->addSelect('country', 'Paese:', $this->model->getCountries());
$city = $form->addSelect('city', 'Città:');

$form->onAnchor[] = function () use ($country, $city) {
	// questa funzione verrà chiamata quando il form conoscerà i dati con cui è stato inviato
	// così potete usare il metodo getValue()
	$val = $country->getValue();
	$city->setItems($val ? $this->model->getCities($val) : []);
};

Protezione dalle vulnerabilità

Nette Framework dà grande importanza alla sicurezza e si preoccupa quindi scrupolosamente della sicurezza dei form. Lo fa in modo completamente trasparente e senza richiedere alcuna configurazione manuale.

Oltre a proteggere i form da attacchi come il Cross-Site Scripting (XSS) e il Cross-Site Request Forgery (CSRF), adotta molte piccole misure di sicurezza a cui non dovete più pensare.

Per esempio filtra dagli input tutti i caratteri di controllo e controlla la validità della codifica UTF-8, garantendo che i dati del form siano sempre puliti. Per i select box e le liste di radio button verifica che gli elementi selezionati fossero davvero tra quelli offerti e che non ci siano state falsificazioni. Abbiamo già detto che, per i campi di testo a riga singola, sostituisce con spazi i caratteri di fine riga che un aggressore potrebbe inviare. Per i campi su più righe normalizza i caratteri di fine riga. E così via.

Nette si occupa al posto vostro di rischi di sicurezza di cui molti programmatori non sospettano nemmeno l'esistenza.

L'attacco CSRF menzionato consiste nell'attirare la vittima su una pagina che, in silenzio, esegue nel browser della vittima una richiesta al server su cui la vittima è connessa. Il server crede allora che la richiesta sia stata fatta volontariamente dalla vittima. Nette rifiuta quindi i form POST inviati da un'origine estranea; anche un sottodominio diverso dello stesso sito conta come estraneo. Se dovete permettere l'invio da un'altra origine, disattivate la protezione con:

$form->allowCrossOrigin(); // ATTENZIONE! Disattiva completamente la protezione!

Questo però disattiva la protezione per qualsiasi origine. Per permetterne solo alcune, disattivate la protezione e verificate voi stessi l'header Origin rispetto a un vostro elenco di origini consentite.

La protezione si basa sull'header Sec-Fetch-Site del browser (Fetch Metadata), che il browser invia automaticamente e che non si può falsificare nemmeno con una vulnerabilità XSS. Per i browser più vecchi, che non li supportano, vale un cookie SameSite di ripiego, che un'applicazione Nette imposta automaticamente. L'articolo The browser finally solves CSRF lo descrive in dettaglio.

La protezione precedente, basata su un token di autorizzazione salvato nella sessione e attivata con $form->addProtection(), non serve più ed è deprecata dalla versione 3.3.

Usare uno stesso form in più presenter

Se dovete usare lo stesso form in più presenter, consigliamo di creare una factory, che poi iniettate nei presenter. Un posto adatto per una classe del genere è, per esempio, la directory app/Forms.

La classe factory potrebbe avere questo aspetto:

use Nette\Application\UI\Form;

class SignInFormFactory
{
	public function create(): Form
	{
		$form = new Form;
		$form->addText('name', 'Nome:');
		$form->addSubmit('send', 'Accedi');
		return $form;
	}
}

Chiediamo alla classe di produrre il form nel metodo factory del componente, dentro il presenter:

public function __construct(
	private SignInFormFactory $formFactory,
) {
}

protected function createComponentSignInForm(): Form
{
	$form = $this->formFactory->create();
	// possiamo modificare il form, qui per esempio cambiamo l'etichetta del pulsante
	$form['send']->setCaption('Continua');
	$form->onSuccess[] = $this->signInFormSuceeded(...); // e aggiungiamo il gestore
	return $form;
}

Il gestore dell'elaborazione del form può essere fornito anche dalla factory stessa:

use Nette\Application\UI\Form;

class SignInFormFactory
{
	public function create(): Form
	{
		$form = new Form;
		$form->addText('name', 'Nome:');
		$form->addSubmit('send', 'Accedi');
		$form->onSuccess[] = function (Form $form, $data): void {
			// qui elaboriamo il nostro form inviato
		};
		return $form;
	}
}

Abbiamo così visto una rapida introduzione ai form in Nette. Provate a guardare nella directory degli esempi della distribuzione per altre idee.

versione: 4.x