Repository navigation
feat: add an attribute-based form system on the view system - #6754
Merged
Merged
Conversation
A form edits a data object: its properties hold the values in their real types, attributes on them declare the field types (`#[Text]`, `#[Choice]`, `#[ArticlePicker]` …). `Form` binds such an object, reads the submitted values and writes them into the object once all of them are valid, and renders the whole form or single rows/widgets for custom markup. Forms can also be embedded into other forms. `ConfigStorage` persists a data object into a config namespace; other save routines just read the properties. The widgets are standalone view components, so they can be used without a form, too. None of the markup uses fragments or the RexVar widgets. `Translatable` holds a translation key resolved lazily, so translated labels can be passed to attributes. The system settings page is the first user; the test addon gets a page showing all field types for the visual tests.
Labels, notes, legends, titles and errors accept `string|Renderable`: a string is plain text, a `Translatable` renders like `I18n::msg()` (keeping basic tags of the message), an `Html` object passes trusted markup. Choice labels, placeholders and the submit label stay plain text, as they end up in contexts without HTML. `Translatable` is no longer `Stringable`, so the context has to be chosen explicitly: `render()` for HTML, `translate()` for plain text.
# Conflicts: # .tools/visual-tests/screenshots/markdown--dark.png # .tools/visual-tests/screenshots/markdown.png
`Number` edits `int` and `float` properties, with `min`/`max` checked on submit and `step` for the input element. `Text` is limited to `string` properties now and checks its `maxLength` on submit as well.
Besides `int` and `float` (for approximate values), a `Number` field edits `BcMath\Number` properties for exact decimals, if ext-bcmath is available. `min`, `max` and `step` accept numeric strings to stay exact as well.
The date, datetime and time pickers accept `min` and `max`, in any format `DateTimeImmutable` understands, so relative limits like `today` work. `Textarea` gets `maxLength`; line breaks count as one character like in the browser. Both are set on the input elements and checked on submit.
Both are text fields with the matching input type and a check on submit. `Url` accepts only absolute URLs with the given schemes, `http` and `https` by default, so that e.g. `javascript:` URLs never make it into links.
`capture()` renders the form tag around the markup a closure emits, so the opening and closing tags can not get out of sync. `open()`/`close()` stay for form tags that can not be rendered in one place. The form tag no longer gets an `action`, so the browser submits to the current url including its parameters; this also makes forms usable outside of the backend.
Without an explicit `key`, a field is stored under its property name in snake_case (`$maxItems` → `max_items`), matching the config keys and database columns. The system settings only keep explicit keys where the property is named differently on purpose. Form field attributes with more than the label are written with one argument per line, as noted in AGENTS.md.
This was referenced Oct 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a new form system built on the view system. It is meant to replace the old one (
DbForm,ConfigFormand the forms built from thecore/form/*fragments) without a migration path; the old system is removed before 6.0.Concept
A form edits a data object. Its properties hold the values in their real types, attributes declare the field types:
Submitted values are converted into the property types (scalars, backed enums,
BcMath\Number,Article,Media,DateTimeImmutable, lists). Invalid values never reach the object; they are kept as raw values and redisplayed with the error.The constraints of the field types (
required,maxLength, valid e-mail addresses and URLs,min/maxof numbers and dates, the offered choices, existing articles/media, allowed media types) are checked on submit; where HTML has an equivalent (required,maxlength,type="email"/type="url",min/max/step), it is set on the input element as well.Storages use the property name in snake_case as key (
$articleHistory→article_history), unless the field sets an explicitkey.Each field type declares which property types it supports and every field property needs a default value – both are checked when the class is read.
Choices and other dynamic arguments are passed as first-class callables (
self::locales(...)), labels asTranslatable(a lazily resolved translation key, usable in attributes).Texts: labels, notes, legends, titles and errors accept
string|Renderable– a string is plain text, aTranslatablerenders likeI18n::msg()(basic tags of the message are kept), anHtmlobject passes trusted markup. Choice labels, placeholders and the submit label are plain text (string|Translatable), as they end up in contexts without HTML.Translatableis deliberately notStringable:render()for HTML,translate()for plain text.Rendering:
render()for the whole form, orcapture()for custom markup – the closure writes the markup inside the form tag, usingrow()/widget()/label()/rest()/submitButton():open()/close()are available for form tags that can not be rendered in one place.#[Fieldset]groups fields in the default rendering.Embedding:
submit()binds given values without the standalone request handling (marker, csrf token),onlyrestricts a form to a subset of its fields – needed for meta info, which will follow in a separate PR.Field types and widgets
Text(withmaxLength),Email,Url(onlyhttp/httpsby default),Number(int,floatfor approximate values,BcMath\Numberfor exact decimals if ext-bcmath is available; withmin/max/step, also as numeric strings),Textarea(withmaxLength),Checkbox,Choice(select, multi-select, radio buttons, checkboxes, with groups),DatePicker,DateTimePicker,TimePicker(withmin/max, also relative liketoday),ArticlePickerandMediaPicker(single and multiple).The field types only bind the values; the markup comes from widgets (
Input,Textarea,SelectInput,ChoiceList,CheckboxInput,ArticleInput,ArticleListInput,MediaInput,MediaListInput) andFieldRow. They are standalone view components, so they can be used without a form as well (e.g. by yform), and their views can be overridden per class viaViewResolver. No fragments and no RexVar widgets are used. The picker widgets still follow theREX_LINKLIST_*/REX_MEDIA_*/REX_MEDIALIST_*element ids the linkmap and media pool scripts rely on.Users
ConfigStorage, the editor settings with their own cookie routine and custom markup.Not included yet