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.
Files and links in the full editor#
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
- Check that the target route needs only the
slugparameter (otherwise no link can be built). - Add the target to
EditorLinkTrait::EDITOR_PAGE_TARGETS(lowercase, e.g.'front:system:page:homepage'). - 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#
- A
FormTraitnext to the component, with acreateComponentForm(): Formmethod. - The skeleton per the example above.
- Split a longer form into
addXxxFields(Form $form)methods; populate selects inprepare*(). onSuccesswith 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();
}
}
- 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. - 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)#
- Prefill through
setDefaults()on the container, not by setting values on the individual controls. - On an already submitted form
addCopy(null, $defaults)does not work — it internally callsContainer::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
- 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 thensetValues()). - 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
- Server-side
MultiplierwithoutaddCreateButton()andaddRemoveButton(), minimum copies 0. - 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 namesimages[__INDEX__][…]. - Initialise controls in a new row (image preview etc.) on the
multiplier:row-addedevent the script fires on the new row and lets bubble (detail.rowis the new row). - Send the rows to the Api in
$metaunder the key the manager reads inafterSave(); 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 bothmaxlengthand the serverMAX_LENGTHrule (catches pasted input too).decimal()rejects exponent notation (1e1000→INF→ a database error in strict mode,0.00in non-strict mode) and measures the range after rounding to the columnscale, i.e. on what gets written (9999.995would otherwise be stored as10000.00). Negative numbers only withallowNegative: true. The optionalnegativeMessageparameter 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 useColumnGate::FRONT_NEGATIVE_PRICE_MESSAGE(example: the advert form,AdvertFormComponent/Traits/FormFieldsTrait.php). A value after theForm::FLOATrule (a float) is turned into plain notation withDecimal::fromFloat()by the gate,NumericRuleandColumnValue, so a small valid number (0.00001, in PHP1.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 maskTaxIdHelper::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#rrggbbshape: colour columns hold 7 characters and a longer notation would be truncated into a different colour, not an error.StrictDateInputrejects a nonexistent date (new DateTime('2026-02-30')silently makes it 2 March) and does not accept relative text such asnext 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→ propertytitle). - A field maps to a property by name, a multilingual one without the language suffix
(
name_2→nameof the translation entity). Entities are searched in the given order. - Limits:
varchar(n)/char(n)at most n characters,TEXTby its size in bytes, integers within the column range (unsignedincluded),decimal(p,s)after rounding to thescale. The limits come from the real schema (ColumnSchema), because mapping and database differ in places. - It runs in
onValidate;maxlengthis 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
activecheckbox included) gets no row. Blank meansnull,false, an empty or whitespace-only string, an empty array and an upload without a file;0counts 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#
- 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 inColumnLimits. - On the front end pass the message
ColumnGate::FRONT_*_MESSAGE. - Handle mandatory filling with
setRequired(); the gates let an empty optional field through. - For
datetime-local, on save callStrictDateInput::keepSeconds(StrictDateInput::parseDatetimeLocal($v), $entity->getX()). - 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
addandadd<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.
- Put the token into the form as a hidden field.
- 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. - 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 byApiBridge::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:
- 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. - Which button submitted the form? In the administration only
saveandupdatesave; a custom submit runs but does not save. - Does the handler take
ArrayHash $values? See themixedwarning above. - Does the button have
setValidationScope([])? Then the values are empty. - Does the template render every field? An unrendered field is saved as empty.
- Did the manager return
isSuccess()? A failure without a flash message is silent. - 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#
- A constant in
ParameterTypeand a name inParameterType::TYPE_NAMES— the admin takes the field and filter type options from there. - A handler
extends AbstractParameterHandlerwithgetTypes()andbuildField(); override only the phases where the type differs from one scalar field. - Registration in
ParameterHandlerRegistry::createDefault(). - If it needs custom rendering, set the control option
widgetand add a branch toapp/UI/Front/Base/Components/ParameterFormComponent/Templates/Default/Style_1.latte(form),Style_Filter.latte(filter) and to the module's admin_form.latte. - If
formatValue()returns an object, map the type to a template in theInitializationTraitof theAdvertParameterListComponent(classifieds, catalogue). - 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#
- A presenter
extends AbstractParameterPresenter(and for groupsAbstractParameterGroupPresenter) with$translatorDomainand the getters from the table. ParameterRuleManager extends AbstractParameterRuleManagerwith the module's table map.cacheTagClasses()returns the FQCNs of the Api entitiesParameterandParameterRuleas strings (Bazaar is the pattern). The method is abstract on purpose: the module does not start without it.- Leaf components
OptionRulesandRuleEditorwith factories that inject the module managers. - 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