Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
UI vrstva / Formuláře

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

  1. Ověř, že routa cíle vystačí s parametrem slug (jinak odkaz nejde sestavit).
  2. Přidej cíl do EditorLinkTrait::EDITOR_PAGE_TARGETS (malými písmeny, např. 'front:system:page:homepage').
  3. 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#

  1. FormTrait vedle komponenty, metoda createComponentForm(): Form.
  2. Kostru podle vzoru výš.
  3. Delší formulář rozděl do metod addXxxFields(Form $form); naplnění selectů do prepare*().
  4. onSuccess s 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();
    }
}
  1. 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.
  2. 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)#

  1. Předvyplnění dělej přes setDefaults() na kontejneru, ne nastavením hodnot jednotlivým prvkům.
  2. 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
  1. 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 pak setValues()).
  2. Řá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

  1. Serverový Multiplier bez addCreateButton() a addRemoveButton(), minimální počet kopií 0.
  2. 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ény images[__INDEX__][…].
  3. 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.row je nový řádek).
  4. Do Api pošli řádky v $meta pod klíčem, který manager čte v afterSave(); 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á maxlength i 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ím 0.00) a rozsah měří po zaokrouhlení na scale sloupce, tedy na tom, co se zapíše (9999.995 by se jinak uložilo jako 10000.00). Záporná čísla jen s allowNegative: true. Volitelný parametr negativeMessage dá 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 pravidle Form::FLOAT (float) brána, NumericRule i ColumnValue převedou přes Decimal::fromFloat() do prostého zápisu, takže malé platné číslo (0.00001, v PHP 1.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 maskou TaxIdHelper::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.
  • StrictDateInput neexistující datum odmítne (new DateTime('2026-02-30') z něj tiše udělá 2. března) a nepřijme relativní text typu next 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 → vlastnost title).
  • Pole se k vlastnosti přiřadí jménem, u vícejazyčných bez přípony jazyka (name_2 → name překladové entity). Entity se prohledají v uvedeném pořadí.
  • Meze: varchar(n)/char(n) nejvýš n znaků, TEXT podle velikosti v bajtech, celá čísla v rozsahu sloupce (se unsigned), decimal(p,s) po zaokrouhlení na scale. Meze čte ze skutečného schématu (ColumnSchema), protože mapování a databáze se místy liší.
  • Běží v onValidate; maxlength doplní 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é je null, false, prázdný řetězec nebo jen mezery, prázdné pole a upload bez souboru; 0 je 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#

  1. Pojmenuj kontrolku stejně jako vlastnost entity — pak stačí dávková varianta (lengths(), decimals()). Neexistující kontrolku přeskočí, překlep ve vlastnosti spadne hned v ColumnLimits.
  2. Na frontu předej hlášku ColumnGate::FRONT_*_MESSAGE.
  3. Povinnost řeš setRequired(); brány prázdné nepovinné pole pouštějí.
  4. U datetime-local při uložení zavolej StrictDateInput::keepSeconds(StrictDateInput::parseDatetimeLocal($v), $entita->getX()).
  5. 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í add a add<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[…].

  1. Token vlož do formuláře jako skryté pole.
  2. 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.
  3. 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:

  1. 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.
  2. Které tlačítko formulář odeslalo? V administraci ukládají jen save a update; vlastní submit proběhne, ale neuloží.
  3. Má obsluha ArrayHash $values? Viz varování o mixed výš.
  4. Nemá tlačítko setValidationScope([])? Pak jsou hodnoty prázdné.
  5. Vykresluje šablona všechna pole? Nevykreslené pole se uloží jako prázdné.
  6. Vrátil manager isSuccess()? Neúspěch bez flash zprávy je němý.
  7. 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#

  1. Konstanta v ParameterType a název v ParameterType::TYPE_NAMES — odtud bere administrace nabídku typů pole i filtru.
  2. Handler extends AbstractParameterHandler s getTypes() a buildField(); přepiš jen fáze, kde se typ liší od jednoho skalárního pole.
  3. Registrace v ParameterHandlerRegistry::createDefault().
  4. Potřebuje-li vlastní vykreslení, nastav kontrolce option widget a doplň větev do app/UI/Front/Base/Components/ParameterFormComponent/Templates/Default/Style_1.latte (formulář), Style_Filter.latte (filtr) a do admin _form.latte modulu.
  5. Vrací-li formatValue() objekt, namapuj typ na šablonu v InitializationTrait komponenty AdvertParameterListComponent (inzerce, katalog).
  6. 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#

  1. Presenter extends AbstractParameterPresenter (a skupiny AbstractParameterGroupPresenter) s $translatorDomain a gettery z tabulky.
  2. ParameterRuleManager extends AbstractParameterRuleManager s mapou tabulek modulu.
  3. cacheTagClasses() vrátí FQCN Api entit Parameter a ParameterRule jako řetězce (vzor Bazaar). Metoda je abstraktní záměrně: bez ní se modul nespustí.
  4. Leaf komponenty OptionRules a RuleEditor s továrnami, které injektují modulové managery.
  5. 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

Nastavení cookies