Formuláře#
Jádro je app/Core/Forms/ — vlastní třída Form nad Bootstrap formuláři, továrna
a několik vlastních prvků. Formulář se nikdy nepíše přímo v presenteru ani
v komponentě: patří do FormTrait vedle nich.
| Kde formulář žije | Cesta |
|---|---|
| komponenta | XxxComponent/Traits/FormTrait.php |
| presenter | Presenters/Traits/Forms/XxxFormTrait.php |
| admin presenter | totéž, ale kostru dělá createForm() — viz Admin presenter |
Kanonická kostra#
protected function createComponentForm(): Form
{
$form = FormFactory::create();
$form->setMethod('POST');
$form->addProtection();
$form->useDomainTranslator($this->translator, $this->translatorDomain);
$form->setHtmlAttribute('role', 'form');
$form->setHtmlAttribute('class', 'ajax');
$form->addText('name', 'name.label')
->setRequired('name.required')
->setHtmlAttribute('placeholder', 'name.placeholder')
->setOption('description', 'name.description'); // jen klíč!
$form->addSubmit('send', 'send');
$form->onSuccess[] = [$this, 'onSuccess'];
return $form;
}
| Řádek | Proč tam je |
|---|---|
FormFactory::create() |
vrátí App\Core\Forms\Form, ne Nette Form — jinak přijdete o všechny prvky níž |
setMethod('POST') |
vždy explicitně |
addProtection() |
CSRF token, povinně |
useDomainTranslator(…) |
labely jsou pak holé klíče ('name.label'), doménu přidá translator |
role('form') |
přístupnost |
Starý zápis zůstává funkční — jen se nepíše do nového kódu
setTranslator() + setTranslatorDomain() + $t = $form->getTranslateFnc()
funguje dál a v hotových formulářích se neupravuje. Kanonicky se píšou nové.
getTranslateFnc() se řídí typem translatoru: u useDomainTranslator() vrací klíč
relativní (doménu přidá translator), u setTranslator() + setTranslatorDomain()
absolutní (doména.klíč). Ručně složený absolutní klíč u prefixovaného translatoru
dostane doménu dvakrát a vykreslí se syrový klíč.
Co Form umí navíc oproti Nette#
| Metoda | Co udělá |
|---|---|
addEditor($name, $caption) |
textarea s data-editor="full" → plný TinyMCE. Pro administrátorský obsah (stránky, články, e-maily, PDF šablony) |
addSimpleEditor($name, $caption) |
textarea s data-editor="simple" → osekaný editor: tučné, kurzíva, barvy, seznamy, tabulka. Žádné odkazy, obrázky ani zdrojový kód |
addRawEditor($name, $caption) |
data-editor="raw" → toolbar jako full, ale editor nic nezahazuje (<button>, <svg>, <form>, vlastní elementy a atributy). Jen sekce stránek a HTML kód reklamy v administraci |
addVatRate($name, $label) |
sazba DPH v procentech 0–100, desetinná čárka i tečka; textové pole, ne type=number; tvar a rozsah hlídá server (VatRate::normalize()) |
addBoundedInteger($name, $label, $min, $max) |
celé číslo s mezí vynucenou serverem (počty na stránku, dny, hodiny); min/max v HTML je jen nápověda |
addCheckbox($name, $caption) |
plochý checkbox bez bootstrapového obalu (FlatCheckboxInput) |
addCheckboxList($name, $label, $items) |
zaškrtávací seznam, který v popisku volby zachová Html |
addImageUpload($name, $caption, …) |
pole pro jeden obrázek + satelitní pole <name>CurrentId a <name>Delete |
useDomainTranslator($translator, $domain) |
translator s prefixem domény |
Ke každému add*Editor a addImageUpload existuje statická varianta
(addEditorControls(), addImageUploadControls()), která bere kontejner — používá se
uvnitř řádku Multiplieru, kde je jen Nette\Forms\Container bez našich metod.
Do uživatelského obsahu patří addSimpleEditor(), ne addEditor()
Plný editor pustí odkazy, obrázky i vložený zdrojový kód. Kde píše obsah
uživatel (formuláře na frontu, parametry inzerátů), musí být osekaná varianta —
a její whitelist musí zůstat shodný se serverovou sanitizací
(EditorHtmlSanitizer::ALLOWED_HTML). Omezení jen v JavaScriptu se obejde
odesláním požadavku mimo prohlížeč.
addRawEditor() jen do administrace za RBAC
Obsah full ani raw se na serveru nesanitizuje a na front jde přes |noescape.
raw navíc pustí <form>, <svg> i neznámé atributy. Použitý na frontu nebo kdekoli,
kam píše návštěvník, je to uložený XSS.
Koncept plného editoru je per uživatel
Plný editor v administraci ukládá rozepsaný obsah do localStorage (Soubor → Obnovit
poslední koncept, 48 h). Výchozí klíč TinyMCE bere jen cestu, query, hash a id editoru, takže
po odhlášení A a přihlášení B ve stejném prohlížeči by B načetl koncept účtu A. Klíč proto
nese scope z EditorAutosaveScope::forUser() — HMAC id uživatele s %jwt.secret%, ne holé
id — který layout administrace vypíše do data-editor-autosave-scope a rich-editor.js
vloží do autosave_prefix. Vlastní instance TinyMCE mimo rich-editor.js musí prefix
převzít stejně.
Výška editoru#
Výchozí výšku editoru určuje atribut na vráceném poli. Platí pro addEditor(),
addRawEditor() i addSimpleEditor() a zpracovává ho rich-editor.js:
$form->addEditor('content', 'Obsah')
->setHtmlAttribute('data-editor-hsize', 'lg');
$form->addSimpleEditor('perex', 'Perex')
->setHtmlAttribute('data-editor-hsize', 'auto');
data-editor-hsize |
Výchozí výška | Kdy |
|---|---|---|
auto |
podle obsahu, 160–900 px | krátké texty proměnlivé délky: perex, krátká zpráva |
sm |
200 px | krátký text, např. krátký text člena týmu |
md |
420 px | popisy, doplňující informace, info kategorie |
lg |
700 px | hlavní obsah obrazovky, např. obsah stránky |
xl |
1000 px | obsah článku, tělo e-mailové šablony |
xxl |
1300 px | obsah sekce stránky, newsletter |
U velikostí sm–xxl si uživatel editor dál roztáhne úchytem v rohu. auto používá plugin
autoresize, editor roste s obsahem a nad 900 px se v něm roluje.
Přesnou výšku v pixelech dá ->setHtmlAttribute('data-editor-height', 320) a má přednost
před data-editor-hsize. Bez obou atributů platí výchozí výška presetu editoru.
auto vypíná ruční roztažení
S pluginem autoresize TinyMCE úchyt v rohu vypne. Pro hlavní obsah proto auto
nepoužívejte, uživatel by si dlouhý text nemohl zvětšit.
Soubory a odkazy v plném editoru#
Plný editor v administraci má dva podklady na serveru. Bydlí jako signály na admin
BasePresenter, protože editor může být na libovolné stránce a odkaz {link editorUpload!}
musí mířit na právě běžící presenter. Signál zároveň dostane od Nette 3.2+ ochranu stejného
původu, vlastní token není potřeba.
| Tlačítko | Trait a signály | Co vrací |
|---|---|---|
správce souborů (rich-editor.js) |
EditorUploadTrait: editorUpload!, editorFiles!, editorCreateFolder!, editorRename!, editorDelete! |
JSON {folder, parent, breadcrumb[], folders[], files[]}; cesty relativní ke kořeni editoru, serverová cesta nikdy |
„Odkaz na stránku“ (tinymce-page-link.js) |
EditorLinkTrait: editorPages! |
JSON {"pages": [{id, label, url}]}, hotové adresy z routy stránky |
Soubory z editoru padají do vlastní složky uploads/editorUploads/ a nejsou to Assety: nemají
záznam v databázi ani varianty. Klient posílá cesty relativní k té složce a prefix přidává až
editorPath(), který zároveň odmítne segment ... Zápis i čtení jde přes Api
FileSystemManager. Celé uploads/ spravuje obrazovka Systém → Soubory.
Postup: editor má nabízet odkaz na další druh stránky
- Ověř, že routa cíle vystačí s parametrem
slug(jinak odkaz nejde sestavit). - Přidej cíl do
EditorLinkTrait::EDITOR_PAGE_TARGETS(malými písmeny, např.'front:system:page:homepage'). - Otevři dialog odkazu v editoru a zkontroluj, že nová stránka má funkční adresu.
Cíle s dalšími parametry do nabídky nepatří
Profil uživatele chce id a username, detail inzerátu id a name. Redaktor je nemá
odkud vzít, takže by odkaz vedl na rozbitou adresu.
Postup: přidávám formulář do komponenty#
FormTraitvedle komponenty, metodacreateComponentForm(): Form.- Kostru podle vzoru výš.
- Delší formulář rozděl do metod
addXxxFields(Form $form); naplnění selectů doprepare*(). onSuccesss typovanou signaturou:
public function onSuccess(Form $form, ArrayHash $values): void
{
$entity = new ContactMessage();
$entity->setEmail($values['email']);
$result = $this->contactMessageManager->save($entity);
if ($result->isSuccess()) {
$this->flashMessage('result.success', 'alert-success');
$form->setValues([], true);
} else {
$this->flashMessage('result.error', 'alert-danger');
}
if ($this->presenter?->isAjax()) {
$this->redrawControl();
}
}
- Flash zprávu pošli holým klíčem — šablona komponenty má translator už
nastavený na doménu komponenty a
{_$flash->message}doménu přidá sama. - Překladové klíče doplň zároveň, ve všech jazycích — viz Překlady.
onSuccess(Form $form, mixed $values) shodí KAŽDÝ submit
Nette z druhého parametru odvozuje, jaký tvar hodnot vyrobit
($this->getValues(Helpers::getSingleType($params[1]))). U mixed z toho vznikne
new ReflectionClass('mixed') → ReflectionException: Class "mixed" does not exist.
Fallback tam žádný není. Netypovaný parametr je taky špatně — dostanete $this
nebo tlačítko místo hodnot. Vždycky ArrayHash $values.
Přesně tohle znemožnilo spustit stavový automat objednávky z administrace, a nikdo
si toho měsíce nevšiml: harness volal Api endpoint přímo a formulář nesubmitoval.
Kontrola: grep -rn 'onSuccess\[\] = function' app | grep mixed.
Postup: vykresluji formulář v Latte#
{form editForm}
<div class="form-group">
{label name /}
{input name}
<span class="errors" n:ifcontent>{inputError name}</span>
<small class="form-text text-muted" n:ifcontent>
{$form['name']->getOption('description') ? _($form['name']->getOption('description')) : ''}
</small>
</div>
<label>
{input active:}
{label active}{_$form['active']->caption}{/label}
</label>
{/form}
| Prvek | Zápis |
|---|---|
| chyba pole | inline {inputError name} u toho pole |
| checkbox | {input active:} — dvojtečka je povinná |
| popisek checkboxu | {label active}{_$form['active']->caption}{/label}, nikdy {label active /} |
| description | v PHP jen klíč, v Latte se překládá přes _() |
{control $form errors} v ručně vykresleném formuláři nepoužívejte
Souhrnný výpis nahoře znamená, že uživatel vidí „Vyplňte název“, ale nevidí které pole to je — u formuláře s jazykovými záložkami je to chybné pole často schované v neaktivní záložce.
Checkbox si vykresluje vlastní popisek
{label active /} (se samouzavřením) vypíše popisek podruhé. Proto ta varianta
s {_…->caption} uvnitř {label}.
n:if na poli formuláře SMAŽE jeho hodnotu
Nevykreslené pole se neodešle a uložení ho zapíše jako prázdné. Platí to i pro
addHidden(). Chcete-li pole podmínit, řešte to při sestavení formuláře
(nepřidat ho), ne v šabloně. Data zmizí bez chybové hlášky.
Postup: opakované skupiny polí (Multiplier)#
- Předvyplnění dělej přes
setDefaults()na kontejneru, ne nastavením hodnot jednotlivým prvkům. - Na už odeslaném formuláři
addCopy(null, $defaults)nefunguje — uvnitř voláContainer::setDefaults(), a ten na odeslaném formuláři nastaví jen zakázaná pole. Kopie přijde prázdná. Správně:
$copy = $outerMultiplier->addCopy(); // bez defaults
$copy->setValues($row); // přímé kontrolky — funguje vždy
- Vnořený Multiplier na odeslaném formuláři ignoruje programově dodané hodnoty —
staví kopie z odeslaných dat, ne z hodnot. Nový kontejner v odeslaných datech není,
takže vnitřní kopie vzniknou nulové. Musíte je přidat explicitně
(
getComponent($i, false) ?? $nested->addCopy($i)a paksetValues()). - Řádek potřebuje vlastní třídu na obalu — bez ní se prvky rozjedou v rozvržení.
onValidate běží i pro tlačítko s prázdným validačním rozsahem
Rozsah omezí jen kontrolky, obslužné funkce onValidate formuláře Nette volá vždy.
Celoformulářová kontrola (třeba „aspoň jedna kategorie“) by bez brány na tlačítko hlásila
chybu i na „Přidat řádek“. Začni ji proto testem $form->isSubmitted() na update
a save. Chybu dej na kontrolku, která je vykreslená vždy (u Multiplieru tlačítko
multiplier_creator), a pod ní ji vypiš: HiddenField::addError() chybu přesměruje na
formulář a ta se v administraci ukáže jen jako flash. Vzor:
AdvertCategoryFieldsTrait::guardCategoriesSelected() (Inzerce).
setValidationScope([]) vyprázdní getValues()
Kontrolky mimo validační rozsah submitteru Nette označí jako omitted
a getValues() je přeskočí. U Multiplieru to vypadá obzvlášť zákeřně:
getValues()->items vrátí [{}] — řádek existuje, ale je prázdný.
Pomocná AJAX tlačítka (recalculate, „přepočítat“, překreslení Pickeru) mají
prázdný rozsah schválně, aby neblokovala na required, když admin teprve
skládá formulář. Jejich obsluha proto musí číst kontrolky napřímo —
$multiplier->getContainers() plus $control->getValue(). Kvůli tomuhle
v e-shopu nikdy nefungovalo „Přepočítat“ ani předvyplnění dopravy: chybová hláška
se vykreslovala do snippetu, který se nepřekresloval, takže selhání bylo němé.
Obsluha AJAX tlačítka musí překreslit i snippet se svou chybovou hláškou
Jinak tlačítko „nic nedělá“ — a je jedno, jestli chyba nastala, nebo ne.
Zakázané pole se po AJAX akci vykreslí prázdné
disabled pole prohlížeč neodešle a výchozí hodnoty se nastavují jen na GET. Po
jakékoli AJAX akci ve formuláři (přidání řádku, duplikace) se proto zakázaný select
nebo checkbox vykreslí bez hodnoty. Obnov ho ze uložených dat přímo v šabloně, dřív
než se z hodnot něco počítá, a odeslané hodnoty editovatelných polí nepřepisuj. Vzor:
restoreShipperTariffGenerationDisplay() (tarif dopravce, volá ji {do} na začátku
každé karty generace). Ze stejného důvodu kopíruje „Duplikovat“ generaci z uložených dat,
ne z POSTu.
Řádky s nahráním souboru (klientský Multiplier)#
Řádek s <input type=file> nesmí jít přes naja add/remove: každé překreslení snippetu vybraný
soubor zahodí a prohlížeč ho z bezpečnostních důvodů neumí doplnit zpátky. Takový Multiplier
přidává a odebírá řádky v prohlížeči skript client-multiplier.js; serverový Multiplier
zůstává jen kontejner, který na odeslání vyrobí container pro každý poslaný index. Mezery
v indexech po odebraném řádku nevadí, pořadí řádků je pořadí v DOM.
| Atribut | Na čem |
|---|---|
data-multiplier |
kořen jedné instance; jeho přítomnost zapíná klientský režim |
data-multiplier-items |
kontejner řádků (chybí-li, je to kořen) |
data-multiplier-template |
<template> s prototypem řádku, v name zástupný __INDEX__ |
data-multiplier-add |
tlačítko Přidat |
data-multiplier-remove |
tlačítko Odebrat uvnitř řádku |
Postup: Multiplier s nahráním souboru
- Serverový
MultiplierbezaddCreateButton()aaddRemoveButton(), minimální počet kopií 0. - V šabloně obal výpis do
data-multiplier, stávající řádky vykresli z formuláře a prototyp nového řádku dej do<template data-multiplier-template>se jményimages[__INDEX__][…]. - Inicializaci prvků v novém řádku (náhled obrázku apod.) navěs na událost
multiplier:row-added, kterou skript vystřelí na novém řádku a nechá ji probublat (detail.rowje nový řádek). - Do Api pošli řádky v
$metapod klíčem, který manager čte vafterSave(); pošli ho i prázdný. Galerie blogu:$meta['images']bez klíče = kolekci nechat být, prázdné pole = vyprázdnit.
Nové pole řádku patří na dvě místa
Do PHP továrny řádku i do <template> v šabloně. Bez druhého ho mají jen řádky
vykreslené serverem; nově přidaný řádek pole nemá a jeho hodnota se neodešle.
Vzory: app/UI/Admin/Blog/Templates/Sections/Gallery/asset_multiplier.latte
(+ GalleryFormTrait::addImageMultiplier()), tým v katalogu firem.
Seznam a detail (master-detail)#
Dlouhou kolekci opakovaných položek (bloky obsahu, tým, přednosti) jde v administraci
zobrazit jako seznam vlevo a detail vybrané položky vpravo. Zapíná se atributem
data-master-detail="<klíč>" na kontejneru řádků (.address-rows), řádky musí být
[data-sort-item]. Obsluhuje ho master-detail.js; bez JavaScriptu zůstanou karty pod sebou.
<div class="address-rows" data-sortable data-sortable-item="[data-sort-item]"
data-master-detail="company-team"
data-md-label="input[name$='[name]']" data-md-sub="input[name*='[role_']"
data-md-position="{_'//admin.general.masterDetail.position'}"
data-md-untitled="{_'//admin.general.masterDetail.untitled'}"
data-md-empty="{_'//admin.general.masterDetail.empty'}"
data-md-language="{_'//admin.general.masterDetail.language'}">
{foreach … as $row}
<div class="address-row multiplier-card" data-sort-item>…</div>
{/foreach}
</div>
| Atribut kontejneru | Co určuje | Výchozí |
|---|---|---|
data-md-label |
selektor pole s názvem položky v seznamu | první [data-sortable-label] |
data-md-sub |
selektor pole s doplňujícím údajem pod názvem | žádný |
data-md-public |
selektor checkboxu zveřejnění (značka v seznamu) | input[type="checkbox"][name$="[public]"] |
data-md-title |
nadpis seznamu | <legend> fieldsetu |
data-md-position |
text pozice v detailu, %index% a %count% |
%index% / %count% |
data-md-untitled |
název položky bez vyplněného názvu, %index% |
#%index% |
data-md-empty |
text prázdné kolekce | prázdný |
data-md-language |
popisek přepínače jazyka | prázdný |
Náhled v seznamu se bere z obrázku .image-upload__img v řádku. Má-li řádek víc jazykových
bloků (.multiplier-card__lang-block), dostane přepínač jazyka a ukazuje jeden blok; volba
platí pro všechny řádky téhož klíče. Řaditelná kolekce (data-sortable) má v seznamu úchyt
.md__handle (SortableJS), přetažení přesune skutečný řádek formuláře.
Skript řádky jen skrývá, nic neklonuje. Vybraný soubor, TinyMCE, pravidla Nette i pořadí pro POST proto zůstávají ve formuláři. Výběr i jazyk přežijí AJAX překreslení serverového multiplieru (pamatují se podle klíče), po přidání řádku se vybere nový.
Chyba ve skrytém řádku se otevře sama
form-error-reveal.js před zobrazením chybného pole pošle na pole událost md:reveal
a master-detail.js přepne na řádek i jazyk, kde chyba je. Vlastní kód, který
odkrývá pole, má poslat totéž: el.dispatchEvent(new CustomEvent('md:reveal', { bubbles: true })).
Přesun řádku s editorem#
Prohlížeč přesunutý <iframe> načte znovu prázdný. Po přesunu řádku s TinyMCE v DOM
(šipky, přetažení) je editor vidět prázdný a psaní do něj se ztrácí, i když instance obsah
stále drží. rich-editor.js proto exportuje refreshMovedEditors(root): vezme obsah
z instance, zruší ji a nad textareou založí novou. Volají ho sortable.js
a master-detail.js.
Vlastní kód, který přesouvá řádky s editorem, musí zavolat refreshMovedEditors()
import { refreshMovedEditors } from './rich-editor.js';
list.insertBefore(row, row.previousElementSibling);
refreshMovedEditors(row);
Bez toho se po uložení odešle buď starý obsah, nebo nic, a v editoru to vypadá jako prázdné pole.
Stav formuláře v liště akcí#
Lišta akcí editačního formuláře ({control adminFormActions $backlink}, šablona
FormActions.latte) nese data-form-state a vlevo ukazuje „Bez neuložených změn“ nebo
„Neuložené změny“. Řídí ji form-state.js. Za změnu se počítá:
| Změna | Jak se pozná |
|---|---|
| psaní a změna pole | input / change, i u pole mimo element formuláře vázaného atributem form |
| obsah TinyMCE | události editoru (editor sedí v iframu) |
| přidání, odebrání a přesun položek | MutationObserver nad [data-sortable], [data-multiplier-items] a .file-upload-list, až po úvodní inicializaci |
| AJAX překreslení snippetu uvnitř formuláře | index.js volá markFormDirty(form) |
Odeslání formuláře stav vynuluje. S neuloženými změnami se prohlížeč při odchodu ze stránky
zeptá (beforeunload).
Změna formuláře z vlastního AJAXu
Když váš kód mění formulář jinak než polem (přestaví seznam, vymění snippet mimo
běžnou obsluhu naja), zavolejte markFormDirty(form) z form-state.js. Jinak lišta
ukáže „Bez neuložených změn“ a odchod ze stránky změny tiše zahodí.
Serverové brány polí#
Atributy maxlength, min, max ani type=date nic nezaručí: požadavek odeslaný mimo
prohlížeč je obejde. Hydrátor délky sloupců nevynucuje a #[ORM\Column(length: …)] je jen
deklarace schématu. Co se nezachytí ve formuláři, skončí podle sql_mode databáze buď obecnou
chybou celého uložení (striktní režim), nebo se tiše ořízne či uloží jako nula (nestriktní). Proto mají formuláře tři sdílené
brány v app/Core/Forms/:
| Brána | Na co | Hlavní metody |
|---|---|---|
ColumnGate |
délka a tvar podle sloupce entity (mez čte reflexí ColumnLimits, žádné opsané číslo) |
length() / lengths(), taxId() / taxIds(), hexColor(), assetPath(), decimal() / decimals() |
StrictDateInput |
hodnoty type=datetime-local (Y-m-d\TH:i) a type=date |
datetimeLocalRule(s)(), dateRule(s)(), parseDatetimeLocal(), parseDate(), keepSeconds() |
MultiplierLimits |
počet řádků Multiplieru | guard() + konstanty mezí |
ColumnGate::lengths($form, Order::class, ['sellerName', 'sellerStreet', 'sellerCity']);
ColumnGate::taxId($form->getComponent('customerRegNo'), Order::class, 'customerRegNo');
ColumnGate::decimal($form->getComponent('price'), ProductSet::class, 'price');
StrictDateInput::datetimeLocalRule($form->getComponent('published'));
Co brány řeší, na co by ruční pravidlo zapomnělo:
length()přidámaxlengthi serverovéMAX_LENGTH(chytí i vložení ze schránky).decimal()odmítne exponenciální zápis (1e1000→INF→ ve striktním režimu chyba DB, v nestriktním0.00) a rozsah měří po zaokrouhlení nascalesloupce, tedy na tom, co se zapíše (9999.995by se jinak uložilo jako10000.00). Záporná čísla jen sallowNegative: true. Volitelný parametrnegativeMessagedá záporné hodnotě vlastní hlášku; bez něj dostane hlášku rozsahu („Zadejte číslo menší než …“), která u záporné ceny mate. Pravidlo se přidá před pravidlo rozsahu a Nette se zastaví na první chybě, takže vyhraje. Na frontu je pro cenu připravenáColumnGate::FRONT_NEGATIVE_PRICE_MESSAGE(vzor: formulář inzerátu,AdvertFormComponent/Traits/FormFieldsTrait.php). Hodnotu po pravidleForm::FLOAT(float) brána,NumericRuleiColumnValuepřevedou přesDecimal::fromFloat()do prostého zápisu, takže malé platné číslo (0.00001, v PHP1.0E-5) nedostane hlášku rozsahu. Stejně ji převáděj i ty, než ji pošleš dál (sklad bere jen prostý zápis).taxId()hlídá IČ/DIČ volnou maskouTaxIdHelper::PATTERN(písmeno nebo číslice, dál písmena, číslice, mezera,.,/,-), ne českou „8 číslic“: e-shop prodává do zahraničí a přísná maska by odmítla platné cizí DIČ. Délku měří podle sloupce předané entity. Registr plátců DPH neověřuje.hexColor()hlídá tvar#rrggbb: sloupce barev mají 7 znaků a delší zápis by se oříznul na jinou barvu, ne na chybu.StrictDateInputneexistující datum odmítne (new DateTime('2026-02-30')z něj tiše udělá 2. března) a nepřijme relativní text typunext monday.
Celý admin formulář najednou: guardColumns()#
Hlídat pole po poli znamenalo, že nové pole se zapomnělo. Admin presenter proto má
guardColumns(): formulář řekne jen, do kterých entit jeho pole zapisují, a brána
(ColumnGate::guard()) ohlídá každé textové a číselné pole podle skutečného sloupce
v databázi.
$this->guardColumns($form, [
'' => [Gallery::class, GalleryText::class], // pole samotného formuláře
'images' => [Asset::class, AssetText::class], // každý řádek Multiplieru `images`
], except: ['password']); // pole, jehož hodnota se před uložením mění
- Klíč
''je formulář sám, název Multiplieru hlídá všechny jeho řádky (i ty vzniklé z POST), klíč končící*bere pole formuláře s tou předponou ('quickPrice*':quickPriceTitle_2→ vlastnosttitle). - Pole se k vlastnosti přiřadí jménem, u vícejazyčných bez přípony jazyka
(
name_2→namepřekladové entity). Entity se prohledají v uvedeném pořadí. - Meze:
varchar(n)/char(n)nejvýš n znaků,TEXTpodle velikosti v bajtech, celá čísla v rozsahu sloupce (seunsigned),decimal(p,s)po zaokrouhlení nascale. Meze čte ze skutečného schématu (ColumnSchema), protože mapování a databáze se místy liší. - Běží v
onValidate;maxlengthdoplní při vykreslení. Přeskočí pole, které už má vlastní chybu, zakázané a skryté pole a pole z$except. Prázdné nepovinné pole projde.
Výsledek: přetékající hodnota je chyba u pole. Bez brány skončí chybou databáze („Data too long“, „Out of range“), administrace ukáže obecnou hlášku a EntityManager zůstane do konce požadavku zavřený.
guardColumns() nenahrazuje tvarové brány
Hlídá délku a rozsah. IČ/DIČ, barvu, cestu k souboru nebo zápornou cenu dál hlídejte
taxId(), hexColor(), assetPath() a decimal().
Prázdný jazyk vícejazyčného formuláře: TranslationInput#
Formulářové traity zakládají překladovou entitu i pro jazyk, ve kterém záznam zatím nemá text. Bez kontroly by každé uložení přidalo prázdný neaktivní překlad za každý takový jazyk a uložení beze změny by měnilo databázi. Proto:
$translation = $news->getTranslation($langId);
if (!$translation instanceof NewsText) {
if (TranslationInput::isBlank($values, $langId, ['active', 'perex', 'text', 'title'])) {
continue; // jazyk nikdo nevyplnil: žádný řádek
}
$translation = new NewsText();
// … jazyk, rodič, addTranslation()
}
// existující i nový překlad se naplní hodnotami
- Jazyk, ve kterém je všechno prázdné (i zaškrtávátko
active), řádek nedostane. Prázdné jenull,false, prázdný řetězec nebo jen mezery, prázdné pole a upload bez souboru;0je vyplněná hodnota. - Stačí vyplnit cokoli (nebo zaškrtnout
active) a překlad vznikne jako dřív. - Existující překlad se aktualizuje vždy, kontrola platí jen pro zakládání.
Do $fields patří všechna pole jazyka bez přípony _<langId>, včetně active. Vzor:
NewsFormTrait, IntroFormTrait (Blog), BillingLineFormTrait (Invoicer).
Postup: zabezpečit nové pole, které se zapisuje do sloupce#
- Pojmenuj kontrolku stejně jako vlastnost entity — pak stačí dávková varianta
(
lengths(),decimals()). Neexistující kontrolku přeskočí, překlep ve vlastnosti spadne hned vColumnLimits. - Na frontu předej hlášku
ColumnGate::FRONT_*_MESSAGE. - Povinnost řeš
setRequired(); brány prázdné nepovinné pole pouštějí. - U
datetime-localpři uložení zavolejStrictDateInput::keepSeconds(StrictDateInput::parseDatetimeLocal($v), $entita->getX()). - Ověř odesláním přetékající hodnoty: chyba musí být u pole a nic se neuloží.
Na frontu jiná konstanta hlášky
Hlášky jsou globální klíče s // (//admin.general.columnGate.*), aby obešly doménu
prefixovaného translatoru a nemusely se kopírovat do každého modulu. Kořen překladů se ale
na frontu a v administraci liší: admin klíč se na frontovém formuláři nenačte a chybějící
klíč shodí stránku.
Bez keepSeconds() uložení beze změny ořízne sekundy
Žádné datetime-local pole nemá step, prohlížeč posílá čas bez sekund. Uložení
formuláře beze změny by z 23:59:59 udělalo 23:59:00.
StrictDateInput nenahrazuje obecné parsování
AdminFormHelpersTrait::parseDateTime() dál čte texty mimo kontrakt date /
datetime-local (třeba snímky v tvaru Y-m-d H:i:s). Striktní parser patří jen na pole,
kde formát garantuje HTML type.
Meze řádků Multiplieru#
Multiplier bez třetího argumentu ($maxCopies) zhmotní libovolně velký strom z požadavku;
s ním přebytečné řádky tiše ořízne, což je u dokladů ztráta dat bez chyby. Proto dvě
vrstvy: MultiplierLimits::guard() spočítá řádky v surových datech požadavku a přidá chybu
formuláře dřív, než se cokoli uloží, a $maxCopies je druhá pojistka.
$form->onAnchor[] = function (Form $form): void {
MultiplierLimits::guard($form, $form->httpDataValue('items'), MultiplierLimits::INVOICE_ITEMS);
};
$multiplier = new Multiplier($factory, 0, MultiplierLimits::INVOICE_ITEMS);
Meze jsou produktové rozhodnutí, proto žijí jen jako konstanty v MultiplierLimits.
guard() patří do onAnchor
getHttpData() potřebuje ukotvený formulář, a ten se ukotví až po návratu
createComponent…(). Volání přímo v továrně nedostane data a neohlídá nic.
Chyba formuláře nemusí být vidět
guard() volá addError() na formulář, a ne každá admin šablona vykresluje
$form->getErrors(). Kde je nevykresluje, ukaž hlášku i flashem
(MultiplierLimits::message()), jinak se nic neuloží a uživatel nezjistí proč.
Ochrany proti zfalšovanému a opakovanému odeslání#
Prohlížeč nabídne jen hodnoty, které formulář vykreslil, ale požadavek se dá poslat i mimo
něj, nebo ze stránky otevřené před smazáním záznamu. Tři další sdílené pojistky v
app/Core/Forms/ hlídají to, co brány polí nepokryjí.
| Pojistka | Před čím chrání | Kde ji zapojit |
|---|---|---|
ChoiceGuard |
hodnota výběru (select, seznam zaškrtávátek), která v nabídce není | ChoiceGuard::rejectUnknown($control) |
PickedRecordGuard |
id záznamu z Pickeru (skryté pole), který neexistuje | PickedRecordGuard::guard($form, $name, $labelKey, $exists) |
FormSubmissionGuard |
dvojklik nebo dva souběžné POSTy formuláře „přidat" | automaticky u akcí add*, jinak guardFormSubmitOnce($form) |
ChoiceGuard: neznámá hodnota výběru#
Nette u neznámého klíče vrátí null. Nepovinný select pak přeskočí všechna pravidla a uložení
bere neznámou hodnotu jako „bez volby" a vymaže uloženou vazbu (uložený produkt pak nemá
výrobce). U seznamu zaškrtávátek s rolemi smazal zfalšovaný klíč všechny role uživatele.
rejectUnknown() přečte surovou odeslanou hodnotu v onValidate a při neznámé odmítne celé
odeslání, ostatní zaškrtnuté hodnoty se neuloží. Prázdná volba zůstává povolená; chce-li pole
povinné, použij setRequired().
ChoiceGuard::rejectUnknown($form->getComponent('producerId'));
ChoiceGuard::rejectUnknown($form->getComponent('roles'));
PickedRecordGuard: id z Pickeru#
Picker zapisuje vybrané id do skrytého pole. To může být zastaralé (záznam smazal jiný
administrátor v jiné záložce) nebo zfalšované. Bez kontroly uložení spadlo na cizím klíči,
administrátor dostal obecnou chybu a EntityManager zůstal do konce požadavku zavřený.
Kontrola běží v onValidate, tedy před jakýmkoli zápisem.
PickedRecordGuard::guard(
$form,
'advertId', // název skrytého pole
'form.fields.advert.label', // klíč popisku (relativní, jako v šabloně)
fn (int $id): bool => $this->advertManager->find($id)->exists(),
required: true, // prázdný výběr je také chyba
);
Chybu skrytého pole Nette posílá na úroveň formuláře, proto hláška obsahuje název pole z popisku
a administrace ji ukáže jako flash. Kontroluje se jen odeslání, které validuje celý formulář
(save, update). Tlačítko s zúženým rozsahem validace (řádek Multiplieru, obnovení Pickeru)
musí fungovat i s prázdným nebo zastaralým výběrem.
FormSubmissionGuard: jednorázový token#
Formulář, který zakládá záznam, nese skryté pole submissionToken. První odeslání ho
zabere zápisem řádku do tabulky cms_system_form_submissions (primární klíč zajistí, že to
projde jen jednou i při souběhu požadavků; relace v Redisu bez zámku to nezajistí). Přidání je
automatické pro:
- formuláře akcí
addaadd<Něco>(createForm()v admin presenteru), - formuláře komponent přes
AdminComponentFormTrait, - formuláře postavené jinde (import produktů, dobropisy, inventura) volají
$this->guardFormSubmitOnce($form)samy.
Editační formuláře chráněné nejsou: opakované odeslání jen uloží stejné hodnoty znovu.
| Situace | Co se stane |
|---|---|
| první odeslání | token se zabere, uloží se, ke tokenu se zapíše id záznamu |
| uložení selhalo | token se uvolní, opravený formulář jde odeslat znovu |
| druhé odeslání téhož tokenu | neuloží se, administrátor je přesměrován na záznam z prvního odeslání s upozorněním |
| druhé odeslání, zatímco první ještě běží | několik sekund se čeká na výsledek, jinak návrat na předchozí stránku s varováním |
| chybějící nebo poškozený token | neuloží se, formulář dostane nový token (typicky formulář otevřený před nasazením) |
Uložení obalíš do runSubmitOnce($form, $backKey, fn (): bool => …). Vrací true (uloženo),
false (neuloženo) nebo null (už uloženo dřív). Před přesměrováním z kroku nastav
$this->savedEntityId, ať má kam odkázat případné druhé odeslání.
Klientská část je jen úspora: submit-once.js zablokuje druhý klik u formulářů s
data-submit-once a označí tlačítka jako zaneprázdněná. Autoritou je server.
Tlačítko se v submit nesmí zakázat hned
Prohlížeč skládá data formuláře až po události submit. Zakázané tlačítko do požadavku
nepřidá, Nette pak nepozná stisknuté save / update a nic neuloží. submit-once.js
proto zakazuje v další úloze (setTimeout).
Selhání databáze se řeší podle toho, jestli mohl první požadavek uložit:
| Selhání | Co se stane |
|---|---|
| chybí tabulka tokenů (migrace ještě neproběhla) | odeslání projde bez ochrany a chyba se zaloguje; žádný token ještě existovat nemůže |
| jiná chyba databáze při zabrání tokenu | SubmissionClaim::Unknown: neuloží se nic, formulář zůstane vyplněný a správce dostane hlášku admin.system.form.submission.unknown; tentýž token jde odeslat znovu |
| token už je zabraný, ale jeho stav nejde přečíst (i při čekání) | také Unknown: první uložení mohlo doběhnout, druhé by záznam zdvojilo |
| úklid starých tokenů | jen se zaloguje, o zabrání rozhoduje vlastní INSERT |
Řádky starší než 24 hodin se mažou při dalším zabrání; DELETE podmínku stáří opakuje, takže
nesmaže token, který mezitím znovu zabral jiný požadavek.
Potvrzení destruktivní akce#
Smazání v mřížce potvrzuje modální okno a odesílá příznak _confirmed (DataGrid::CONFIRM_FIELD).
Server bez příznaku nesmaže nic a vykreslí potvrzovací stránku (DeleteConfirmation.latte),
takže ochrana platí i bez JavaScriptu. Skript confirm-guard.js se načítá blokujícím
<script> v <head>, aby klik na tlačítko s data-grid-confirm před připraveností modálu
neodeslal formulář bez potvrzení. Klik zapamatuje a zopakuje po připravenosti. Vlastní tlačítko
s potvrzením označ data-grid-confirm="Text otázky".
Množství bez zbytečných desetinných míst#
Databáze vrací množství s plným počtem míst: doklad fakturace decimal(8,2) jako 2.00,
sklad decimal(10,3) jako 1.500. Pravidla zobrazení drží jediná třída Quantity: celé číslo
bez desetin (2), desetinné jen s potřebnými místy (1,5), oddělovač podle lokalizace (čeština
a slovenština čárka, angličtina tečka). Hodnotu zpracuje jako řetězec, bez floatu, takže
0.001 neztratí přesnost.
| Kde | Čím |
|---|---|
| Latte | filtr {$qty\|quantity}; \|quantity:true dá strojový tvar s tečkou pro value a data atributy |
| mřížka administrace | QuantityRenderer (CSV export ve strojovém tvaru) |
| PHP (e-mail skládaný v manageru) | Quantity::format($qty, Quantity::decimalSeparatorOf($localization)) |
| formuláře a PDF fakturace | QuantityFormat::quantity() přes InvoicePdfGenerator::formatQuantity(): tenký obal nad Quantity, který nechá null jako null a předem zaokrouhlí na 2 místa sloupce |
Každý výstup množství veď přes ni, jinak se na jednom místě objeví 2.00 a na jiném 2.
Pole, které se po založení nesmí měnit#
Klíč, na který se váže kód nebo seed (code role, key sekce stránek), se zadává jen při
založení. Na úpravě ho formulář nesmí převzít z požadavku, protože skryté nebo jen „šedé“ pole
klient podvrhne. Spolehlivé jsou dva vzory:
| Vzor | Co dělá Nette | Příklad |
|---|---|---|
| prvek na úpravě vůbec nezaložit | o poli neví, hodnotu z POST nikdy nenačte; v šabloně hodnotu vypiš jako text | PageSectionFormTrait::addKeyField() |
prvek setDisabled() |
hodnotu z POST ignoruje a prvek vynechá z getValues(); zobrazí výchozí hodnotu |
UserRoleFormTrait (code) |
V obou případech uložení čte klíč z $values jen při založení a na úpravě nechá hodnotu
entity načtené z databáze.
Skryté pole s klíčem + kontrola v onSuccess nestačí
Hodnotu skrytého pole pošle klient libovolnou, a addError() nad skrytým polem se ukáže
jen jako chyba formuláře. Přejmenovaný klíč rozbije šablony a seedy, které ho volají napevno.
Na zakázaném prvku nečti $values->code
Po setDisabled() klíč v $values chybí. Kód, který by ho na úpravě zapsal do entity,
původní hodnotu nedostane: bez ?? padne na nedefinované vlastnosti, s ?? zapíše
náhradní hodnotu místo klíče z databáze.
Postup: nahrávání souborů#
Sdílený UploaderDropzone nahraje soubory předem AJAX signálem do dočasné složky
klíčované tokenem; finální submit je odtud přečte podle skrytého pole dropzoneToken
a skrytých položek uploaderItems[…].
- Token vlož do formuláře jako skryté pole.
- Na POST překreslení token NEGENERUJ znovu — přečti ho z odeslaných dat
a nastav přes
setToken($posted).prepareTempDir(forceNew: true)by vyrobil novou prázdnou složku. - Novou složku zakládej jen na čerstvém GET (v komponentě se to pozná podle
getSignal() === null).
Co jde do Api. Formulář neposílá obsah souborů, ale odkaz na dočasný soubor:
položku {name, path}, kde path je soubor v dočasné složce dropzone. Přečte ho jediné
místo, App\Core\Security\Upload\UploadedItem, a to po jednom souboru až těsně před
zpracováním (dřív šly všechny soubory najednou jako base64 a 60 až 80 MB fotek přerostlo
memory_limit). path se přijme jen tehdy, když:
- běží interní volání přes bridge. Stav drží
App\Core\Bridge\InternalCallContext, nastavuje ho jediněApiBridge::internalCall()a na RBAC nezávisí; - cesta po
realpath()leží uvnitř kořene dropzone (DropzoneTempRoot, tedy<appTempDir>/dropzone), jde o běžný soubor a ne symlink, a v cestě není...
Externí klient dál posílá base64 v data a klíč path se z externího požadavku
zahazuje už na hranici (SaveTrait::stripExternalUploadPaths()). Prázdná položka (bez
path i bez data) se zahodí a uložení kvůli ní neselže.
Zmenšení v prohlížeči. UploaderDropzone zmenšuje JPEG a PNG s delší stranou přes
2560 px před nahráním (atributy data-dropzone-resize-max a data-dropzone-resize-quality
čte dropzone.js); jiné formáty a přílohy se nemění. Hodnotu a kvalitu JPEG nastavíš
setClientResize($maxSide, $quality). Uploader, jehož bajty se měnit nesmí (přílohy,
důkazní fotky reklamace, import produktů), volej disableClientResize(). Skript
komponenty je v každém tématu vlastní kopie, po změně ho rozešli
příkazem bin/theme-js-sync.php.
Přípony místo MIME. Textové formáty (XML, CSV, JSON) prohlížeč nebo systém často pošle
jako text/plain či application/octet-stream, takže MIME kontrola by je odmítla. Uploader
pro ně dostane configure(…, allowedExtensions: ['xml', 'csv', 'json']): zadaný seznam přípon
je rozhodující a MIME kontrola se pro ten upload přeskočí, nepřidává se navíc. Bez seznamu
platí jen MIME (obrázky, video). Vzor: ProductImportAddFormTrait.
Úklid dočasné složky. getUploadedFiles() vrací jen odkazy, soubory čte Api až při
zpracování. cleanup() proto volej až po dokončeném uložení; smazaná složka uprostřed
volání Api znamená záznam bez souborů.
Kvóty a odmítnuté soubory. Rozpracovaná nahrávání omezují nastavení
upload.dropzone.maxPendingForms (výchozí 20) a upload.dropzone.maxSessionMb
(výchozí 300). Obrázek či přílohu, kterou Api neuloží, manager zapíše do
saveInfo.rejectedUploads s kódem důvodu (UploadRejection):
| Kód | Kdy |
|---|---|
type |
formát se tu nepřijímá (přípona, obsah, SVG, video bez hlavičky) |
dimensions |
obrázek má moc pixelů nebo se nevejde do paměti |
size |
soubor je větší než limit záznamu: max_size aktivního parametru typu ParameterType::IMAGE_UPLOADER, jinak nastavení modulu maxImageSize, jinak 50 MB (ImageUploadSizeLimit) |
unreadable |
soubor nejde přečíst nebo dekódovat |
saveFailed |
prošel kontrolami, ale nepodařilo se ho zapsat (disk, DB) |
Uložení neselže, přijaté soubory se uloží a administrace z odmítnutých složí varování.
Limit velikosti hlídá i Api (AssetProcessingTrait, manager vrátí limit z
getAssetMaxBytes(); výchozí null = bez limitu, vlastní mají inzeráty a firmy), takže ho
neobejde ani přímé volání Api mimo formulář.
prepareTempDir() složku na disku nezakládá, jen vyhradí cestu v session; fyzicky vznikne až
při prvním nahrání (handleUpload()), takže pouhé otevření formuláře nenechá prázdnou složku.
Tokeny se drží v pořadí založení a po překročení maxPendingForms se ten nejstarší smaže
i se soubory.
Moc otevřených formulářů smaže nejstarší nahrávání
Kdo v jedné session otevře víc formulářů s nahráváním, než dovoluje
upload.dropzone.maxPendingForms, přijde o soubory nahrané v tom nejstarším. Odeslání toho
formuláře pak uloží záznam bez nich. Limit zvyšuj v nastavení, ne v kódu.
Bez převzetí tokenu zmizí rozpracované nahrávání při první validační chybě
Scénář: uživatel nahraje obrázek → odešle formulář → validace selže na něčem jiném
→ snippet s galerií se překreslí → serverové HTML zná jen soubory z databáze, ne
ty v dočasné složce → skryté položky uploaderItems zmizí. Uživatel chybu opraví,
odešle znovu, a záznam se uloží bez obrázku — přestože soubor pořád leží
v dočasné složce. Šťastná cesta bez chyby funguje, takže se to odhalí až při
ladění, kdy validace padá často.
Uložení nahradí celý seznam příloh
Nenačte-li se pole s nahráváním, uloží se prázdný seznam a přílohy zmizí. Je to tatáž mechanika jako u kolekcí — Hydrátory, oddíl o ukládání objektového grafu.
Postup: formulář se odeslal, ale nic se neuložilo#
Od nejlevnějšího:
- Přišel flash? Když ne, podívej se, jestli se nepřesměrovává přes
redirectUrl()— ten flash zahodí a akce vypadá, že neproběhla. - Které tlačítko formulář odeslalo? V administraci ukládají jen
saveaupdate; vlastní submit proběhne, ale neuloží. - Má obsluha
ArrayHash $values? Viz varování omixedvýš. - Nemá tlačítko
setValidationScope([])? Pak jsou hodnoty prázdné. - Vykresluje šablona všechna pole? Nevykreslené pole se uloží jako prázdné.
- Vrátil manager
isSuccess()? Neúspěch bez flash zprávy je němý. - Teprve pak koukej do databáze.
Formulářová vrstva potřebuje aspoň jeden test, který formulář SKUTEČNĚ odešle
Sestavení formuláře ani volání Api endpointu pod ním tuhle třídu chyb nezachytí — obojí projde, i když formulář nejde odeslat. Chytí to jen POST nebo živý proklik prohlížečem.
Po změně struktury formuláře načtěte stránku dvakrát
První načtení ještě jede podle staré podoby z mezipaměti.
Dynamické parametry#
Vedle ručně psaných formulářů existuje systém parametrů
(app/Core/Form/Parameters/): pole se skládá podle konfigurace v databázi, ne podle
kódu. Používá ho inzerce, katalog firem i e-shop — každá kategorie má jinou sadu polí
a ta se dá měnit z administrace bez zásahu do kódu.
Chování v administraci popisuje Kategorie a parametry.
HTML parametru s editorem se čistí v Api, ne ve formuláři
Parametr s useEditor detail vypisuje přes |noescape. Inzerce a katalog firem ho proto
čistí v beforeSave() Api AdvertManager (AdvertParameterEditorHtmlTrait, po hydrataci,
před flush), kudy jde front, administrace i externí Advert:save. Čištění jen ve
formuláři by externí klient obešel a uložil <p onmouseover=…>. Nový modul s editorovými
parametry musí volat purifyEditorParameterRels() stejně. Hodnoty bez editoru se nečistí:
šablony je vypisují escapované.
Typy parametrů a jejich handlery#
Každý typ (ParameterType, celé číslo) obsluhuje jeden bezstavový handler
extends AbstractParameterHandler. Mapu typ → handler drží ParameterHandlerRegistry
(shared() pro výpis a filtry z URL, createDefault() pro formulář, kde si ji smí rozšířit).
Ve formuláři se bere getType(), ve filtru getSearchType() — jeden parametr tak může mít
v obou místech jiný handler. Handler pokrývá celý životní cyklus:
| Fáze | Metody (výchozí implementace = jedno skalární pole) |
|---|---|
| stavba pole | buildField() (povinná), setupOptions(), resolveDefaultValue(), applyDefault() |
| předvyplnění z entity | applyEntityValue($form, $key, $value, $force) |
| hodnota k uložení | extractValue(), extractRows(), isPersistable() |
| formulář ↔ URL | toUrlParams(), fromUrlParams() |
| URL → filtr dotazu | toFilters($parameterId, $urlParams, $matchAll) |
| výpis uložené hodnoty | formatValue() → řetězec, objekt pro vlastní šablonu (např. OpeningHours), nebo null = řádek vynechat |
| odznaky aktivního filtru | getActiveValueLabels() |
Pole se do formuláře skládá přes ParameterFieldBuilder (front i admin):
init() → build() → applyEntityDefaults() (editace) → po odeslání extractValues().
Řádky specifikace pro detail staví ParameterDisplayBuilder.
| Konvence | Detail |
|---|---|
| klíče v URL | p[{id}]; rozsahy p[{id}_from], p[{id}_to]; vzdálenost od adresy navíc p[{id}_d] (km) a p[{id}_r] (region) |
| číselný klíč | PHP z '12' v klíči pole udělá 12 — mapy indexované hodnotou (optionCounts) mají typ klíče array-key, hledá se přes (string) $id |
| rozsahová vazba | value = od, value2 = do (jedna vazba). LIMITRANGE_SLIDER hledá „celý rozsah inzerátu uvnitř hledaného“: od ≥ hledané od a do ≤ hledané do, meze se bindují jako čísla (sloupec je text). RANGE a RANGE_SLIDER naopak hledají průnik (ParameterRelRangeFilter: uložené [value, value2 ?? value] se překrývá s hledaným), jeden filtr na obě meze, aby platily na téže vazbě; viz Filtry |
| výpisový text překladu | s %value% / %value2% je to celá věta, bez nich přípona za hodnotou; plurál podle %count% |
| počty u voleb a meze posuvníku | ParameterFormContext::$optionCounts a $rangeBounds plní LoadingTrait modulu — dnes jen e-shop. Prázdné = pole bez počtů, rozsah jako dvě čísla. Volba s nulou dostane třídu is-empty (zašedne) |
Postup: přidávám nový typ parametru#
- Konstanta v
ParameterTypea název vParameterType::TYPE_NAMES— odtud bere administrace nabídku typů pole i filtru. - Handler
extends AbstractParameterHandlersgetTypes()abuildField(); přepiš jen fáze, kde se typ liší od jednoho skalárního pole. - Registrace v
ParameterHandlerRegistry::createDefault(). - Potřebuje-li vlastní vykreslení, nastav kontrolce option
widgeta doplň větev doapp/UI/Front/Base/Components/ParameterFormComponent/Templates/Default/Style_1.latte(formulář),Style_Filter.latte(filtr) a do admin_form.lattemodulu. - Vrací-li
formatValue()objekt, namapuj typ na šablonu vInitializationTraitkomponentyAdvertParameterListComponent(inzerce, katalog). - Proklikni: založit parametr v kategorii → uložit inzerát → editace ukáže hodnotu → filtr z URL filtruje → detail ji vypíše.
Neregistrovaný typ zmizí bez chyby
Formulář i filtrační box typ bez handleru přeskočí (registry->get() vrátí null).
Pole se nevykreslí, nejde do URL ani do filtru a nikdo si toho nevšimne.
Pole z více kontrolek musí přepsat toUrlParams() i fromUrlParams()
Výchozí verze čte $values[$key], jenže rozsah má jen {key}_from a {key}_to. Vrátí
prázdno, do URL nejde nic a filtr nefiltruje — výpis vrací stejný počet položek pro
jakoukoli mez. Přípony musí sedět na extractBaseParameterIds() v ParameterTrait
presenteru modulu, který je regulárem odřezává (e-shop zná jen _from|_to).
setDefaultValue() na odeslaném formuláři nic neudělá
Pole, která se objeví až po AJAX překreslení kategorií, by na editaci zůstala prázdná.
Proto applyEntityDefaultsForNewFields($form, $rawPost) plní setValue()em ($force) jen
pole, jejichž klíč není v odeslaném POST. Force na pole, které v POST je, přepíše
rozepsanou hodnotu uživatele hodnotou z databáze.
Prázdný překlad se přeskakuje jako chybějící
Popisky hledají první neprázdný text (i '' a mezery se přeskočí, ?? by nestačilo):
jinak by překlad s prázdným názvem vyhrál nad češtinou a volba by byla bez popisku.
Ve filtru se jiný jazyk nepoužije vůbec — nepřeložený parametr nebo volba se skryje.
Ve formuláři zůstane s popiskem z jiného jazyka, aby uložení nesmazalo hodnotu.
<select> dostane popisky jako text
getOptionItemsForField(..., html: false): <option> vnořené značky neumí a vypsal by je.
Zaškrtávátka a přepínače Html popisek s počtem vykreslí (proto addCheckboxList() zachovává
Html). Odznaky aktivního filtru berou popisek bez počtu.
Skrytá hodnota, která potřebuje chybu u pole
REGION_PICKER drží id regionu v TextInput s type=hidden, ne v addHidden():
HiddenField::addError() posílá chybu na formulář a šablony formulářové chyby
nevykreslují. V administraci inzerce a katalogu se SELECT_REGION místo selectu vykresluje
jako Picker — formulář přidá do registru SelectRegionPickerHandler.
Komponenta ParameterForm#
Front formulář i filtrační box nad parametry dědí abstraktní
app/UI/Front/Base/Components/ParameterFormComponent/ParameterForm.php. Potomci jsou
AdvertForm a AdvertFilterBox v inzerci a katalogu firem a ProductFilterBox v e-shopu.
Odznaky, počty a mapa uploaderů jsou na sdílené bázi, takže je dostanou všichni naráz.
| Potomek dodá | Povinné | K čemu |
|---|---|---|
loadParameters(), loadParameterGroups() |
ano | data z managerů modulu |
buildContext() |
ano | položky speciálních selectů (měna, region, kategorie katalogu) |
getMode() |
ne, výchozí formulář | MODE_FORM (formulář) nebo MODE_SEARCH (filtr) |
processSubmit() |
ne | uložení entity, flash, přesměrování; běží před callbacky $onSuccess přidanými zvenku, ty pak vidí uložený stav |
getEntityValues() |
ne | předvyplnění z entity při editaci |
beforeSubmitButton() |
ne | vlastní pole před tlačítkem |
buildRegistry() |
ne | vlastní handlery typů |
Co báze dává šablonám:
| Metoda | Kde se volá | Co vrací |
|---|---|---|
renderActiveFilters() |
šablona výpisu: {control frontBazaarAdvertFilterBox:activeFilters} |
odznaky aktivních filtrů nad výpisem; bez aktivního filtru nevykreslí nic, ani obal |
getActiveFilters() |
Style_ActiveFilters.latte |
jeden záznam na každou zrušitelnou hodnotu („Sony“ a „Canon“ jsou dva odznaky); remove je p bez té hodnoty, null = odkaz na výpis bez filtru |
getGroupChosenCount($group) |
Style_Filter.latte |
počet zvolených voleb skupiny pro odznak u sbaleného nadpisu; vícehodnotové pole po položkách, tlačítka a skrytá pole se nepočítají |
getUploaderControlMap() |
Style_1.latte |
parameter_<id> → uploader_<id> pro parametry IMAGE_UPLOADER a FILE_UPLOADER; Latte u nich vykreslí dropzone |
Odznaky i počty se skládají z parametrů požadavku a z už načtených entit parametrů, bez dalšího dotazu do databáze.
Pole REGION_PICKER obsluhuje RegionSuggestHostTrait (používají ho oba AdvertForm a oba
AdvertFilterBox). Našeptávač je Multiplier podle názvu kontrolky (regionSuggest-parameter_34),
protože signál suggest! si instanci vyrobí sám, bez vykreslení formuláře. Nastavení proto
vychází jen z módu: formulář přijme jen obec a předvyplní city, zip a country, filtr
přijme kraj, okres i obec a nic nepředvyplňuje. Hostitel musí mít $regionSuggestFactory
a $regionManager.
Potomek s vlastním uploaderem musí vyrobit komponentu uploader_<id>
getUploaderControlMap() předpokládá, že pro každý uploaderový parametr existuje
podřízená komponenta pojmenovaná uploaderName(). Potomek bez uploaderů přepíše
getUploaderParameterIds() na [], jinak šablona hledá komponentu, která neexistuje.
Správa parametrů v administraci#
Obrazovky parametrů a skupin parametrů jsou v inzerci, katalogu firem i e-shopu jeden
sdílený kód v app/Core/Base/Admin/Parameters/. Modul dodává jen tenký list:
| Sdílené | Co modul dodá |
|---|---|
AbstractParameterPresenter, AbstractParameterGroupPresenter |
$translatorDomain, gettery parameterManager(), parameterGroupManager(), parameterRuleManager(), parameterEntityClass(), parameterTextEntityClass() |
AbstractParameterRuleManager (pravidla raw DBAL) |
ParameterTableMap s prefixem cms_mod_<modul> a cacheTagClasses() |
AbstractOptionRules, AbstractRuleEditor |
leaf komponenty a jejich továrny v modulu |
Sdílený kód nesmí importovat modulovou entitu, proto entity vytváří přes class-string
gettery. Modul bez pravidel (e-shop) vrátí ze supportsRules() false a šablona celé UI
pravidel skryje.
Postup: přidávám správu parametrů do dalšího modulu#
- Presenter
extends AbstractParameterPresenter(a skupinyAbstractParameterGroupPresenter) s$translatorDomaina gettery z tabulky. ParameterRuleManager extends AbstractParameterRuleManagers mapou tabulek modulu.cacheTagClasses()vrátí FQCN Api entitParameteraParameterRulejako řetězce (vzor Bazaar). Metoda je abstraktní záměrně: bez ní se modul nespustí.- Leaf komponenty
OptionRulesaRuleEditors továrnami, které injektují modulové managery. - Prokliknout založení parametru, volby na editaci a pravidlo; ověřit na frontu formulář a filtr hned po uložení.
Leaf komponenta potřebuje vlastní konstruktor s modulovými managery
search: v config/common.neon zaregistruje každou konkrétní třídu pod app/UI jako
službu. Konstruktor zděděný z AbstractOptionRules / AbstractRuleEditor je typovaný
na sdílené předky (AbstractParameterRuleManager, Manager) a ty mají implementaci
v každém modulu. Kompilace kontejneru pak spadne na „Multiple services found“ a nenaběhne
žádná stránka. Leaf proto přebírá konstruktor s konkrétními managery svého modulu a volá
parent::__construct(); vzor app/UI/Admin/Bazaar/Components/OptionRulesComponent/OptionRules.php.
Pravidla se zapisují mimo most, cache zneplatní jen cacheTagClasses()
Bez správných tagů se formulář inzerátu a filtr výpisu chovají podle starého pravidla až do vypršení TTL služby parametrů (hodina).
Cíl pravidla nese parameter_id i option_id
Front (DependentSelectTrait) pravidlo bez obou klíčů ztratí. addTarget() je plní
napevno; ruční SQL nad tabulkou cílů musí taky.
Co pravidlo udělá, určuje cíl, ne sloupec action (RulesTrait, DependentSelectTrait):
| Cíl | Účinek |
|---|---|
optionId vyplněné |
odhalí volby výběru (závislý select) |
parameterId nebo parameterGroupId, optionId prázdné |
zobrazí/skryje pole nebo skupinu, případně ho podmíněně zpovinní |
Pravidlová vrstva zná jen rozhraní, ne entity modulu
RulesTrait a DependentSelectTrait pracují s ParameterRuleInterface
(a rozhraními podmínek a cílů v app/Core/Base/Shared/Shared/Models/Entities/Interfaces/).
Typová kontrola na entitu konkrétního modulu by pravidla jiných modulů tiše vyřadila:
sada pravidel zůstane prázdná a skrývání, povinnost i závislé selecty se přeskočí bez
chyby. Nový modul s pravidly proto implementuje tato rozhraní.
„Ve výchozím filtru“ vyžaduje typ vyhledávacího pole
Parametr se searchDefault a bez searchType filtrační box přeskočí, takže se ve filtru
tiše neobjeví. Formulář to proto zakazuje už při uložení; data vložená mimo formulář
zkontroluj.
Klíč search je rezervovaný
Filtrační box přidává fulltextové pole search a stejně jmenovaný URL parametr.
Formulář klíč odmítne (bez ohledu na velikost písmen).
Volby jdou upravit až na editaci
Nový parametr ještě nemá lft/rgt, takže editor voleb se vykreslí až po prvním uložení.
Volby ukládá Api ParameterManager v afterSave() z $meta['options']
(OptionsSyncTrait::syncOptions(), v e-shopu i v inzerci). Volba je dítě parametru ve stromu
(level = 2) a zapisuje se surovým DBAL ve stejné transakci:
Řádek v options |
Co se stane |
|---|---|
optionId existující volby |
UPDATE jen default, search_default, active a textů; id i místo ve stromu zůstávají |
bez optionId |
INSERT a zařazení na konec dětí (insertTreeNode()) |
| volba, která v seznamu chybí | tvrdé smazání (deleteTreeNode()) i s texty; cizí klíč option_id smaže navázaná pravidla |
| text s prázdným názvem | řádek překladu nevznikne a existující se smaže |
Chybějící klíč options volby nechá. Výchozí volbu („výchozí“ i „výchozí ve hledání“) smí mít
jen jedna, první označená vyhraje, a zapíše se rodiči do default_option_id /
search_default_option_id.
Volby se nesmí ukládat smazáním a novým vložením
option_id je klíč, na který odkazují uložené hodnoty a pravidla. Nové id by tyto vazby
rozbilo a pravidla by kaskáda smazala. Proto se existující volby upravují na místě.
Prázdný překlad by přebil záložní jazyk
Řádek s name = '' v cizím jazyce má přednost před hlavním jazykem a volba by se na té
verzi webu vykreslila bez názvu. Vymazaný název proto znamená „vrať se k záložnímu jazyku“.
Volbu, kterou chceš jen skrýt, nemaž
Odebraná volba zmizí nadobro i s pravidly. Pro dočasné skrytí je zaškrtávátko active.
Kam sáhnout#
| Chci | Kde |
|---|---|
| vlastní prvky a helpery | app/Core/Forms/Form.php |
| továrnu formuláře | app/Core/Forms/FormFactory.php |
| vlastní inputy | app/Core/Forms/Inputs/ |
| kostru admin formuláře | app/Core/Base/Admin/BasePresenter.php → createForm() |
| sdílený uploader | app/UI/Admin/Base/Components/UploaderDropzoneComponent/ |
| sanitizaci editoru | app/Core/Utils/Helpers/EditorHtmlSanitizer.php |
| výšku editoru a přesun editorů | www/themes/admin/*/desktop/assets/js/components/rich-editor.js (configFor(), refreshMovedEditors()) |
| seznam a detail kolekce | …/js/components/master-detail.js, vzor app/UI/Admin/Company/Templates/Company/edit.latte |
| stav formuláře | app/UI/Admin/Base/Components/FormActionsComponent/, …/js/components/form-state.js |
| soubory a odkazy plného editoru | app/Core/Traits/Admin/Presenters/EditorUploadTrait.php, EditorLinkTrait.php |
| dynamické parametry | app/Core/Form/Parameters/ (handlery, ParameterHandlerRegistry), typy ParameterType |
| front formulář a filtr nad parametry | app/UI/Front/Base/Components/ParameterFormComponent/ParameterForm.php, Traits/RegionSuggestHostTrait.php |
| brány polí | app/Core/Forms/ColumnGate.php, StrictDateInput.php, MultiplierLimits.php; guardColumns() v app/Core/Base/Admin/BasePresenter.php; meze sloupců app/Core/Utils/Schema/ColumnSchema.php |
| prázdný jazyk překladu | app/Core/Forms/TranslationInput.php |
| odmítnuté soubory | app/Core/Security/Upload/UploadRejection.php, ImageUploadSizeLimit.php |
| pojistky odeslání | app/Core/Forms/ChoiceGuard.php, PickedRecordGuard.php, FormSubmissionGuard.php; guardFormSubmitOnce() a runSubmitOnce() v app/Core/Base/Admin/BasePresenter.php; submit-once.js, confirm-guard.js |
| formát množství | app/Core/Utils/Helpers/Quantity.php (obal dokladu QuantityFormat.php), Latte filtr quantity v app/Core/Latte/Extensions/PriceLatteExtension.php, mřížka QuantityRenderer |
| závaznou konvenci | skills/code-standard/cs-conv-forms/, skills/code-standard/cs-form-setup/ |
Navazující kapitoly: Admin presenter · Picker · Šablony Latte · Překlady · Konvence kódu