Formulář ve formuláři? HTML to zakazuje, Nette 3.3 ne

před 3 hodinami od David Grudl  

Že <form> nesmí být uvnitř jiného <form>, ví každý, kdo psal složitější stránku. A pokud jste někdy potřebovali jeden formulář vnořit do druhého, víte, že čisté řešení dlouho neexistovalo. Nette Forms 3.3 to konečně mění: {form detached} obejde letité omezení HTML jediným slovem v šabloně.

Proč nejde formulář ve formuláři

Představte si pokladnu e-shopu: velký formulář s doručovací adresou, dopravou a platbou. A uvnitř malý formulář na slevový kód, který cenu přepočítá sám, aniž by odeslal celou objednávku. Dva nezávislé formuláře, jeden vizuálně uvnitř druhého.

První nápad je napsat to doslova:

<form n:name="orderForm">
	{input address}

	{* tohle prohlížeč zahodí *}
	<form n:name="couponForm">
		{input code}
		{input apply}
	</form>

	{input send}
</form>

Jenže HTML vnořování <form> do <form> nepovoluje a prohlížeč se nezeptá. Při parsování prostě zahodí vnitřní otevírací značku, takže žádný couponForm v dokumentu nevznikne. Navíc první </form> ukončí ten vnější formulář předčasně, takže i tlačítko send za ním propadne ven. Nedostanete chybu, dostanete těžko dohledatelný bug.

Atribut form mění pravidla hry

HTML5 má pro tuhle situaci elegantní východisko: atribut form na ovládacím prvku. Libovolný <input> nebo <button> s form="nejakeId" patří k formuláři <form id="nejakeId"> bez ohledu na to, kde v dokumentu fyzicky leží.

Formulář a jeho prvky tak můžeme od sebe oddělit:

<form id="newsletter" action="/subscribe" method="post"></form>

<!-- klidně o kus dál, v úplně jiné části stránky -->
<input name="email" form="newsletter">
<button form="newsletter">Odebírat</button>

Značka <form> je prázdná, prvky se na ni odkazují přes id. Prohlížeč je při odeslání spojí dohromady. Žádný hack, je to součást specifikace s podporou napříč prohlížeči.

{form detached}: zanoření na jeden řádek

Přesně tohle teď Nette dělá za vás. Formuláře zůstanou dva nezávislé, každý se svou továrničkou:

protected function createComponentOrderForm(): Form
{
	$form = new Form;
	$form->addText('address', 'Adresa:');
	// ... doprava, platba
	$form->addSubmit('send', 'Odeslat objednávku');
	return $form;
}

protected function createComponentCouponForm(): Form
{
	$form = new Form;
	$form->addText('code', 'Slevový kód:');
	$form->addSubmit('apply', 'Uplatnit', function (Form $form, $data) {
		$this->applyCoupon($data->code);
	});
	return $form;
}

Drobnost na okraj: addSubmit() v 3.3 přijímá obsluhu kliknutí rovnou při vytvoření, takže ji nemusíte věšet zvlášť přes $button->onClick[].

V šabloně je trik v tom, že slovem detached označíme ten vnější formulář:

{form detached orderForm}
	{input address}

	{* samostatný formulář vizuálně uvnitř objednávky *}
	{form couponForm}
		{input code}
		{input apply}
	{/form}

	{input send}
{/form}

Proč vnější? Detached formulář se vzdá své skutečné značky <form> a své prvky si připojí přes atribut form. Tím se uvnitř uvolní místo pro plnohodnotný vnitřní <form>, který už není zanořený do ničeho. Výsledné HTML proto vypadá takhle:

<form id="frm-orderForm" method="post" action="..."></form>
<input type="text" name="address" form="frm-orderForm">

<form id="frm-couponForm" method="post" action="...">
	<input type="text" name="code">
	<input type="submit" name="apply">
</form>

<input type="submit" name="send" form="frm-orderForm">

Vnější orderForm je teď prázdná značka na začátku a jeho prvky se k ní hlásí atributem form="frm-orderForm". Vnitřní couponForm je naopak úplně běžný formulář, který už nikdo neobaluje, takže projde prohlížečem bez problémů. Kliknutí na „Uplatnit“ odešle jen kupón, kliknutí na „Odeslat objednávku“ celou objednávku.

Id se odvozuje z názvu komponenty, tedy frm- plus název (frm-orderForm). Když potřebujete konkrétní hodnotu, nastavte si ji přes $form->getElementPrototype()->id.

Nemusíte přitom dlouho přemýšlet, který formulář takto označit. Vnější formulář musí být detached vždy, vnitřní může zůstat běžný. A protože označit detached i vnitřní ničemu nevadí, nejjednodušší je dát ho prostě oběma. Totéž platí pro hlubší zanoření: ať vrstvíte formuláře jakkoli, klidně dejte detached všem.

Stejný princip pohání POST odkazy

Stejný trik s prázdnou <form> a atributem form možná znáte z jiného doporučení: odkazy spouštějící akce. Akce měnící stav serveru, jako mazání, nepatří pod GET odkaz, a tak se v layoutu nechá jeden prázdný formulář a tlačítka se na něj zavěsí:

<form method="post" id="postForm"></form>

<table>
	<tr n:foreach="$posts as $post">
		<td>{$post->title}</td>
		<td>
			<button class="btn btn-link" form="postForm"
				formaction="{link delete $post->id}">smazat</button>
		</td>
	</tr>
</table>

Akci pak zabezpečíte proti spuštění z cizího webu atributem #[Requires] (u signálů je kontrola původu automatická):

use Nette\Application\Attributes\Requires;

#[Requires(methods: 'POST', sameOrigin: true)]
public function actionDelete(int $id): void
{
	$this->facade->deletePost($id);
	$this->redirect('default');
}

{form detached} je vlastně tahle ruční technika zabalená do Nette formuláře. Jednou si ji napíšete ručně pro pár tlačítek, podruhé ji necháte vygenerovat pro celý formulář.

{form scope}: vykreslete jen kus formuláře

Kromě detached přibyla v šabloně ještě druhá značka, {form scope}. Říká prvkům {input} a {label} uvnitř, ke kterému formuláři nebo kontejneru patří, ale sama žádnou značku <form> nevykreslí. Hodí se ve dvou situacích.

První: vykreslujete políčka nějakého kontejneru ve formuláři. Aby se u každého nemusela opakovat celá cesta jako {input address-street}, přepne {form scope} kontext na kontejner a názvy uvnitř se pak píší relativně:

<form n:name="signForm">
	{form scope address}
		{input street}
		{input city}
	{/form}
</form>

Druhá: přes AJAX překreslujete jen kousek už vykresleného formuláře, třeba jediné políčko ve snippetu. Novou značku <form> tu vykreslit nesmíte, ta na stránce už je, ale {input} pořád musí vědět, kam patří:

{snippet captcha}
	{form scope signForm}
		{input captcha}
	{/form}
{/snippet}

Nejspíš vám to připomíná dřívější {formContext} a {formContainer}. A přesně ty scope nahrazuje. Každá z nich uměla jednu z těch dvou věcí a lidi si je pletli; jedno klíčové slovo, které se chová podle kontextu, je k zapamatování příjemnější. Obě staré značky přitom dál fungují.

Závěr

Co HTML zakázalo, vrací atribut form zadními vrátky. Nette Forms 3.3 z toho dělá jediné slovo v šabloně, detached, a stejný princip, který drží POST odkazy bezpečné, máte teď i pro plnohodnotné zanořené formuláře. Stačí upgradnout na Forms 3.3 a Latte 3.1. Novinek je ve verzi víc, třeba modernizovaná ochrana proti CSRF, která místo tokenu kontroluje hlavičku Sec-Fetch-Site.

David Grudl Vyvíjí webové aplikace již od roku 1999 a specializuje se na umělou inteligenci. Je autorem Nette Framework a knihoven jako Texy!, Tracy či Latte. Moderuje pořad Tech Guys a píše na Uměligence o AI novinkách. Jeho blog La Trine byl nominován na cenu Magnesia Litera. Věnuje se vzdělávání v oblasti AI a je pragmatický optimista.