Skip to content
V
For developers
Architecture, conventions, the core and security
UI layer / Forms

Forms#

The core is app/Core/Forms/ — a custom Form class on top of Bootstrap forms, a factory and a few custom controls. A form is never written directly in a presenter or a component: it belongs in a FormTrait next to them.

Where the form lives Path
a component XxxComponent/Traits/FormTrait.php
a presenter Presenters/Traits/Forms/XxxFormTrait.php
an admin presenter the same, but the skeleton is built by createForm() — see Admin presenter

The canonical skeleton#

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');   // the key only!

    $form->addSubmit('send', 'send');

    $form->onSuccess[] = [$this, 'onSuccess'];
    return $form;
}
Line Why it is there
FormFactory::create() returns App\Core\Forms\Form, not Nette's Form — otherwise you lose every control below
setMethod('POST') always explicitly
addProtection() the CSRF token, mandatory
useDomainTranslator(…) labels are then bare keys ('name.label'), the domain is added by the translator
role('form') accessibility

The old notation stays functional — it is just not written in new code

setTranslator() + setTranslatorDomain() + $t = $form->getTranslateFnc() still works and is not rewritten in finished forms. Only new forms are written canonically.

getTranslateFnc() follows the translator type: with useDomainTranslator() it returns a relative key (the translator adds the domain), with setTranslator() + setTranslatorDomain() an absolute one (domain.key). A hand-built absolute key on a prefixed translator gets the domain twice and renders as a raw key.

What Form adds on top of Nette#

Method What it does
addEditor($name, $caption) a textarea with data-editor="full" → the full TinyMCE. For administrator content (pages, articles, emails, PDF templates)
addSimpleEditor($name, $caption) a textarea with data-editor="simple" → a stripped-down editor: bold, italics, colours, lists, a table. No links, images or source code
addRawEditor($name, $caption) data-editor="raw" → the same toolbar as full, but the editor drops nothing (<button>, <svg>, <form>, custom elements and attributes). Only page sections and the advert HTML code in the admin
addVatRate($name, $label) a VAT rate in percent, 0–100, decimal comma or dot; a text field, not type=number; shape and range are checked on the server (VatRate::normalize())
addBoundedInteger($name, $label, $min, $max) an integer with a server-enforced limit (per-page counts, days, hours); min/max in HTML is only a hint
addCheckbox($name, $caption) a flat checkbox without the Bootstrap wrapper (FlatCheckboxInput)
addCheckboxList($name, $label, $items) a checkbox list that keeps Html in an option's caption
addImageUpload($name, $caption, …) a field for a single image plus the satellite fields <name>CurrentId and <name>Delete
useDomainTranslator($translator, $domain) a translator with a domain prefix

Every add*Editor and addImageUpload also has a static variant (addEditorControls(), addImageUploadControls()) taking a container — used inside a Multiplier row, where there is only a Nette\Forms\Container without our methods.

User content gets addSimpleEditor(), not addEditor()

The full editor allows links, images and pasted source code. Wherever the content is written by a user (front-end forms, advert parameters) the stripped-down variant is required — and its whitelist has to stay identical to the server-side sanitisation (EditorHtmlSanitizer::ALLOWED_HTML), because a restriction that lives only in JavaScript is bypassed by sending the request outside the browser.

addRawEditor() only in the admin, behind RBAC

Neither full nor raw content is sanitised on the server, and it reaches the front end through |noescape. raw also lets <form>, <svg> and unknown attributes through. Used on the front end, or anywhere a visitor writes, it is a stored XSS.

The full editor's draft is per user

The full editor in the administration stores unsaved content in localStorage (File → Restore last draft, 48 h). The default TinyMCE key uses only the path, query, hash and editor id, so after A signs out and B signs in within the same browser, B would load A's draft. The key therefore carries a scope from EditorAutosaveScope::forUser() — an HMAC of the user id with %jwt.secret%, not the bare id — which the admin layout writes into data-editor-autosave-scope and rich-editor.js puts into autosave_prefix. A custom TinyMCE instance outside rich-editor.js must take the prefix over the same way.

Editor height#

The default editor height is set by an attribute on the returned field. It works for addEditor(), addRawEditor() and addSimpleEditor() and rich-editor.js handles it:

$form->addEditor('content', 'Content')
    ->setHtmlAttribute('data-editor-hsize', 'lg');

$form->addSimpleEditor('perex', 'Perex')
    ->setHtmlAttribute('data-editor-hsize', 'auto');
data-editor-hsize Default height When
auto follows the content, 160–900 px short texts of varying length: perex, a short message
sm 200 px a short text, e.g. a team member's short text
md 420 px descriptions, additional information, category info
lg 700 px the main content of the screen, e.g. page content
xl 1000 px article body, e-mail template body
xxl 1300 px page section content, newsletter

With sizes sm–xxl the user can still drag the editor taller by the corner handle. auto uses the autoresize plugin: the editor grows with the content and scrolls above 900 px.

An exact height in pixels comes from ->setHtmlAttribute('data-editor-height', 320) and wins over data-editor-hsize. Without either attribute the editor preset's default height applies.

auto turns off manual resizing

With the autoresize plugin TinyMCE disables the corner handle. Do not use auto for main content, the user could not enlarge a long text.

The full editor in the administration has two server backends. They live as signals on the admin BasePresenter, because the editor can be on any page and the link {link editorUpload!} must point to the presenter currently running. A signal also gets the same-origin protection of Nette 3.2+, so no own token is needed.

Button Trait and signals What it returns
the file manager (rich-editor.js) EditorUploadTrait: editorUpload!, editorFiles!, editorCreateFolder!, editorRename!, editorDelete! JSON {folder, parent, breadcrumb[], folders[], files[]}; paths relative to the editor root, never the server path
"Link to a page" (tinymce-page-link.js) EditorLinkTrait: editorPages! JSON {"pages": [{id, label, url}]}, ready addresses from the page route

Editor files land in their own folder uploads/editorUploads/ and are not assets: they have no database record and no variants. The client sends paths relative to that folder and the prefix is added only by editorPath(), which also rejects a .. segment. Reads and writes go through the Api FileSystemManager. The whole uploads/ is managed on the System → Files screen.

Procedure: the editor should offer a link to another kind of page

  1. Check that the target route needs only the slug parameter (otherwise no link can be built).
  2. Add the target to EditorLinkTrait::EDITOR_PAGE_TARGETS (lowercase, e.g. 'front:system:page:homepage').
  3. Open the link dialog in the editor and check that the new page has a working address.

Targets with further parameters do not belong in the offer

A user profile needs id and username, an advert detail id and name. The editor has nowhere to take them from, so the link would lead to a broken address.

Procedure: adding a form to a component#

  1. A FormTrait next to the component, with a createComponentForm(): Form method.
  2. The skeleton per the example above.
  3. Split a longer form into addXxxFields(Form $form) methods; populate selects in prepare*().
  4. onSuccess with a typed signature:
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. Send the flash message as a bare key — a component template already has its translator set to the component's domain and {_$flash->message} adds it.
  2. Add the translation keys at the same time, in every language — see Translations.

onSuccess(Form $form, mixed $values) breaks EVERY submit

Nette derives the shape of the values from the second parameter ($this->getValues(Helpers::getSingleType($params[1]))). With mixed that becomes new ReflectionClass('mixed') → ReflectionException: Class "mixed" does not exist. There is no fallback. An untyped parameter is wrong too — you get $this or the button instead of the values. Always ArrayHash $values.

Exactly this made the order state machine impossible to run from the administration, and nobody noticed for months: the harness called the Api endpoint directly and never submitted the form. The check: grep -rn 'onSuccess\[\] = function' app | grep mixed.

Procedure: rendering a form in 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}
Element How it is written
a field error inline {inputError name} next to that field
a checkbox {input active:} — the colon is mandatory
a checkbox caption {label active}{_$form['active']->caption}{/label}, never {label active /}
a description only the key in PHP, translated in Latte through _()

Do not use {control $form errors} in a hand-rendered form

A summary at the top means the user sees "Fill in the name" but not which field it is — in a form with language tabs the offending field is often hidden in an inactive tab.

A checkbox renders its own caption

{label active /} (self-closing) prints the caption a second time. Hence the variant with {_…->caption} inside {label}.

n:if on a form field ERASES its value

A field that is not rendered is not submitted, and saving writes it as empty. The same holds for addHidden(). If you need a field to be conditional, handle it while building the form (do not add it), not in the template. The data disappears with no error message.

Procedure: repeated groups of fields (Multiplier)#

  1. Prefill through setDefaults() on the container, not by setting values on the individual controls.
  2. On an already submitted form addCopy(null, $defaults) does not work — it internally calls Container::setDefaults(), which on a submitted form only sets disabled fields. The copy comes out empty. Correctly:
$copy = $outerMultiplier->addCopy();     // no defaults
$copy->setValues($row);                  // direct controls — always works
  1. A nested Multiplier on a submitted form ignores programmatically supplied values — it builds the copies from the submitted data, not from the values. A new container is not in the submitted data, so the inner copies come out as none. You have to add them explicitly (getComponent($i, false) ?? $nested->addCopy($i) and then setValues()).
  2. A row needs its own class on the wrapper — without it the controls fall apart in the layout.

onValidate runs even for a button with an empty validation scope

The scope limits only the controls; Nette always calls the form's onValidate handlers. A form-wide check (such as "at least one category") without a button gate would report an error even on "Add row". So start it by testing $form->isSubmitted() for update and save. Put the error on a control that is always rendered (for a Multiplier the multiplier_creator button) and print it under it: HiddenField::addError() redirects the error to the form, and the administration shows that only as a flash. Example: AdvertCategoryFieldsTrait::guardCategoriesSelected() (Classifieds).

setValidationScope([]) empties getValues()

Nette marks controls outside the submitter's validation scope as omitted, and getValues() skips them. On a Multiplier it looks especially insidious: getValues()->items returns [{}] — the row exists but is empty.

Auxiliary AJAX buttons (recalculate, "recompute", a Picker redraw) have an empty scope on purpose, so they do not block on required while the administrator is still assembling the form. Their handlers therefore have to read the controls directly — $multiplier->getContainers() plus $control->getValue(). Because of this, "Recalculate" and the shipping prefill never worked in the e-shop: the error message rendered into a snippet that was not being redrawn, so the failure was silent.

An AJAX button's handler must also redraw the snippet holding its error message

Otherwise the button "does nothing" — whether an error occurred or not.

A disabled field renders empty after an AJAX action

The browser does not submit disabled fields, and defaults are applied on GET only. After any AJAX action in the form (adding a row, duplicating) a disabled select or checkbox therefore renders without its value. Restore it from the stored data right in the template, before anything is computed from the values, and never overwrite the submitted values of editable fields. Pattern: restoreShipperTariffGenerationDisplay() (shipping tariff, called through {do} at the start of every generation card). For the same reason "Duplicate" copies a generation from the stored data, not from the POST.

Rows with a file upload (client-side Multiplier)#

A row with <input type=file> must not go through naja add/remove: every snippet redraw drops the chosen file, and for security reasons the browser cannot put it back. Such a Multiplier adds and removes rows in the browser with the client-multiplier.js script; the server-side Multiplier stays a mere container that creates a container for every posted index on submit. Gaps in the indexes after a removed row do no harm, the row order is the DOM order.

Attribute On what
data-multiplier root of one instance; its presence turns the client mode on
data-multiplier-items the row container (the root when missing)
data-multiplier-template <template> with the row prototype, __INDEX__ placeholder in name
data-multiplier-add the Add button
data-multiplier-remove the Remove button inside a row

Procedure: a Multiplier with a file upload

  1. Server-side Multiplier without addCreateButton() and addRemoveButton(), minimum copies 0.
  2. In the template wrap the list in data-multiplier, render the existing rows from the form and put the prototype of a new row into <template data-multiplier-template> with names images[__INDEX__][…].
  3. Initialise controls in a new row (image preview etc.) on the multiplier:row-added event the script fires on the new row and lets bubble (detail.row is the new row).
  4. Send the rows to the Api in $meta under the key the manager reads in afterSave(); send it even when empty. Blog gallery: no $meta['images'] key = leave the collection alone, an empty array = empty it.

A new row field belongs in two places

In the PHP row factory and in the <template> in the template. Without the second only the server-rendered rows have it; a newly added row lacks the field and its value is not submitted.

Models: app/UI/Admin/Blog/Templates/Sections/Gallery/asset_multiplier.latte (+ GalleryFormTrait::addImageMultiplier()), the team in the company catalogue.

List and detail (master-detail)#

A long collection of repeated items (content blocks, team, highlights) can be shown in the administration as a list on the left and the selected item's detail on the right. It is switched on by the attribute data-master-detail="<key>" on the rows container (.address-rows); rows must be [data-sort-item]. master-detail.js handles it; without JavaScript the cards stay stacked.

<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>
Container attribute What it sets Default
data-md-label selector of the field with the item name in the list the first [data-sortable-label]
data-md-sub selector of the field with the secondary line under the name none
data-md-public selector of the publish checkbox (a mark in the list) input[type="checkbox"][name$="[public]"]
data-md-title the list heading the fieldset's <legend>
data-md-position position text in the detail, %index% and %count% %index% / %count%
data-md-untitled name of an item with no name filled in, %index% #%index%
data-md-empty text of an empty collection empty
data-md-language label of the language switch empty

The thumbnail in the list is taken from the .image-upload__img image in the row. When a row has several language blocks (.multiplier-card__lang-block), it gets a language switch and shows one block; the choice applies to all rows of the same key. A sortable collection (data-sortable) has a .md__handle grip in the list (SortableJS); dragging moves the real form row.

The script only hides rows, it clones nothing. A selected file, TinyMCE, Nette rules and the POST order therefore stay in the form. The selection and language survive an AJAX redraw of a server-side multiplier (remembered by the key); after a row is added, the new one is selected.

An error in a hidden row opens by itself

Before revealing an invalid field, form-error-reveal.js dispatches the md:reveal event on it and master-detail.js switches to the row and language holding the error. Custom code that reveals fields should send the same: el.dispatchEvent(new CustomEvent('md:reveal', { bubbles: true })).

Moving a row with an editor#

A browser reloads a moved <iframe> empty. After a row with TinyMCE moves in the DOM (arrows, dragging), the editor looks empty and typing into it is lost, even though the instance still holds the content. rich-editor.js therefore exports refreshMovedEditors(root): it takes the content from the instance, removes it and creates a new one over the textarea. sortable.js and master-detail.js call it.

Custom code that moves rows with an editor must call refreshMovedEditors()

import { refreshMovedEditors } from './rich-editor.js';

list.insertBefore(row, row.previousElementSibling);
refreshMovedEditors(row);

Without it the save sends either the old content or nothing, and the editor looks like an empty field.

Form state in the action bar#

The action bar of an edit form ({control adminFormActions $backlink}, template FormActions.latte) carries data-form-state and shows "No unsaved changes" or "Unsaved changes" on the left. form-state.js drives it. A change is:

Change How it is detected
typing into and changing a field input / change, also for a field outside the form element bound by the form attribute
TinyMCE content editor events (the editor lives in an iframe)
adding, removing and moving items a MutationObserver over [data-sortable], [data-multiplier-items] and .file-upload-list, only after the initial set-up
an AJAX redraw of a snippet inside the form index.js calls markFormDirty(form)

Submitting the form resets the state. With unsaved changes the browser asks before leaving the page (beforeunload).

A form change from your own AJAX

When your code changes the form other than through a field (rebuilds a list, swaps a snippet outside the usual naja handling), call markFormDirty(form) from form-state.js. Otherwise the bar shows "No unsaved changes" and leaving the page silently drops the changes.

Server-side field gates#

The maxlength, min, max attributes or type=date guarantee nothing: a request sent outside the browser bypasses them. The hydrator does not enforce column lengths and #[ORM\Column(length: …)] is only a schema declaration. Whatever the form does not catch ends, depending on the database sql_mode, either as a generic database error of the whole save (strict) or silently truncated or stored as zero (non-strict). Forms therefore have three shared gates in app/Core/Forms/:

Gate For Main methods
ColumnGate length and shape by the entity column (the limit is read by reflection in ColumnLimits, no copied number) length() / lengths(), taxId() / taxIds(), hexColor(), assetPath(), decimal() / decimals()
StrictDateInput type=datetime-local (Y-m-d\TH:i) and type=date values datetimeLocalRule(s)(), dateRule(s)(), parseDatetimeLocal(), parseDate(), keepSeconds()
MultiplierLimits the number of Multiplier rows guard() + limit constants
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'));

What the gates handle that a hand-written rule would forget:

  • length() adds both maxlength and the server MAX_LENGTH rule (catches pasted input too).
  • decimal() rejects exponent notation (1e1000 → INF → a database error in strict mode, 0.00 in non-strict mode) and measures the range after rounding to the column scale, i.e. on what gets written (9999.995 would otherwise be stored as 10000.00). Negative numbers only with allowNegative: true. The optional negativeMessage parameter gives a negative value its own message; without it the value gets the range message ("Enter a number less than …"), which is confusing for a negative price. The rule is added before the range rule and Nette stops at the first failure, so it wins. For a price on the front end use ColumnGate::FRONT_NEGATIVE_PRICE_MESSAGE (example: the advert form, AdvertFormComponent/Traits/FormFieldsTrait.php). A value after the Form::FLOAT rule (a float) is turned into plain notation with Decimal::fromFloat() by the gate, NumericRule and ColumnValue, so a small valid number (0.00001, in PHP 1.0E-5) does not get the range message. Convert it the same way yourself before passing it on (the warehouse accepts plain notation only).
  • taxId() checks company and VAT ids with the loose mask TaxIdHelper::PATTERN (a letter or digit, then letters, digits, space, ., /, -), not a Czech "8 digits": the e-shop sells abroad and a strict mask would reject valid foreign VAT ids. The length is measured against the column of the entity passed in. It does not check the VAT payer register.
  • hexColor() checks the #rrggbb shape: colour columns hold 7 characters and a longer notation would be truncated into a different colour, not an error.
  • StrictDateInput rejects a nonexistent date (new DateTime('2026-02-30') silently makes it 2 March) and does not accept relative text such as next monday.

The whole admin form at once: guardColumns()#

Gating field by field meant that a new field got forgotten. The admin presenter therefore has guardColumns(): the form only says which entities its fields write to, and the gate (ColumnGate::guard()) checks every text and number field against the real database column.

$this->guardColumns($form, [
    ''       => [Gallery::class, GalleryText::class],  // fields of the form itself
    'images' => [Asset::class, AssetText::class],      // every row of the `images` Multiplier
], except: ['password']);                              // a field whose value is transformed before storage
  • The key '' is the form itself, a Multiplier name gates all its rows (those created from the POST too), a key ending with * takes the form's fields with that prefix ('quickPrice*': quickPriceTitle_2 → property title).
  • A field maps to a property by name, a multilingual one without the language suffix (name_2 → name of the translation entity). Entities are searched in the given order.
  • Limits: varchar(n)/char(n) at most n characters, TEXT by its size in bytes, integers within the column range (unsigned included), decimal(p,s) after rounding to the scale. The limits come from the real schema (ColumnSchema), because mapping and database differ in places.
  • It runs in onValidate; maxlength is added on render. It skips a field that already has its own error, disabled and hidden fields, and fields in $except. An empty optional field passes.

Result: an oversized value is an error at the field. Without the gate it ends as a database error ("Data too long", "Out of range"), the admin shows a generic message and the EntityManager stays closed for the rest of the request.

guardColumns() does not replace the shape gates

It checks length and range. Company/VAT ids, colours, file paths or a negative price still need taxId(), hexColor(), assetPath() and decimal().

A blank language of a multilingual form: TranslationInput#

The form traits create the translation entity even for a language the record has no text in yet. Without a check every save would add an empty inactive translation for each such language, and a save without changes would change the database. Hence:

$translation = $news->getTranslation($langId);
if (!$translation instanceof NewsText) {
    if (TranslationInput::isBlank($values, $langId, ['active', 'perex', 'text', 'title'])) {
        continue; // nobody filled the language in: no row
    }
    $translation = new NewsText();
    // … language, parent, addTranslation()
}
// both an existing and a new translation get the values
  • A language where everything is blank (the active checkbox included) gets no row. Blank means null, false, an empty or whitespace-only string, an empty array and an upload without a file; 0 counts as input.
  • Fill in anything (or tick active) and the translation is created as before.
  • An existing translation is always updated; the check applies to creating only.

$fields lists all fields of the language without the _<langId> suffix, active included. Examples: NewsFormTrait, IntroFormTrait (Blog), BillingLineFormTrait (Invoicer).

Procedure: protecting a new field that is written to a column#

  1. Name the control the same as the entity property — then the batch variant is enough (lengths(), decimals()). A missing control is skipped; a typo in the property fails at once in ColumnLimits.
  2. On the front end pass the message ColumnGate::FRONT_*_MESSAGE.
  3. Handle mandatory filling with setRequired(); the gates let an empty optional field through.
  4. For datetime-local, on save call StrictDateInput::keepSeconds(StrictDateInput::parseDatetimeLocal($v), $entity->getX()).
  5. Verify by submitting an overflowing value: the error must sit on the field and nothing is saved.

A different message constant on the front end

The messages are global // keys (//admin.general.columnGate.*) so that they bypass the prefixed translator's domain and need not be copied into every module. The translation root differs between the front end and the admin, though: the admin key is not loaded on a front-end form, and a missing key crashes the page.

Without keepSeconds() an unchanged save cuts the seconds

No datetime-local field has step, so the browser sends the time without seconds. Saving the form unchanged would turn 23:59:59 into 23:59:00.

StrictDateInput does not replace general parsing

AdminFormHelpersTrait::parseDateTime() still reads texts outside the date / datetime-local contract (e.g. snapshots in the Y-m-d H:i:s shape). The strict parser belongs only on fields where the HTML type guarantees the format.

Multiplier row limits#

A Multiplier without the third argument ($maxCopies) materialises an arbitrarily large tree from the request; with it, excess rows are silently cut, which for documents is data loss without an error. Hence two layers: MultiplierLimits::guard() counts the rows in the raw request data and adds a form error before anything is saved, and $maxCopies is the second safeguard.

$form->onAnchor[] = function (Form $form): void {
    MultiplierLimits::guard($form, $form->httpDataValue('items'), MultiplierLimits::INVOICE_ITEMS);
};
$multiplier = new Multiplier($factory, 0, MultiplierLimits::INVOICE_ITEMS);

The limits are a product decision, so they live only as constants in MultiplierLimits.

guard() belongs in onAnchor

getHttpData() needs an anchored form, and the form is anchored only after createComponent…() returns. A call directly in the factory gets no data and guards nothing.

The form error may not be visible

guard() calls addError() on the form, and not every admin template renders $form->getErrors(). Where it does not, show the message as a flash as well (MultiplierLimits::message()), otherwise nothing is saved and the user never learns why.

Protection against forged and repeated submits#

A browser offers only the values the form rendered, but a request can be sent from outside it, or from a page opened before a record was deleted. Three more shared guards in app/Core/Forms/ cover what the field gates do not.

Guard Protects against Where to wire it
ChoiceGuard a select or checkbox-list value that is not among the options ChoiceGuard::rejectUnknown($control)
PickedRecordGuard a Picker record id (hidden field) that does not exist PickedRecordGuard::guard($form, $name, $labelKey, $exists)
FormSubmissionGuard a double click or two concurrent POSTs of an "add" form automatic for add* actions, otherwise guardFormSubmitOnce($form)

ChoiceGuard: an unknown choice#

For an unknown key Nette returns null. An optional select then skips all its rules and the save treats the value as "no choice" and clears the stored relation (a saved product ends up without a producer). With a checkbox list of roles, one forged key removed all roles of a user. rejectUnknown() reads the raw submitted value in onValidate and rejects the whole submit on an unknown one; the other ticked values are not saved either. An empty choice stays allowed; make the field mandatory with setRequired().

ChoiceGuard::rejectUnknown($form->getComponent('producerId'));
ChoiceGuard::rejectUnknown($form->getComponent('roles'));

PickedRecordGuard: an id from a Picker#

A Picker writes the chosen id into a hidden field. It can be stale (another administrator deleted the record in another tab) or forged. Without a check the save failed on a foreign key, the administrator got a generic error and the EntityManager stayed closed for the rest of the request. The check runs in onValidate, before any write.

PickedRecordGuard::guard(
    $form,
    'advertId',                     // name of the hidden field
    'form.fields.advert.label',     // label key (relative, as in the template)
    fn (int $id): bool => $this->advertManager->find($id)->exists(),
    required: true,                 // an empty pick is an error too
);

Nette sends an error of a hidden field to the form level, so the message names the field by its label and the administration shows it as a flash. Only a submit that validates the whole form is checked (save, update). A button with a narrowed validation scope (a Multiplier row, a Picker refresh) must keep working with an empty or stale pick.

FormSubmissionGuard: a one-time token#

A form that creates a record carries a hidden submissionToken field. The first submit claims it by inserting a row into cms_system_form_submissions (the primary key makes it pass only once even with concurrent requests; sessions in Redis without a lock cannot do that). The token is added automatically for:

  • forms of the add and add<Something> actions (createForm() in the admin presenter),
  • component forms through AdminComponentFormTrait,
  • forms built elsewhere (product import, credit notes, stocktake) call $this->guardFormSubmitOnce($form) themselves.

Edit forms are not guarded: a repeated submit only saves the same values again.

Situation What happens
first submit the token is claimed, the record is saved, the record id is stored with the token
the save failed the token is released, the corrected form can be sent again
second submit of the same token nothing is saved; the administrator is redirected to the record of the first submit with a notice
second submit while the first still runs waits a few seconds for the result, otherwise back to the previous page with a warning
missing or malformed token nothing is saved; the form gets a fresh token (typically a form opened before a deployment)

Wrap the save in runSubmitOnce($form, $backKey, fn (): bool => …). It returns true (saved), false (not saved) or null (already saved earlier). Set $this->savedEntityId in the step before redirecting so that a repeated submit has somewhere to lead.

The client part only saves effort: submit-once.js blocks a second click on forms with data-submit-once and marks the buttons busy. The server is the authority.

Do not disable the button synchronously in submit

The browser assembles the form data after the submit event. A disabled button is not added to the request, so Nette cannot tell which of save / update was pressed and saves nothing. submit-once.js therefore disables in the next task (setTimeout).

A database failure is handled by whether the first request could have saved:

Failure What happens
the token table is missing (migration not applied yet) the submit goes through unguarded and the failure is logged; no token can exist yet
any other database error while claiming the token SubmissionClaim::Unknown: nothing is saved, the form stays filled and the administrator gets the message admin.system.form.submission.unknown; the same token can be sent again
the token is already claimed but its state cannot be read (also while waiting) Unknown as well: the first save may have finished, a second one would create the record twice
purging old tokens only logged; the claim's own INSERT decides

Rows older than 24 hours are purged on the next claim; the DELETE repeats the age condition, so it does not remove a token that another request has claimed afresh meanwhile.

Confirming a destructive action#

Deleting in a grid is confirmed by a modal window that sends the _confirmed flag (DataGrid::CONFIRM_FIELD). Without the flag the server deletes nothing and renders a confirmation page (DeleteConfirmation.latte), so the protection holds without JavaScript too. The script confirm-guard.js loads as a blocking <script> in <head> so that a click on a button with data-grid-confirm cannot submit the form without confirmation before the modal is ready. It remembers the click and repeats it once ready. Mark your own button with a confirmation as data-grid-confirm="Question text".

Quantities without needless decimals#

The database returns quantities with the full number of places: an invoicing document decimal(8,2) as 2.00, the warehouse decimal(10,3) as 1.500. The display rules live in a single class, Quantity: a whole number without decimals (2), a decimal with the needed places only (1.5), the separator by the localization (Czech and Slovak a comma, English a full stop). It processes the value as a string, without floats, so 0.001 keeps its precision.

Where With
Latte the filter {$qty\|quantity}; \|quantity:true gives the machine form with a full stop for value and data attributes
administration grid QuantityRenderer (CSV export in the machine form)
PHP (an e-mail composed in a manager) Quantity::format($qty, Quantity::decimalSeparatorOf($localization))
invoicing forms and PDF QuantityFormat::quantity() via InvoicePdfGenerator::formatQuantity(): a thin wrapper over Quantity that keeps null as null and rounds to the 2 places of the column first

Route every quantity output through it, otherwise one place shows 2.00 and another 2.

A field that must not change after creation#

A key that code or a seed binds to (a role code, a page section key) is entered only on creation. On update the form must not take it from the request, because the client can forge a hidden or merely greyed-out field. Two patterns are reliable:

Pattern What Nette does Example
do not create the control on update the form does not know the field and never loads the value from POST; print the value as text in the template PageSectionFormTrait::addKeyField()
setDisabled() on the control ignores the POST value and leaves the control out of getValues(); shows the default value UserRoleFormTrait (code)

In both cases the save reads the key from $values only on creation and on update keeps the value of the entity loaded from the database.

A hidden key field plus a check in onSuccess is not enough

The client sends any value of a hidden field, and addError() on a hidden field shows only as a form error. A renamed key breaks templates and seeds that call it by a fixed name.

Do not read $values->code from a disabled control

After setDisabled() the key is missing from $values. Code that writes it into the entity on update does not get the original value: without ?? it fails on an undefined property, with ?? it writes the fallback instead of the key from the database.

Procedure: file uploads#

The shared UploaderDropzone uploads files ahead of time through an AJAX signal into a temporary folder keyed by a token; the final submit reads them from there using the hidden dropzoneToken field and the hidden uploaderItems[…] entries.

  1. Put the token into the form as a hidden field.
  2. Do NOT regenerate the token on a POST redraw — read it from the submitted data and set it with setToken($posted). prepareTempDir(forceNew: true) would create a new empty folder.
  3. Create a new folder only on a fresh GET (in a component that is recognisable by getSignal() === null).

What goes to the Api. The form does not send file contents but a reference to a temporary file: an item {name, path}, where path is a file in the dropzone temporary folder. It is read by a single place, App\Core\Security\Upload\UploadedItem, one file at a time right before processing (formerly all files went at once as base64 and 60 to 80 MB of photos exceeded memory_limit). A path is accepted only when:

  • an internal bridge call is running. The state is held by App\Core\Bridge\InternalCallContext, set only by ApiBridge::internalCall(), and it does not depend on RBAC;
  • the path, after realpath(), lies inside the dropzone root (DropzoneTempRoot, that is <appTempDir>/dropzone), it is a regular file and not a symlink, and the path contains no ...

An external client still sends base64 in data, and the path key is dropped from an external request already at the boundary (SaveTrait::stripExternalUploadPaths()). An empty item (neither path nor data) is discarded and the save does not fail because of it.

Resizing in the browser. UploaderDropzone shrinks JPEG and PNG images whose longer side is over 2560 px before the upload (dropzone.js reads the data-dropzone-resize-max and data-dropzone-resize-quality attributes); other formats and attachments are left alone. Set the size and the JPEG quality with setClientResize($maxSide, $quality). Call disableClientResize() for an uploader whose bytes must not change (attachments, complaint evidence photos, product import). The component script is a separate copy in every theme; after changing it, distribute it with the bin/theme-js-sync.php command.

Extensions instead of MIME. The browser or the OS often sends text formats (XML, CSV, JSON) as text/plain or application/octet-stream, so a MIME check would reject them. The uploader gets configure(…, allowedExtensions: ['xml', 'csv', 'json']) for them: the given extension list is authoritative and the MIME check is skipped for that upload instead of being added on top. Without the list only MIME applies (images, video). Model: ProductImportAddFormTrait.

Cleaning up the temp folder. getUploadedFiles() returns links only; the Api reads the files when it processes them. So call cleanup() only after the save has finished; a folder deleted in the middle of the Api call means a record without files.

Quotas and rejected files. Unfinished uploads are limited by the settings upload.dropzone.maxPendingForms (default 20) and upload.dropzone.maxSessionMb (default 300). An image or attachment the Api does not store is written by the manager into saveInfo.rejectedUploads with a reason code (UploadRejection):

Code When
type the format is not accepted here (extension, content, SVG, video without a header)
dimensions the image has too many pixels or does not fit into memory
size the file is larger than the record's limit: max_size of the active ParameterType::IMAGE_UPLOADER parameter, otherwise the module setting maxImageSize, otherwise 50 MB (ImageUploadSizeLimit)
unreadable the file cannot be read or decoded
saveFailed it passed the checks but could not be written (disk, DB)

The save does not fail, accepted files are stored and the administration builds a warning from the rejected ones. The size limit is enforced by the Api too (AssetProcessingTrait, the manager returns the limit from getAssetMaxBytes(); the default null = no limit, adverts and companies have their own), so a direct Api call outside the form cannot bypass it.

prepareTempDir() does not create the folder on disk, it only reserves the path in the session; the folder is created on the first upload (handleUpload()), so merely opening a form leaves no empty folder behind. Tokens are kept in creation order and once maxPendingForms is exceeded the oldest one is deleted together with its files.

Too many open forms delete the oldest upload

Whoever opens more upload forms in one session than upload.dropzone.maxPendingForms allows loses the files uploaded in the oldest one. Submitting that form then saves the record without them. Raise the limit in the settings, not in code.

Without taking the token over, an in-progress upload disappears on the first validation error

The scenario: the user uploads an image → submits the form → validation fails on something else → the snippet with the gallery is redrawn → the server HTML knows only the files from the database, not those in the temporary folder → the hidden uploaderItems entries disappear. The user fixes the error, submits again, and the record is saved without the image — even though the file is still sitting in the temporary folder. The happy path works, so it only surfaces during debugging, when validation fails often.

Saving replaces the whole list of attachments

If the upload field is not loaded, an empty list is saved and the attachments disappear. It is the same mechanism as with collections — see Hydrators, the section on saving an object graph.

Procedure: the form was submitted but nothing was saved#

Cheapest first:

  1. Did a flash arrive? If not, check whether the code redirects through redirectUrl() — that throws the flash away and the action looks like it never ran.
  2. Which button submitted the form? In the administration only save and update save; a custom submit runs but does not save.
  3. Does the handler take ArrayHash $values? See the mixed warning above.
  4. Does the button have setValidationScope([])? Then the values are empty.
  5. Does the template render every field? An unrendered field is saved as empty.
  6. Did the manager return isSuccess()? A failure without a flash message is silent.
  7. Only then look into the database.

The form layer needs at least one test that ACTUALLY submits a form

Building the form or calling the Api endpoint beneath it will not catch this class of bug — both pass even when the form cannot be submitted. Only a POST or a live click-through in a browser catches it.

After changing a form's structure, load the page twice

The first load still runs on the old shape from the cache.

Dynamic parameters#

Alongside hand-written forms there is a parameter system (app/Core/Form/Parameters/): the field is assembled from configuration in the database rather than from code. Classifieds, the company catalogue and the e-shop all use it — every category has a different set of fields and those can be changed from the administration without touching the code.

The behaviour in the administration is described in Categories and parameters.

The HTML of an editor parameter is cleaned in the Api, not in the form

The detail prints a useEditor parameter through |noescape. Classifieds and the company catalogue therefore clean it in beforeSave() of the Api AdvertManager (AdvertParameterEditorHtmlTrait, after hydration, before the flush), the path of the front end, the administration and the external Advert:save alike. Cleaning only in the form would let an external client bypass it and store <p onmouseover=…>. A new module with editor parameters must call purifyEditorParameterRels() the same way. Values without an editor are not cleaned: templates print them escaped.

Parameter types and their handlers#

Each type (ParameterType, an integer) is served by one stateless handler extends AbstractParameterHandler. The type → handler map is kept by ParameterHandlerRegistry (shared() for display and URL filters, createDefault() for the form, which may extend it). The form uses getType(), the filter getSearchType() — so one parameter can have a different handler in each place. A handler covers the whole life cycle:

Phase Methods (default implementation = one scalar field)
building the field buildField() (required), setupOptions(), resolveDefaultValue(), applyDefault()
prefilling from the entity applyEntityValue($form, $key, $value, $force)
the value to save extractValue(), extractRows(), isPersistable()
form ↔ URL toUrlParams(), fromUrlParams()
URL → query filter toFilters($parameterId, $urlParams, $matchAll)
displaying the stored value formatValue() → a string, an object for a custom template (e.g. OpeningHours), or null = skip the row
active filter badges getActiveValueLabels()

Fields are put into a form through ParameterFieldBuilder (front end and admin): init() → build() → applyEntityDefaults() (edit) → after submit extractValues(). Specification rows on the detail are built by ParameterDisplayBuilder.

Convention Detail
URL keys p[{id}]; ranges p[{id}_from], p[{id}_to]; distance from an address also p[{id}_d] (km) and p[{id}_r] (region)
numeric key PHP turns '12' in an array key into 12 — maps indexed by value (optionCounts) have the key type array-key and are looked up via (string) $id
range relation value = from, value2 = to (one relation). LIMITRANGE_SLIDER matches "the advert's whole range inside the searched one": from ≥ searched from and to ≤ searched to; the bounds are bound as numbers (the column is text). RANGE and RANGE_SLIDER, by contrast, look for an intersection (ParameterRelRangeFilter: the stored [value, value2 ?? value] overlaps the searched one), one filter for both bounds so they hold on the same relation; see Filters
translation display text with %value% / %value2% it is a whole sentence, without them a suffix after the value; plural by %count%
option counts and slider bounds ParameterFormContext::$optionCounts and $rangeBounds are filled by the module's LoadingTrait — currently only the e-shop. Empty = fields without counts, a range as two numbers. An option with zero gets the is-empty class (greyed out)

Procedure: adding a new parameter type#

  1. A constant in ParameterType and a name in ParameterType::TYPE_NAMES — the admin takes the field and filter type options from there.
  2. A handler extends AbstractParameterHandler with getTypes() and buildField(); override only the phases where the type differs from one scalar field.
  3. Registration in ParameterHandlerRegistry::createDefault().
  4. If it needs custom rendering, set the control option widget and add a branch to app/UI/Front/Base/Components/ParameterFormComponent/Templates/Default/Style_1.latte (form), Style_Filter.latte (filter) and to the module's admin _form.latte.
  5. If formatValue() returns an object, map the type to a template in the InitializationTrait of the AdvertParameterListComponent (classifieds, catalogue).
  6. Click through: create the parameter in a category → save an advert → the edit shows the value → the URL filter filters → the detail displays it.

An unregistered type disappears without an error

The form and the filter box skip a type without a handler (registry->get() returns null). The field is not rendered, never reaches the URL or the filter, and nobody notices.

A field made of several controls must override toUrlParams() and fromUrlParams()

The default reads $values[$key], but a range has only {key}_from and {key}_to. It returns nothing, nothing reaches the URL and the filter does not filter — the list returns the same number of items for any bound. The suffixes must match extractBaseParameterIds() in the module presenter's ParameterTrait, which strips them by regex (the e-shop knows only _from|_to).

setDefaultValue() does nothing on a submitted form

Fields that appear only after an AJAX category redraw would stay empty on edit. That is why applyEntityDefaultsForNewFields($form, $rawPost) fills with setValue() ($force) only the fields whose key is not in the submitted POST. Forcing a field that is in the POST overwrites what the user is typing with the database value.

An empty translation is skipped like a missing one

Labels take the first non-empty text ('' and blanks are skipped too, ?? would not do): otherwise a translation with an empty name would win over Czech and the option would have no label. In the filter no other language is used at all — an untranslated parameter or option is hidden. In the form it stays with a label from another language, so that saving does not wipe the value.

A <select> gets its labels as text

getOptionItemsForField(..., html: false): <option> cannot hold nested markup and would print it. Checkboxes and radios render the Html label with the count (hence addCheckboxList() keeps Html). Active filter badges take the label without the count.

A hidden value that needs its error on the field

REGION_PICKER keeps the region id in a TextInput with type=hidden, not in addHidden(): HiddenField::addError() sends the error to the form, and templates do not render form-level errors. In the classifieds and catalogue admin, SELECT_REGION renders as a Picker instead of a select — the form adds SelectRegionPickerHandler to the registry.

The ParameterForm component#

Both the front form and the filter box over parameters inherit the abstract app/UI/Front/Base/Components/ParameterFormComponent/ParameterForm.php. The children are AdvertForm and AdvertFilterBox in adverts and the company catalogue and ProductFilterBox in the e-shop. Badges, counts and the uploader map live on the shared base, so every child gets them at once.

The child supplies Required What for
loadParameters(), loadParameterGroups() yes data from the module's managers
buildContext() yes items of special selects (currency, region, catalogue category)
getMode() no, form by default MODE_FORM (form) or MODE_SEARCH (filter)
processSubmit() no saving the entity, flash, redirect; runs before the $onSuccess callbacks added from outside, so those see the saved state
getEntityValues() no prefill from the entity when editing
beforeSubmitButton() no custom fields before the button
buildRegistry() no custom type handlers

What the base gives the templates:

Method Called from Returns
renderActiveFilters() the list template: {control frontBazaarAdvertFilterBox:activeFilters} active filter badges above the list; with no active filter it renders nothing, not even the wrapper
getActiveFilters() Style_ActiveFilters.latte one entry per cancellable value ("Sony" and "Canon" are two badges); remove is p without that value, null = link to the list without a filter
getGroupChosenCount($group) Style_Filter.latte number of selected options of a group for the badge on a collapsed heading; a multi-value field counts per item, buttons and hidden fields are not counted
getUploaderControlMap() Style_1.latte parameter_<id> → uploader_<id> for IMAGE_UPLOADER and FILE_UPLOADER parameters; Latte renders a dropzone for them

Badges and counts are built from the request parameters and from already loaded parameter entities, with no extra database query.

A REGION_PICKER field is served by RegionSuggestHostTrait (used by both AdvertForm and both AdvertFilterBox). The suggest box is a Multiplier keyed by the control name (regionSuggest-parameter_34), because the suggest! signal creates the instance itself without rendering the form. Its configuration therefore comes only from the mode: the form accepts only a municipality and prefills city, zip and country, the filter accepts a region, district or municipality and prefills nothing. The host must have $regionSuggestFactory and $regionManager.

A child with its own uploader must create the uploader_<id> component

getUploaderControlMap() assumes a child component named by uploaderName() exists for every uploader parameter. A child without uploaders overrides getUploaderParameterIds() to return [], otherwise the template looks for a component that does not exist.

Parameter management in the admin#

The parameter and parameter group screens of adverts, the company catalogue and the e-shop are one shared code base in app/Core/Base/Admin/Parameters/. A module provides only a thin leaf:

Shared What the module provides
AbstractParameterPresenter, AbstractParameterGroupPresenter $translatorDomain, the getters parameterManager(), parameterGroupManager(), parameterRuleManager(), parameterEntityClass(), parameterTextEntityClass()
AbstractParameterRuleManager (raw DBAL rules) a ParameterTableMap with the cms_mod_<module> prefix and cacheTagClasses()
AbstractOptionRules, AbstractRuleEditor leaf components and their factories in the module

The shared code must not import a module entity, so it creates entities through class-string getters. A module without rules (the e-shop) returns false from supportsRules() and the template hides the whole rule UI.

Procedure: adding parameter management to another module#

  1. A presenter extends AbstractParameterPresenter (and for groups AbstractParameterGroupPresenter) with $translatorDomain and the getters from the table.
  2. ParameterRuleManager extends AbstractParameterRuleManager with the module's table map.
  3. cacheTagClasses() returns the FQCNs of the Api entities Parameter and ParameterRule as strings (Bazaar is the pattern). The method is abstract on purpose: the module does not start without it.
  4. Leaf components OptionRules and RuleEditor with factories that inject the module managers.
  5. Click through creating a parameter, options on edit and a rule; check the front form and filter right after saving.

A leaf component needs its own constructor with the module managers

search: in config/common.neon registers every concrete class under app/UI as a service. A constructor inherited from AbstractOptionRules / AbstractRuleEditor is typed on the shared parents (AbstractParameterRuleManager, Manager), which have an implementation in every module. Container compilation then fails with "Multiple services found" and no page starts. The leaf therefore declares a constructor with the concrete managers of its module and calls parent::__construct(); example app/UI/Admin/Bazaar/Components/OptionRulesComponent/OptionRules.php.

Rules are written outside the bridge, only cacheTagClasses() invalidates the cache

Without the right tags the advert form and the list filter follow the old rule until the parameter service TTL expires (an hour).

A rule target carries both parameter_id and option_id

The front end (DependentSelectTrait) loses a rule without both keys. addTarget() fills them unconditionally; manual SQL on the targets table must too.

What a rule does is decided by its target, not by the action column (RulesTrait, DependentSelectTrait):

Target Effect
optionId filled reveals select options (dependent select)
parameterId or parameterGroupId, optionId empty shows/hides the field or group, or makes it conditionally required

The rule layer knows only interfaces, not module entities

RulesTrait and DependentSelectTrait work with ParameterRuleInterface (and the condition and target interfaces in app/Core/Base/Shared/Shared/Models/Entities/Interfaces/). A type check on one module's entity would silently drop the rules of other modules: the rule pool stays empty and hiding, requiredness and dependent selects are skipped without an error. A new module with rules therefore implements these interfaces.

\"In the default filter\" requires a search field type

The filter box skips a parameter with searchDefault and no searchType, so it silently does not appear in the filter. The form therefore rejects it on save; check data inserted outside the form.

The key search is reserved

The filter box adds the full-text field search and a URL parameter of the same name. The form rejects the key (case-insensitively).

Options can be edited only on edit

A new parameter has no lft/rgt yet, so the options editor renders after the first save.

Options are saved by the Api ParameterManager in afterSave() from $meta['options'] (OptionsSyncTrait::syncOptions(), in the e-shop and in adverts alike). An option is a child of the parameter in the tree (level = 2) and is written with raw DBAL in the same transaction:

Row in options What happens
optionId of an existing option UPDATE of default, search_default, active and the texts only; the id and the place in the tree stay
without optionId INSERT and placement as the last child (insertTreeNode())
an option missing from the list hard delete (deleteTreeNode()) including texts; the option_id foreign key deletes the linked rules
a text with an empty name no translation row is created and an existing one is deleted

A missing options key leaves the options alone. Only one option may be the default ("default" and "default in search"); the first marked one wins and is written to the parent's default_option_id / search_default_option_id.

Options must not be saved by deleting and inserting again

option_id is a key referenced by stored values and rules. A new id would break those links and the cascade would delete the rules. That is why existing options are updated in place.

An empty translation would override the fallback language

A row with name = '' in a foreign language takes precedence over the main language and the option would render without a name on that version of the site. A cleared name therefore means "fall back to the fallback language".

Do not delete an option you only want to hide

A removed option is gone for good, rules included. For temporary hiding there is the active checkbox.

Where to look#

I want Where
the custom controls and helpers app/Core/Forms/Form.php
the form factory app/Core/Forms/FormFactory.php
the custom inputs app/Core/Forms/Inputs/
the admin form skeleton app/Core/Base/Admin/BasePresenter.php → createForm()
the shared uploader app/UI/Admin/Base/Components/UploaderDropzoneComponent/
the editor sanitisation app/Core/Utils/Helpers/EditorHtmlSanitizer.php
editor height and moving editors www/themes/admin/*/desktop/assets/js/components/rich-editor.js (configFor(), refreshMovedEditors())
list and detail of a collection …/js/components/master-detail.js, example app/UI/Admin/Company/Templates/Company/edit.latte
form state app/UI/Admin/Base/Components/FormActionsComponent/, …/js/components/form-state.js
files and links of the full editor app/Core/Traits/Admin/Presenters/EditorUploadTrait.php, EditorLinkTrait.php
the dynamic parameters app/Core/Form/Parameters/ (handlers, ParameterHandlerRegistry), types ParameterType
the front form and filter over parameters app/UI/Front/Base/Components/ParameterFormComponent/ParameterForm.php, Traits/RegionSuggestHostTrait.php
the field gates app/Core/Forms/ColumnGate.php, StrictDateInput.php, MultiplierLimits.php; guardColumns() in app/Core/Base/Admin/BasePresenter.php; column limits app/Core/Utils/Schema/ColumnSchema.php
a blank translation language app/Core/Forms/TranslationInput.php
rejected files app/Core/Security/Upload/UploadRejection.php, ImageUploadSizeLimit.php
submit guards app/Core/Forms/ChoiceGuard.php, PickedRecordGuard.php, FormSubmissionGuard.php; guardFormSubmitOnce() and runSubmitOnce() in app/Core/Base/Admin/BasePresenter.php; submit-once.js, confirm-guard.js
quantity format app/Core/Utils/Helpers/Quantity.php (document wrapper QuantityFormat.php), Latte filter quantity in app/Core/Latte/Extensions/PriceLatteExtension.php, grid QuantityRenderer
the binding convention skills/code-standard/cs-conv-forms/, skills/code-standard/cs-form-setup/

Follow-up chapters: Admin presenter · Picker · Latte templates · Translations · Code conventions

Cookie settings