Problem/Motivation
Follow-up for the ancient #1921996: Convert system_config_form() to implement FormInterface as a base class.!
Extracted from #2164373-16: [META] Untie config validation from form validation — enables validatable Recipes, decoupled admin UIs … from >4 years ago.
The CDN contrib module has been using this successfully for >4 years: #2969065: Use typed config validation constraints for validation of cdn.settings simple config.
It's currently very difficult to get started with using validation constraints for Simple Configuration. Defining the validation constraints is one thing (and simpler to test since https://www.drupal.org/project/config_inspector/releases/2.1.1), but then how to use them?
Steps to reproduce
N/A
Proposed resolution
Allow each config form (that subclasses ConfigFormBase) to opt in whenever they want, by overriding the default
/**
* Copies form values to Config keys.
*
* This should not change existing Config key-value pairs that are not being
* edited by this form.
*
* @param \Drupal\Core\Config\Config $config
* The configuration being edited.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*
* @see \Drupal\Core\Entity\EntityForm::copyFormValuesToEntity()
*/
protected static function copyFormValuesToConfig(Config $config, FormStateInterface $form_state): void {
@trigger_error('Implementing form-coupled validation logic is deprecated in drupal:10.2.0 and will trigger a PHP error from drupal:11.0.0. Implement ::copyFormValuesToConfig() instead, which will become an abstract method on ConfigFormBase. See https://www.drupal.org/node/3373502', E_USER_DEPRECATED);
// This allows ::submitForm() and ::validateForm() to know that this config
// form is not yet using constraint-based validation.
throw new \BadMethodCallException();
}
(That exception is how ConfigFormBase is able to detect which forms have opted in.)
Usually, you'd also need to override the default
/**
* Maps the given Config key to a form element name.
*
* @param string $config_name
* The name of the Config whose value triggered a validation error.
* @param string $key
* The Config key that triggered a validation error (which corresponds to a
* property path on the validation constraint violation).
*
* @return string
* The corresponding form element name.
*/
protected static function mapConfigKeyToFormElementName(string $config_name, string $key) : string {
return self::defaultMapConfigKeyToFormElementName($config_name, $key);
}
/**
* Default implementation for ::mapConfigKeyToFormElementName().
*
* Suitable when the configuration is mapped 1:1 to form elements: when the
* keys in the Config match the form element names exactly.
*
* @param string $config_name
* The name of the Config whose value triggered a validation error.
* @param string $key
* The Config key that triggered a validation error (which corresponds to a
* property path on the validation constraint violation).
*
* @return string
* The corresponding form element name.
*/
final protected static function defaultMapConfigKeyToFormElementName(string $config_name, string $key) : string {
return str_replace('.', '][', $key);
}
Finally, in the quite common case of a type: sequence that is represented by a <textarea> in the UI, it's important to be able to generate an appropriate single message. By default, a message like Entry 1: "http://example.com" is not a valid email address. Entry 3: "http://example.com/sdfsdf" is not a valid email address.
is generated:
protected function formatMultipleViolationsMessage(string $form_element_name, array $violations): TranslatableMarkup {
$transformed_message_parts = [];
foreach ($violations as $index => $violation) {
// Note that `@validation-error-message` (should) already contain a
// trailing period, hence it is intentionally absent here.
$transformed_message_parts[] = $this->t('Entry @human-index: @validation-error-message', [
// Humans start counting from 1, not 0.
'@human-index' => $index + 1,
// Translators may not necessarily know what "violation constraint
// messages" are, but they definitely know "validation errors".
'@validation-error-message' => $violation->getMessage(),
]);
}
return new TranslatableMarkup(implode("\n", $transformed_message_parts));
}
which you can also override, using knowledge about the data being saved & validated, as well as knowledge about the structure of this very form we're adding this method to:
protected function formatMultipleViolationsMessage(string $form_element_name, array $violations): TranslatableMarkup {
if ($form_element_name !== 'update_notify_emails') {
return parent::formatMultipleViolationsMessage($form_element_name, $violations);
}
$invalid_email_addresses = [];
foreach ($violations as $violation) {
$invalid_email_addresses[] = $violation->getInvalidValue();
}
return $this->t('%emails are not valid email addresses.', ['%emails' => implode(', ', $invalid_email_addresses)]);
}
Remaining tasks
Review.
User interface changes
Update settings form:
- Before

- Before — with Inline Form Errors installed

- After

- After — with Inline Form Errors installed

API changes
None. Only addition.
Data model changes
None.
Release notes snippet
Existing (simple) configuration forms (subclasses of ConfigFormBase) can opt in to use config validation by overriding copyFormValuesToConfig(), after adding the necessary validation constraints to their config schema. When all modules adopt this, this will allow all of a site's configuration to be validated at once, including prior to commit, during CI, et cetera.
| Comment | File | Size | Author |
|---|---|---|---|
| #108 | 3364506-108.patch | 53.77 KB | wim leers |
| #108 | interdiff.txt | 3.85 KB | wim leers |
| #46 | before — inline_form_errors.png | 172.54 KB | wim leers |
| #46 | before.png | 163.97 KB | wim leers |
| #100 | after.png | 192.18 KB | wim leers |
Comments
Comment #2
wim leersComment #3
wim leersNow, a demo of how to actually use it. And to show that gradual adoption is totally possible.
user.settings:anonymousis missing validation constraints:So let's define it:
Then simply changing
and moving some logic out of
::submitForm()(which we'll eventually refactor away, butAccountSettingsFormedits THREE different Simple Configs!):and into the new
mapFormValuesToConfig():is all we need to get validation constraints working:
🥳
Comment #4
wim leerscspell.Comment #5
borisson_This doesn't seem like it is specific to the validate version of this?
Comment #6
smustgrave commentedTried testing this out by adding an emoji to the anonymous user field and it saves vs throwing an error.
Comment #7
wim leers#5: Can you elaborate? I'm not sure what the point is that you're making 😅
The regex is not forbidding emojis completely, it's only forbidding a value that contains onlyemoji 😊
#5:
Comment #8
borisson_@Wim Leers, I wasn't sure about if we should include that code in this class or if it should be in the base class instead since it seems to be something that shouldn't be specific to this being a validatable config form.
Comment #9
wim leersFixing issue tag 😅
This is the base class, isn't it? It can't live in the parent class (
ConfigFormBase) because themapFormValuesToConfig()method it calls lives inValidatableConfigFormBase, notConfigFormBase.Maybe you're suggesting that
mapFormValuesToConfig()should be added toConfigFormBaseinstead? We can't do that, unfortunately, because it'd be a BC break.Comment #10
smustgrave commented@Wim Leers that's what I mean. I added just an emoji to the field and it still saved.
Comment #11
smustgrave commentedRetested and the error is correctly shown. So there is proof this approach is working
Talking with @Wim Leers at DrupalCon talked about adding 1 converted form as an example and landed on the Media Settings Form.
Comment #12
borisson_I watched the talk you did at drupalcon, I now understand more the usecase for this form, and I agree with @smustgrave that this should be implemented in at least one form to land.
Comment #13
nickdickinsonwildeNot a *hard* contrib blocker, but there are now contrib modules (Spambot for example) that would like to be using this instead of implementing it locally/copying.
Comment #14
dave reidFor anyone in contrib that wants to use this base form, I added it to the Helper module in #3367122: Add ValidatableConfigFormBase from upcoming core issue.
I do wish we didn't necessarily need a new form base and could opt-in with the existing one though.
Comment #15
wim leers@Dave Reid: oh, interesting idea! No strong feelings. This felt logical/less complex, but perhaps what you propose would be easier for the ecosystem to adopt?
Comment #16
wim leersAddressing the feedback from @borisson_ and @smustgrave…
Comment #17
wim leersIn order to use the media settings form for this, I'd need to either modify the
media.schema.ymlfile (i.e. its config schema) or the defaultiframe_domain: ''value, because the default value (the empty string) is a violation of the config schema type 😅 That would be out of scope here. It's in scope of #3361534: KernelTestBase::$strictConfigSchema = TRUE and BrowserTestBase::$strictConfigSchema = TRUE do not actually strictly validate.So I'm going for
UpdateSettingsForminstead!First, let's add a test for that form 😅
Comment #18
wim leers#17 should pass tests — assuming it does, that establishes the baseline. We need to match the same UX while removing the validation logic at
UpdateSettingsForm::validateForm().The interdiff:
ValidatableConfigFormBasecspelldictionaryUpdateSettingsFormto useValidatableConfigFormBase→ results in1 file changed, 19 insertions(+), 68 deletions(-)🤩Emailconstraint to thetype: emailconfig schema typeThe 4th point means a system-wide change, but nothing is executing validation yet, so it should be fine. Also …
type: emailcontaining valid e-mails doesn't seem like a controversial change? 😅 But, still, if preferred, I could remove it from there and instead add this constraint toupdate.schema.yml's schema forupdate.settings.Comment #19
smustgrave commentedApplied the patch on a Drupal 11.x with a standard install.
I can't save the form with a valid email "smustgrave@gmail.com" my d.o. one I get
This value should be of the correct primitive type.Adding "http://example.com" I get
Can't seem to save the form.
Comment #20
wim leersis due to
in the default config at
core/modules/update/config/install/update.settings.ymlwhich hastype: uriin the config schema, which the above patch has solved by setting:😬
You're right though that this won't be acceptable for Drupal core. My bad!
So I have two choices:
ValidatableConfigFormBaseto allow certain property paths to be exempted, under the guise of "well, these property paths aren't editable anyway via this form!". The problem with that is that it's totally possible that there's validation logic that covers a combination of property paths. So config validation really should always be applied on the entire config. Otherwise we open up a wholly new can of worms…So, going with choice 1.
Comment #21
wim leersThis should reproduce what @smustgrave reported.
Comment #22
wim leersUpdate path test: expected to add one more failure to the sole failure I'm expecting for #21.
Comment #23
wim leersFinally: the update path!
Comment #24
wim leersComment #25
wim leers"smustgrave@gmail.com " is not a valid email address.👈 I cannot reproduce this on latest11.xwith a freshcomposer installon PHP 8.1. And neither can testbot on PHP 8.2. Any chance this was PEBKAC? 😅FYI this is identical to the pattern in
system_install().Comment #30
wim leersAs of #23, the 2 previously failing tests are passing, but now there's a sole new failure:
Easy fix!
Comment #31
smustgrave commentedIssue previously reported seems resolved!
Comment #32
lauriiiLooks like custom commands failed
Comment #33
lauriiiIt looks like this could benefit #2408549: Display status message on configuration forms when there are overridden values because it's trying to implement different approach for mapping form elements to config.
Comment #34
wim leersCustom commands failed on a new policy introduced in the last few days:
🙃
Fixed by doing
s/e-mail/email/Comment #35
wim leersThis would indeed enormously simplify that.
Comment #37
wim leersHah! #3361534: KernelTestBase::$strictConfigSchema = TRUE and BrowserTestBase::$strictConfigSchema = TRUE do not actually strictly validate landed and already did the hard work us of providing the upgrade path for the invalid default
update.settingsconfig! 🥳Rerolled, this is now even simpler, and especially less distracting! 😊
Self re-RTBC'ing because I literally only deleted code 😁
Comment #38
wim leersRerolled, the patch stopped applying today. No conflicts.
Comment #39
wim leers@lauriii has indicated in Slack that this is missing and hard-blocking commit. We need a review from either @tim.plunkett or @effulgentsia, because both of them are maintainers of the
Form APIandPluginsubsystems perMAINTAINERS.txt, which makes them ideally suited.Comment #40
effulgentsia commentedThis looks really good. I'm happy to sign off on this as subsystem maintainer. Things I noticed while reviewing:
With the patch, I think we're losing info if multiple email addresses are invalid, because while I think the replacement logic in
ValidatableConfigFormBasewould call$form_state->setErrorByName()for each email in the sequence, the implementation of setErrorByName() can only set a single error per form element name. Or, does our constraint validator handle merging violations of multiple items in a sequence into a single violation that contains info about all of them?Related to above, interesting that the config property path is
notification.emails, notnotification.emails.#. Perhaps the validator is merging violations of multiple items of a sequence into a single one?Is there a use-case for ever returning a different $config object than the one that was passed in? Or is the pattern to always mutate the $config that was passed in? If the latter, should we change the return type to void to help clarify that?
In the docs, s/form name/form element name/. Also I think the @return doc should expand on that to say that in this context the form element name should be the full #parents path of the form element as documented in
FormStateInterface::setErrorByName().Comment #41
tim.plunkettNit: trailing comma, please
Consider adding some assertion here to ensure the config hasn't already been saved (it shouldn't be!)
This is called once per editable config, and while this implementation only has 1 available, I think as the first and only example in core this should have a switch/match/if on
$config->getName()to demonstrate how that would need to be handledThe
implements ContainerInjectionInterfacewas never needed, but now it's confusing without thecreate()overrideI'm guessing it's because it's the only one we know has any config validation, but why is this the only one mapped? The other 3 values have mismatches too. I think as the example form being converted, this should establish a pattern for doing this for all 4 properties (whether it's switch/match/if)
I was about to write a comment about the mutable vs returned Config but I see @effulgentsia brought it up just now.
Comment #42
wim leers#40
void✅#41.2: Now that I tried to implement that, I'm not actually sure we can do this? The only way could theoretically work is:
… but that would not work in case nothing is being modified 😅
#41: 1 + 3 + 4 + 5: done ✅
Will address @effulgentsia's remaining feedback tomorrow, curious about thoughts regarding #41.2 🤓
Comment #43
wim leersTo address #40's remarks wrt validation, let's resurface the test-only patch from #17. (Since then, a few simplifications landed in that test coverage in the patches above, but only in the
setUp()part, I won't show those in the interdiff, only the changes relevant to #40.)In testing this, I found another edge case bug:
\rwas not being trimmed from individual lines! 😅 Added test coverage for that too.Comment #44
wim leersAlright, #43 proves that those things are nicely handled in
HEAD'sUpdateSettingsForm👍Now it's up to me to match that UX in this refactored implementation 🤓
Let's first apply the updated test coverage to the main patch: now its test coverage should fail, which would confirm the bugs that @effulgentsia discovered.
Comment #46
wim leersGreat, #44 failed as expected 👍 Specifically, it failed on the test coverage that verifies that the error message is associated with a specific form element (so basically a consequence of the first two points of @effulgentsia's review at #40).
Now, let's fix this entirely.
There are essentially two tricky things at play here:
mapConfigPropertyPathToFormElementName()should specifically be aware of sequences, and it wasn't until now. 🙈 I really tried to use PHP 8'smatch (…) {…}to our benefit here, but unfortunately it's either a match on the parameter you pass intomatch (…)(which prevents partial string matches like we'd need here) OR you hardcodematch (TRUE)and then specify a lot of expressions, which reduces readability a lot. So I went with the best balance I could find — and I tried many possible approaches 😅 Really curious what y'all think!\Drupal\Core\Form\ValidatableConfigFormBase::validateForm()a LOT smarter when it comes to dealing with the relatively common "type: sequenceis mapped to a single form element" case that we run into here, where we also run into the (reasonable!) limitation thatFormState::setErrorByName()has: only a single validation error message can be associated with a particular form element.Basically: detect when >1 validation constraint violation targets the same form element, and whenever that happens, use the index in the sequence to actually provide better (more precise) validation error messages! 🤓🤩 Even more curious what y'all think about that one!
Comment #47
wim leersAnd of course, #46 will fail because
does not match what you see in #46. But it changes for the better: it becomes more precise!
See the attached interdiff :)
Comment #49
borisson_We need to remove the change to the drupalci.yml again, to see what happens with all the tests.
Comment #50
wim leersDone.
Comment #51
borisson_This already had a subsystem maintainer review, and the change here doesn't break anything until we use that base class for more things. Implementing this for more forms would be great, but should be done in followups. Back to rtbc.
Comment #52
tim.plunkettThanks @Wim Leers for addressing my (and @effulgentsia's) feedback! +1 to RTBC
Comment #53
catchSorry I'm coming in late with a question...
There's discussion above about whether this could an opt-in on the base class, that was also my first thought when I saw the issue title.
First of all I thought about the base class implementing mapConfigPropertyPathToFormElementName(), and using an interface to determine whether mapFormValuesToConfig was implemented. The problem with that is mapFormValuesToConfig() is protected and it would have to be public, so not great.
But... traits can have abstract methods, so would that work?
It would mean something like:
subclasses use the trait.
ConfigFormBase checks method_exists(static::mapFormValuesToConfig()) and if it exists, then we know the trait is being used and can do the validation steps. Or it could check an empty interface like ValidatableConfigFormInterface.
The benefit would be that if you extend from a form that extends from ConfigFormBase, you could still use this.
My other question is 'This value should be of the correct primitive type' when does this show up?
Comment #54
wim leersInteresting. Nobody surfaced the trait route. It’s at least theoretically possible:https://3v4l.org/7fIUk 👍
@Dave Reid had asked for opt-in in #14. Tim didn’t mention it in #41, but he did consider that too, he thought this was simpler.
I’ve just checked it, and even abstract methods on traits work just fine: https://3v4l.org/ASMl2. So … yes, possible! 😄
👨💻 So I went ahead and implemented it …👨💻 Conclusions:
mapConfigPropertyPathToFormElementName()default implementation is just that, a default, a fallback. Pretty much every form would have to override this. Why? Because if they don't, that means they'd be literally exposing the (internal) config data structure to the (external) user interface! 😱🙈Unfortunately, if you want to call a trait method, you need to explicitly alias it:
and then call it using the alias. Brittleness #1 This would apply to 99% of uses of this trait.
parent::something()calls the base class's method, not the trait's! Which in this case means that you are callingConfigFormBase::submitForm()and notValidatableConfigFormTrait::submitForm(), which would result in the form not actually saving anything (I know this because the test failed and it took me 15 mins to figure this out 😬🤷♂️).So here, you'd need to do:
Brittleness #2.
RE: other question: that shows up if you haven't applied DB updates. The
update.settingsdefault config was fixed in #3361534: KernelTestBase::$strictConfigSchema = TRUE and BrowserTestBase::$strictConfigSchema = TRUE do not actually strictly validate and comes with an update path 😇Comment #55
catchI don't think this is right. A lot of classes would need to override mapConfigPropertyPathToFormElementName(), but since it's just a one-liner, I don't think they'd ever have to call the raw trait method from their override or from anywhere else?
If you define a method from a trait in your class, then it overrides, no aliases required.
I'm not suggesting overriding any of ConfigFormBase's existing methods in the trait.
What I was suggesting was providing a trait with only the two new methods (the abstract and non-abstract one). In ConfigFormBase it would then have to use an (empty) interface or method_exists() to determine whether the form is validatable or not - directly in its own methods that it already has. So if you call parent:: it'll just be ConfigFormBase's stuff.
Comment #56
catchAhh OK, so UpdateSettingsForm is doing that.
However that seems solvable with two methods - one (final) method that does the logic, and one that calls it but can be overridden.
Comment #57
wim leersI like it! 😄
Comment #58
wim leersDone! I think this is what you had in mind? 😊 That indeed completely removes "brittleness #1" I was worried about in #54! 👍😄 Wish I had thought of this.
(Had to remove the
readonly, that triggered that PHPStan error.)Comment #59
wim leersNow implemented that too, which indeed completely removes "brittleness #2". It's sort of a hybrid between what @Dave Reid suggested balanced against the need to have an
abstractmethod to ensure a good DX.This means the code changes to adopt this become even simpler! 🤩
Comment #60
wim leersUpdated issue summary and change record.
Comment #61
wim leersOops 😅
Comment #62
catchThanks! This is much more what I hand in mind, and seems like it worked out OK.
Two minor nits/questions:
Why is it OK to call ::mapFormValuesToConfig() here without checking method_exists()? edit: crosspost, fixed above.
Here though I see it's using class_uses() before calling the method, but also I'm not sure we can use class_uses().
https://www.php.net/manual/en/function.class-uses.php - this only checks the actual current class.
So if you subclass a config form that uses the trait, and you don't explicitly add the trait yourself, this will return FALSE. Can we just use method_exists() though?
Comment #63
wim leers#62:
Damn, you're right: https://3v4l.org/odajL 😱
Oh PHP 😅
We could do https://www.php.net/manual/en/function.class-uses.php#110752 or https://www.php.net/manual/en/function.class-uses.php#125274, but that's far more complicated than a simple
method_exists().Done! 👍
Comment #64
borisson_The changes requested in #62 have both been fixed, this new version is now much more in line with how I figured it was going to work, introducing this in contrib or a new form in core is now much easier as well. This is great. It's now as easy as using the trait and implementing
mapFormValuesToConfiginstead of having to use the new base class.Comment #66
lauriiiLooks like a known random fail
Comment #67
catchComment #68
wim leersUnassigning @catch because I know he's on vacation 🏝️😊
P.S.: as #3341682-68: New config schema data type: `required_label` shows, there's often a mismatch between which form elements are required/optional vs which config is required/optional. The introduction of
mapConfigPropertyPathToFormElementName()in this issue would in theory enable us to e.g. show warnings to developers whensystem.logging:error_levelis set toverboseor when assertions are enabled — that'd help more developers keep their forms in sync with their config, which would result in Drupal being perceived as more reliable! 😊 Just one of many things this issue helps enable!Comment #69
lauriiiThe current solution with trait looks pretty nice! I'm still wondering how does the current solution integrate with #2408549: Display status message on configuration forms when there are overridden values which needs to map config with the form as well? I don't think it should have to implement its own trait, but using
ValidatableConfigFormTraitfor mapping config to form seems a bit off too. Since the trait doesn't do the actual validation, and mapping config to the form can enable several different functionalities, maybe we need to rename the trait into something more generic?Comment #70
catchNeeds review for #69 however there's another problem too. ConfigFormBase now relies on the existence of the methods to determine whether to validate or not, if we use it for other things, then we can't use the existence of those methods as an explicit opt-in to being validatable.
One option would be to make the trait naming more generic, but additionally add an empty interface so that forms can indicate they're validatable that way - they'd still need to use the trait too though.
Comment #71
wim leers@lauriii in #69:
See #2408549-178: Display status message on configuration forms when there are overridden values. Adding override indicators would be as simple as (pseudocode):
I don't see the problem in the name
ValidatableConfigFormTrait: it is using far richer validation than >90% of config forms — because >90% of config forms have either zero validation, and AFAICT none have full validation.I agree it'd be slightly awkward to have to
use ValidatableConfigFormTraitto get override indicators. But is that really such a big deal? Won't we eventually rollValidatableConfigFormTraitintoConfigFormBase? Isn't it just a way to allow config forms to gradually start opting in?IMHO we can put the "override indicator" logic in
ConfigFormBaseitself, just like we're doing here for 90% of the logic: the trait is at this point just a way to opt in and ensure the necessary logic is written, by having thatabstractmethod. We can expand the documentation addition we have onConfigFormBase:We could add
To provide override indicators on the form, ValidatableConfigFormTrait must be used as well.— wouldn't that be sufficient? 🤔@catch in #70: I think an even more generic trait name would be more confusing. Adding an interface to the mix (so: trait + interface) would make it more confusing to adopt IMHO.
Adjusted proposal 🤓
Would it be simpler to remove the trait altogether, and instead:
final protected static function defaultMapConfigPropertyPathToFormElementNameintoConfigFormBaseas-isprotected static function mapConfigPropertyPathToFormElementName(string $config_name, string $property_path) : stringintoConfigFormBaseas-isabstract protected static function mapFormValuesToConfig(FormStateInterface $form_state, Config $config): void;to a newValidatableConfigFormInterfaceand make it non-abstract and public ⚠️ (interfaces cannot have private/protected methods!)That'd mean every form that wants to adopt this would have to:
… which is not a whole lot different to #63's 1) use the trait, 2) implement the abstract method
That would satisfy @Dave Reid's concern 😅 My only concern is that this feels like a rather silly interface, for one tiny concern, so it doesn't feel cohesive. And we'd still want to do the override indicator stuff automatically, to ensure as many right forms as possible would benefit, right?
Comment #72
wim leersAdjusted adjusted proposal 😅
What if we omit the interface altogether?!
What if we did this as step 3 instead:
abstract protected static function mapFormValuesToConfig(FormStateInterface $form_state, Config $config): void;toConfigFormBaseand make it non-abstract, keep it protected, but make its default implementationthrow new SomeException, and use that as a way to detect if the current config form is using validation constraints or not!The implication of putting this into
ConfigFormBaseitself is that we want eventually all config forms to adopt this. Which … I think we do anyway?! Who doesn't want override indicators? And to get to the full potential of a more reliable Drupal, all config should be validatable using validation constraints, eventually. So we could … trigger a deprecation notice, to encourage people to adopt this 😊 That'd actually be a major improvement over #63 — at least for me, when looking at this from config schema validation angles 🤓Curious what y'all think!
Despite how I probably appear, I don't feel very strongly about the exact implementation for
ConfigFormBaseand friends. I care especially about the end result: precise validation, consistently in the UI and through the API, resulting in improved reliability of Drupal, and indirectly enabling additional UX improvements, such as the override indicators of #2408549: Display status message on configuration forms when there are overridden values.Comment #73
wim leersFixed #71. (PHPStan often refuses to run locally…)
Comment #75
wim leersFixed #72.
Comment #76
wim leersIf @catch and @lauriii like either of the new approaches better, the IS + CR need to be updated.
Comment #78
wim leersYAYYYY that worked!
👍
Will add that to the ignored deprecation notices tomorrow, ready for review 😊
Comment #79
effulgentsia commentedI like the idea of moving the concept and implementation of validating the config that's edited by a form into
ConfigFormBaseitself. Seems like the question then is how do forms gradually opt into that. #75's approach is for the form to implementmapFormValuesToConfig().I'd like to propose that instead of that, perhaps it would be better DX for the form to implement just a static property? Like so:
Then individual forms wouldn't need to implement
mapFormValuesToConfig()ormapConfigPropertyPathToFormElementName()unless they're doing something exotic that can't be expressed declaratively in$formElementNamesToConfigKeys, because ConfigFormBase's implementations could implement what is declared in$formElementNamesToConfigKeys.In the above example,
MultilineToSequencemight be a class that's in\Drupal\Core\Form\Confg\and that implements something like\Drupal\Core\Form\Confg\FormValueToConfigValueInterfacecontaining 3 methods:As we convert other forms, we'll probably discover additional form value <=> config value transformations that we'll need to create the corresponding classes for that implement this interface.
As a bonus, in the future (not necessarily in this issue), a declarative
$formElementNamesToConfigKeysproperty as opposed to an imperativemapFormValuesToConfig()method would allow us to change:to:
or even remove that line entirely and have
ConfigFormBaseautomatically populate it :)Comment #80
aayushi.kachhwaha commentedReviews #69. Works fine!
Comment #81
wim leers#79: What you describe/show there (a static property on the form class), has always been my ideal DX scenario! 😄🤝
I just never saw a way we could implement it that was too complicated DX-wise. 😞
I'm still not entirely convinced that your proposal would result in a good DX 😅 But I'd love to give it a try.
More worryingly, I don't think it can work for everything. What if the form UI is entirely human concepts and nothing in the UI maps 1:1 to key-value pairs in config? What if you have to combine multiple form elements' values to a single value for a key in config?
I'd like to hear from a few more people what they think about @effulgentsia's very interesting proposal before I start writing code 🙏
Comment #82
lauriiiI can see how #79 could simplify the mappings, especially for simple use cases. The downside of this is that we are introducing a new array structure that needs to be learned – you would have to read the docs to understand how it works. With
mapFormValuesToConfig()andmapConfigPropertyPathToFormElementName(), you are essentially just writing code, meaning that your IDE can support you.For this reason, I personally might still prefer the approach of defining
mapFormValuesToConfig()andmapConfigPropertyPathToFormElementName()instead of defining them in the static property. I'm not against adding the static property but if there's a risk of derailing this issue, maybe it's something we could provide in a follow-up issue? It seems that it would be a pure DX addition on top of #75.Comment #83
borisson_I agree with #82, learning another new magic array structure doesn't sound like fun to me. This could be added a followup later and I don't think the DX win is very big compared to what we already have in this issue today.
Comment #84
wim leersGood points! I hadn't realized that we could indeed implement it as a "nice syntactic sugar-esque" DX enhancement on top of
mapFormValuesToConfig().I think a
OneToOneConfigFormTraitwould be a great name for this. That would be a GREAT way to start creating a config form ("to get up and running"), and simultaneously a superb indicator of a likely not-so-great admin UI! 😄If @effulgentsia is on board for that, I'll create a follow-up for this. Tagging to ensure we don't forget.
Updated #72, which failed tests only because lots of the new deprecation notice. Updated
core/.deprecation-ignore.txt(and fixed a typo:and and→and).IS + CR updated for the current patch.
Comment #85
wim leersFollow-up for #79 created: #3382510: Introduce a new #config_target Form API property to make it super simple to use validation constraints on simple config forms, and adopt it in several core config forms.
Comment #86
catchSo in 11.x would we make this method abstract? If so we could probably explicitly say that in the deprecation error.
What happens if you implement the method but don't map all your config (I know the answer is in the patch somewhere...). If the answer is that it doesn't really do anything, I'm wondering if we might want to make the new method abstract in 12.x, but then in 12.x also trigger a deprecation if the mapping is incomplete (if we can?). That might reduce temptations to do a 'fake' implementation.
Comment #87
wim leersYes. Done 👍
There's two directions, so a two-part answer:
mapFormValuesToConfig()implementation. I don't see how we can automatically detect this: a form may only allow editing some subset of the config, a form may be altered, there could be complex transformation logic from one form element to many config property paths, et cetera.mapConfigPropertyPathToFormElementName()override callsdefaultMapConfigPropertyPathToFormElementName()per the recommendations, then its fallback logic will run:The return value of that is passed to
FormState::setErrorByName(). An non-existent form element name will just result in the error being shown at the form level, without being associated with a particular form element.This could theoretically be updated to scan the form to actually find the form element. But that'd frankly be something Form API should do. In fact, that'd still be a net DX improvement for Form API too!
Conclusion: only when there's less dynamism/less logic involved, can we detect these problems. And that's what #3382510: Introduce a new #config_target Form API property to make it super simple to use validation constraints on simple config forms, and adopt it in several core config forms will provide.
I very much agree that'd be wonderful to do! But:
Comment #88
wim leers@catch indicated in Drupal Slack that he's worried about contrib modules adopting this and providing bogus implementations.
For
mapConfigPropertyPathToFormElementName()that's not a major concern because:stringreturn type forces something sensible to be returnedFor
mapFormValuesToConfig(), it's a bigger concern because the givenConfigobject must be updated. Fortunately, we can quite easily detect the case that it's a bogus implementation: if none of the edited config objects are in fact being updated!So, added detection logic for that, which will now throw a
\LogicExceptionto nudge developers in the right direction. For forms editing a single config it'll generate an exception likeand for multiple:
Comment #89
effulgentsia commentedWhat if only 1 email address is invalid, but it's not the 1st (0th) one that's in the textarea?
I think we should put an
if ($config_name === 'update.settings')check around this. Technically not needed since that's the only config that's ingetEditableConfigNames(), but I think it would both be more robust and be more clear to have another explicit check. Or would you like to argue against that?Do we want this to be public? Or is that just a leftover from prior iterations where this was on a trait/interface?
This might be bikeshedding, so I won't hold up RTBC/commit on it, but FWIW, there's something about both of these method names starting with
map, but one of them returning its result and the other one mutating an argument, that bothers me. Also, while thePropertyPathterminology comes from Symfony'sConstraintViolationInterface, I don't think we need to expose simple config forms to that internal terminology. Within$config->get(), we simply call that akey. Therefore, perhaps alternate method names could be:setConfigFromFormValues()andgetFormElementNameFromConfigKey()?copyFormValuesToConfig()andmapConfigKeyToFormElementName()?Note that
EntityFormhas acopyFormValuesToEntity()method, which might make option 2 a little nicer, though in that case it might also be good to match the parameter order of that method (i.e.,$config, $form, $form_state).Comment #90
wim leersDiscussed #88 with @catch. He actually did not intend for that to happen in this issue. Doing that kind of validation is great for DX, but is also kind of new ground. We could do it using
assert()but that'd reach fewer developers than the exception I added. So both @catch and I see quite a lot of bikeshed potential that we both agree should not stand in the way of this issue landing. So, deferred #88 to a follow-up: #3384123: Detect invalid `copyFormValuesToConfig()` implementations and provide helpful error messages.Comment #91
wim leersSo, to address #89, I'm posting a patch with an interdiff relative to #87.
#89:
mapFormValuesToConfig(), the code is providing that generic use case 🤔 So … let's match that same pattern? 😊$formsince it's not needed (and risks introducing side effects — which is less likely in the case of content entity forms since only a few dozen people in the world ever write field widgets). Added@sees in both directions, to help ease the learning curve, and show that that there's a pattern 😊Addressed everything @effulgentsia raised in #89, but didn't fix the error yet, to prove the added test coverage works.
P.S.: caveat for point 2: looks like
phpcsdoes not correctly handlematchinside aswitchyet, so I was forced by PHPCS to format it in a really weird way. This is happening despite the latest PHPCS3.7.2being installed 🤷♂️Comment #92
catch+1 to the new naming.
Comment #93
wim leersAnd finally, the fix for #89.1 👍
Comment #94
lauriiiThis feels a bit verbose compared to the current message. For example, currently if I had two invalid errors, these would be rendered as "invalid, another invalid are not valid email addresses.", but with this it would be "Entry 2: "invalid" is not a valid email address. Entry 3: "another invalid" is not a valid email address.".
Tagging with usability since there are at least some usability implications from this.
Comment #95
wim leers@lauriii That was requested by core committer @effulgentsia in #40 to improve usability 🫣
See the before vs after screenshots in #46 and the issue summary. How can this not be seen as an improvement? Before you'd have to go find the entries by hand, now it says clearly which ones!
It is indeed slightly more verbose. But … not by much? And with a trade-off that clearly has some benefits too.
P.S.: Note that this affects only this form, which previously had this logic:
P.P.S.: Most config forms either have no validation or barely any. This hence is very unlikely to result in a usability regression. I just checked and I found 8 overrides of
public function validateFormin the 28 non-testConfigFormBasesubclasses 🫣— slightly more than 25%. If anything … it's going to make it more likely that config forms will have validation at all, so that config forms are not "saving successfully" but then mysteriously the config seems to have not any effect: a far bigger boost in usability than the slightly more verbose messages would decrease usability. (I've lost count of how many times I've had to debug modules to figure out why mysteriously 🧙♀️ the config wasn't working after using config forms, only due to missing validation …)Comment #97
effulgentsia commentedActually, I didn't intend in #40 to request that we change HEAD's multi-invalid-email message, only that the patch in this issue not limit itself to only including the 1st invalid email address in its error message. Personally, I don't have a strong opinion on how we display multiple invalid email addresses, only that all of the invalid ones get listed.
But, if UX review is needed to change the message string from HEAD's comma separated list to #93's more verbose concatenation of sentences, then in order to unblock this issue, what do you think of something along the lines of this interdiff proposal as a way to retain HEAD's current message format for this form, and opening a follow-up issue to discuss the merits of changing the message?
Comment #98
wim leersThat works for me!
That means the only known case in HEAD remains completely unchanged, and even allows specific forms to opt in to make messages for sequences more human-friendly! 👏 😊
AFAICT, then there is zero change in terms of UX and we'd be able to remove the tag?
P.S.: it's still amazing to me how you can jump into an issue and articulate a compromise others didn't see that allows us all to keep moving forward together 😊🤝
Comment #99
lauriiiThis looks great 👍 Removing the usability tag since this is no longer introducing end-user facing changes after we implement #97.
Comment #100
wim leersYay!
Done. IS + CR updated.
Comment 💯 — a sign?🤞
Comment #101
catchReviewed the interdiff and that's a really good, transparent approach. Lots of revisions here but we ended up with a really nice API in the end, nice when it ends up like that and not back where you started in comment #5 or something.
Comment #102
lauriiiWhy is this not added to
__construct()?Nit: out of date reference.
Nit: I was surprised to see dashes in the
$this->t()placeholder, but looks like we have some pre-existing instances of that. However, it looks like underscores are definitely more commonly used than dashes.Nit: can we use
$this->t()like we do few lines earlier?The CR, the issue and code comments talk about optional config validation. But is it really optional if we are triggering a deprecation warning if someone doesn't implement it? 🤔
Comment #103
catchSorry realising this late.
The MR is ignoring the deprecation with a follow-up to convert core then un-ignore it. Generally we've avoided doing that and would do it in reverse - convert core then add the deprecation. So could we do it like that? Would mean a pp-2 follow-up after the conversions adding it back in.
Comment #104
wim leersConfigFormBase::__construct()overrides: #3384782: [PP-1] Follow-up for #3364506: add deprecation once all simple config forms in core implement.ConfigFormBase. Which has some implications — quoting myself from #72:It seems that most of y'all are on board with that. @catch asked in #86 whether we should set the target not to 11, but 12 — to which I wrote in Slack: "let's do 11 now, we can still choose to bump it? For example, #3295421: Update TranslationWrapper deprecation to removal in 11.0.0 bumped something else from 10 to 11 as the target.
I'd be fine with setting it to 12 immediately if that is a big concern. But I personally think it's very feasible to get a solid set of examples in place:
media_library.settings(UI, but a single boolean),menu_ui.settings(no UI) andnode.settings(no UI).Media(Library)SettingsFormare the perfect match for this: 1 (resp. 4) values, but no form validation.BookSettingsFormis also a perfect match: 2 values that are edited through the form, one of which has custom validation … which soon we'll be able to replace by a constraint, once #3382581: Add new `EntityBundleExists` constraint lands.ConfigFormBasesubclasses converted. It'll be easy to do all the rest. That'd provide a body of examples to follow! Issue created: #3384790: Update all remaining ConfigFormBase subclasses in Drupal core to use #config_target.CR updated 👍
#103 (cross-post):
That's not possible. This issue must land first, because otherwise we cannot update any of the existing forms to actually use the new infrastructure 😅
The only alternative is to not start triggering deprecations just yet. But that'd just be splitting things up into extra work for no reason — literally the only downside here is a single line being added to
core/.deprecation-ignore.txt😅(Although in this very patch iteration I'm introducing a new deprecation per @lauriii's request, and for that one, we could definitely do it here. That'd mean I'd close #3384782: [PP-1] Follow-up for #3364506: add deprecation once all simple config forms in core implement.)
Comment #105
wim leersOops!
Talked to @catch meanwhile:
Doing that 👍
Comment #106
wim leersDid #105. Rescoped/repurposed #3384782: [PP-1] Follow-up for #3364506: add deprecation once all simple config forms in core implement.
Interdiff:
ConfigFormBasesubclasses in core that override__construct()to pass in the new parametercore/.deprecation-ignore.txtComment #108
wim leersMissed 3 cases. Fixed.
Comment #109
lauriiiLooks good! Thanks for addressing the feedback @Wim Leers!
Comment #110
effulgentsia commentedTicking credit checkboxes for reviewers.
Comment #112
effulgentsia commentedPushed to 11.x and published the CR!
Comment #113
wim leers🥳🥳🥳
Can’t wait to continue the follow-ups on Monday, happy weekend y’all!
Comment #114
wim leersAs promised:
… and updated the issues blocked on those to indicate they're less blocked now.
Comment #115
wim leersAlso rebased #2408549 on top of this, see #2408549-179: Display status message on configuration forms when there are overridden values 🤓