Problem/Motivation
Related, but independent to #3398974: Follow-up for #3382510: FormStateInterface::setErrorByName() needs not #name but a variation.
Configuration UIs should present things in a way that makes sense for the end user's mental model.
🆚
Configuration should store things in a way that makes things as simple as possible for the module developer AND optimizes for git diff.
This means that config UIs CAN and arguably SHOULD not have a 1:1 relationship between UI/form elements and the underlying config. #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 does assume that, which is a reasonable default, but it must not get in the way. Unfortunately it does.
A validation error for some specific config property path should be associated with the closest containing form element. And that does not happen today: if a validation error for a property path does not have a 1:1 relationship to a form element, a PHP error appears:
Attempt to read property "elementName" on null in Drupal\Core\Form\ConfigFormBase->validateForm() (line 204 of core/lib/Drupal/Core/Form/ConfigFormBase.php).
Steps to reproduce
- Install Drupal 10.2
- Install the CDN module, and apply #3394172-6: Adopt Drupal core 10.3 config validation infrastructure
- Choose "only files", do not specify any file extensions, click "Save configuration".
- You will see:
There are a few examples in core of forms whose logic is a bit too complicated for the 1:1 use case preferred by #config_target:
- \Drupal\language\Form\NegotiationBrowserForm
- \Drupal\locale\Form\LocaleSettingsForm (fixed here)
- \Drupal\language\Form\NegotiationConfigureForm
Proposed resolution
Root cause:
$map["$config_name:$property_path"]
is assumed to exist. But … this will ONLY exist if there's a 1:1 relationship between property paths and form elements. It may very well NOT exist.
So instead, try to find a parent property path, if it exists.
Per #9. ← Abandoned since #31.
- The 1:N case
- (1 form element, N property paths
-
- Allow passing multiple property paths to
ConfigTarget - If multiple property paths are indeed passed, then
fromConfigandtoConfigcallables become required, not optional.
- Allow passing multiple property paths to
- The N:1 case
- (N form elements, 1 property path — see #27 for an example)
-
Allow making the necessary conditional decisions:
- Pass the
toConfigcallable aFormStateInterfaceparameter. - Allow the
toConfigcallable to return one of two special values:ToConfig::NoOp, to indicate that the given form value does not need to be mapped onto the Config objectToConfig::DeleteKeyto indicate that the targeted property path should be deleted from config.
- Pass the
-
Use case for this: This allows for the scenario of a
input[type=radios](withmodeas the target) to choose the simple or advanced way to configure something in the UI.
When the user chooses "simple", it would be mapped tomode: { style: automatic }(i.e. full config subtree set)
When the user chooses "advanced", a conditionally displayedinput[type=text]would appear in the UI (target:mode.something_very_advanced).That conditionally displayed form element's
toConfigcallable would default to returningToConfig::NoOp, unless it can see in theFormStateInterfaceobject that the radio button is set to "advanced" 👈 that's why those 2 pieces are needed!See #27 for a concrete example with accompanying code. As well as the explicit test coverage for the conditional use case plus the infrastructure and how to use it.
All of this complexity is encapsulated in ConfigTarget — ConfigFormBase becomes simpler 😊
Remaining tasks
Reviews.
User interface changes
None.
API changes
ConfigTarget (new in 10.2!):
- Allow passing multiple property paths to
ConfigTarget - If multiple property paths are indeed passed, then
fromConfigandtoConfigcallables become required, not optional. - Allow the
toConfigcallable to specify aFormStateInterfaceparameter, detect this using reflection, and if detected, pass it. - Allow the
toConfigcallable to returnToConfig::NoOp(no-op) orToConfig::DeleteKey(delete the targeted property path's key).
Data model changes
None.
Release notes snippet
None.
| Comment | File | Size | Author |
|---|---|---|---|
| #9 | core-configformbase-non-1-to-1.patch | 2.84 KB | wim leers |
| #3 | Screenshot 2023-11-03 at 11.45.13 AM.png | 169.88 KB | wim leers |
| #3 | Screenshot 2023-11-03 at 11.42.14 AM.png | 167 KB | wim leers |
| Screenshot 2023-11-03 at 11.34.48 AM.png | 284.72 KB | wim leers |
Issue fork drupal-3398982
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
- 3398982-parent-property-path
changes, plain diff MR !5240
Comments
Comment #3
wim leersAs of https://git.drupalcode.org/project/drupal/-/merge_requests/5240/diffs?co..., this is what you'll see instead of an error:
… but this is obviously still not associated with the right form element.
Apply #3398974: Follow-up for #3382510: FormStateInterface::setErrorByName() needs not #name but a variation as well and you'll see:
Comment #4
alexpottI think we definitely need some tests here.
Comment #5
smustgrave commentedDid not test, moving to NW for tests requested in #4
Comment #7
alexpottThis now contains #3398974: Follow-up for #3382510: FormStateInterface::setErrorByName() needs not #name but a variation and more test coverage of \Drupal\Core\Form\ConfigFormBase::validateForm() handling violations.
Comment #8
wim leers👍 I'm hoping @phenaproxima can finish this — I'm working on #3364108: Configuration schema & required keys.
Comment #9
wim leersI propose an alternative solution, which applies cleanly to Drupal core at commit
13883cc3808f9442268c904f4d96379372ecf0bf. This works for everything, not just composite form elements.In a nutshell, this restores the ability that #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 removed: the ability to implement non-1:1 mappings by allowing modules to override
::copyFormValuesToConfig()(but still call the parent method to do as much as possible using#config_target) and optionally implementmapConfigKeyToFormElementName().This patch allows modules with human-optimized UIs to use config validation again in their config forms, by:
::copyFormValuesToConfigprotectedinstead ofprivate::mapConfigKeyToFormElementName()to be implemented again, and automatically calling it for any property path not found using#config_target… which is what #3364506: Add optional validation constraint support to ConfigFormBase originally introduced, until #3382510 removed it.
Concrete example
The CDN module's
CdnSettingsFormin #3394172-13: Adopt Drupal core 10.3 config validation infrastructure uses:#config_target6 times, for all the 1:1 mappings 👍mapping][simpleontocdn.settings:mapping.conditionsin config:Comment #10
phenaproximaI like Wim's idea in #9.
Restoring some of the removed infrastructure gives modules like CDN an "out" for the more complex cases.
#config_target really is only meant for the simpler cases. I spent quite a bit of time trying to figure out how to make #config_target smart enough to handle these complex cases, but all I really ended up doing was tying my brain into knots. That leads me to think that we shouldn't try to make #config_target "smarter", which will probably introduce more problems than it would solve, but instead make sure developers have the tools to do things the right way for their specific needs.
Indeed, while working on #3384790: Update all remaining ConfigFormBase subclasses in Drupal core to use #config_target, I already found a few spots in core where we're going to need the ability to do complex logic that is out of #config_target's reach.
Therefore, Wim's idea makes sense to me.
Comment #11
wim leersWhere? Examples in core would be most valuable!
Comment #12
wim leersPer #10
Comment #13
wim leersComment #14
wim leersThe sole Nightwatch test failure for
fe9b0f11is unrelated. All other tests passed 👍Just pushed a commit that fixes the test expectations, which will cause it to fail: the solution so far is known to be incomplete/inadequate!
Comment #15
phenaproximaRe #11: I've updated the issue summary with a few core forms that have complicated mapping logic.
Comment #16
wim leers🥳 That passed tests, with the exception of
AjaxTest::testAjaxFocus(), which is unrelated. Next: prove that#config_targetand acopyFormValuesToConfig()override can co-exist.Then I'm done here 👍
Comment #17
wim leersComment #18
wim leers@phenaproxima Can you please implement 1 of the 3 examples in this issue? Because per #10, this is actually a hard blocker for #3384790: Update all remaining ConfigFormBase subclasses in Drupal core to use #config_target.
Comment #19
phenaproximaRe #18: Sure; done!
Comment #20
phenaproximaTook a shot at a change record: https://www.drupal.org/node/3399660
Comment #21
phenaproximaComment #22
phenaproximaComment #23
phenaproximaComment #24
borisson_Found one minor nitpick, otherwise this looks good to go imho.
Comment #25
wim leers#20: I think it'd be clearer if we updated https://www.drupal.org/node/3373502 once more? 😅
Working on trying to implement @alexpott's feedback.
Comment #26
wim leersAddressed @alexpott's feedback!
I think do like this better.
Regardless of the DX, it has one major ecosystem/UX benefit:
#config_targetis present even for conditionally config property paths, which means #2408549: Display status message on configuration forms when there are overridden values will be more effective. 👍Applied to the CDN contrib module: see
interdiff-13-16.txtin #3394172-17: Adopt Drupal core 10.3 config validation infrastructure.Next up: apply this to
LocaleSettingsFormin core.Comment #27
wim leersTurns out that
LocaleSettingsFormis quite different fromCdnSettingsForm(see previous comment):CdnSettingsFormhas conditionality and maps 1 form element (a dropdown) to 1 subtree, unless that dropdown has a particular value, then a second form element corresponds to a value inside that same subtreeLocaleSettingsFormhas no conditionality but maps 1 form element to 2 config property paths (to be more abstract: 2 independent subtrees), and vice versaIf you zoom out/abstract this, the above means that
CdnSettingsFormmaps N form elements to 1 "node" in the config tree (which would never make sense of course, unless there's conditionality, which is the case here!)$form['mapping']['simple']['extensions_condition_toggle'] = …and$form['mapping']['simple']['extensions_condition_value'] = …cdn.settings:mapping.conditions.extensions(the first form element targetscdn.settings:mapping.conditions, the second targetscdn.settings:mapping.conditions.extensions, which means the second targets a subset of the former)LocaleSettingsFormmaps 1 form element to N "nodes" in the config tree$form['overwrite'] = …locale.settings:translation.overwrite_customized+locale.settings:translation.overwrite_not_customized👍 So AFAICT we got 2 perfectly complementary examples here! 😄
Comment #28
wim leersComment #29
wim leersNeeded to introduce
ConfigMultiTargetto support theLocaleSettingsFormuse case.Looking forward to feedback :)
Comment #30
phenaproxima#3399295: Allow all callables in ConfigTarget landed, so I've merged those changes into the MR.
Comment #31
phenaproximaBefore I hit the hay for the night, here's where I'm at: I'm trying to remove
ConfigMultiTargetand makeConfigTargethandle multiple property paths. It's a bit tricky to do that while aiming to keep the DX as sensible as possible. Some tests are failing but they're very clearly related to flawed logic I haven't fully finished implementing yet.I also think that we should always pass $form_state as the first argument to the
toConfigcallback, just for consistency's sake.Long story short -- if a ConfigTarget instance is handling only one property path, then it should behave the way it does in HEAD. If it's handling multiple property paths, then the toConfig callback should return an associative array of values, keyed by property path.
To keep this logic relatively cleanly encapsulated, I'm thinking we might want to put this directly in ConfigTarget.
Comment #32
wim leersStarted doing the remaining things that @phenaproxima did not get to.
$values = array_map($this->fromConfig, $values);does not work correctly for the multiple values case, because it callsfromConfigfor each value separately … which means that it no longer receives all the values at once. IOW: in an attempt to simplify the logic, the very purpose (being able to target multiple target property paths in config at once) was made impossible 😅Fixing that got us from 11 to 10 test failures.
Just pushed
67628221b0which will make the multi-target case actually work again, which should get us down to 4 failures.Comment #33
wim leersI discussed this with @alexpott in Drupal Slack.
He and I agree that this was a wrong direction. It keeps Drupal core simple but deteriorates the DX for actual users of this API. That's the wrong trade-off, it must be the other way around.
By reverting that change, the remaining failing functional tests will also start passing again 👍
(Most of @phenaproxima's changes are still in the MR though: removal of
ConfigMultiTarget, stricter validation, logic moved out ofConfigFormBaseand intoConfigTarget.)Issue summary updated.
Comment #34
wim leers#3398891: Do not require the config in #config_target to be listed in getEditableConfigNames() just landed. Rebasing. But first getting this to green 🤓
Comment #35
wim leersGreen! Time to clean up and then rebase. 👍
Comment #36
wim leersComment #37
wim leersThe rebased MR is still green 👍
Note that #3394172-17: Adopt Drupal core 10.3 config validation infrastructure still works fine, but I've also updated that patch to leverage the use of closures & arrow functions, and that really shows just how much cleaner/simpler all of this together makes config forms: #3394172-19: Adopt Drupal core 10.3 config validation infrastructure.
Comment #38
phenaproximaJust updating where I'm leaving this tonight: I added a failing test case to prove a point to Wim. Other than that, as far as I know, all feedback is addressed and all other tests are passing.
Comment #39
wim leersThe failing test case was incorrect AFAICT. See https://git.drupalcode.org/project/drupal/-/merge_requests/5240?commit_i... for the detailed explanation.
This is now green again 👍
This is ready IMO.
Comment #40
phenaproximaI'm satisfied here, although I've worked on this too. Hopefully someone who hasn't worked on it will come by and +1 RTBC...
Comment #41
borisson_This is lovely, I agree with the rtbc in #40. The changes here make it so much more capable.
Comment #42
wim leersYAY! 😊
Comment #43
alexpottAdded some comments to the MR.
Comment #44
wim leersAll of @alexpott's feedback has been addressed 👍
Comment #45
borisson_This is great: https://git.drupalcode.org/project/drupal/-/merge_requests/5240/diffs?co...
Good error messages are so helpful.
I though we retired the extraneous keys naming in the other issue, should we use something else here as well?
Comment #46
wim leersGood call! Fixed.
Comment #47
borisson_All remarks by @alexpott and me have been fixed. This now looks like it has very good test coverage as well, back to rtbc.
Comment #48
alexpottAdded some comments to the MR. They need to be addressed.
Nice work on having an MR. I think the MR should be focussed on how module developers can use this for complex config forms rather than the internals of ConfigTarget.
Comment #49
wim leersComment #50
wim leersAddressed all feedback.
That included opening #3401186: Follow-up for #3382510: Throw \LogicException when >1 #config_target in the same form targets the same property path — and having a test + fix ready there too.
The change record is wildly outdated. IMHO we should delete it and update https://www.drupal.org/node/3373502 instead, like we've done for all the other
#config_targetissues. Since it's all new, a single comprehensive change record is much more usable.Comment #51
wim leers😬 Just as I was about to update the issue summary for #50 after having implemented @alexpott's proposal, I realized that this actually does not work 😇
By allowing
toConfig: FALSE, you remove the ability to conditionallythrow new \OutOfBoundsException(). See #3394172-19: Adopt Drupal core 10.3 config validation infrastructure, which uses:I'll work on adding explicit functional test coverage for this use case.
Comment #52
phenaproximaIf we still feel icky about communicating by exception (a feeling I generally share), another option here is to leverage an enum. We could add something like this:
toConfig callbacks could return
ConfigTargetValueEnum::NoMappingas a way of explicitly signaling they don't want to set a value for the property. Since it's a non-backed enum, there's no ambiguity, and no chance of a wrong value being accidentally stored in config.The downside here is that it calls for a single-case enum, which is kind of lame.
Comment #53
alexpottRE #51, #52 - I think though that
toConfig: FALSEwould work for #3394172-19: Adopt Drupal core 10.3 config validation infrastructure. We need to change where the extensions are being set... to something like what is below.Comment #54
wim leers(I just walked @phenaproxima through #53 and explained why I agreed, but while implementing @alexpott's proposal, I realized that there's still a problem. But, I do think this line of questioning has gotten us to a point where we've actually gotten to a comprehensive solution 😊)
#53: I agree that pattern works for #3394172. But it doesn't work in all cases where there's conditional logic to determine which key-value pairs should be set in config.
That proposal works because one config target targets some config subtree (
mapping.conditions), and another config target conditionally targets a node in that subtree (mapping.conditions.extensions).I tried to find a more general case for the test coverage I aimed to add here (and apparently succeeded): one config targets one config subtree (
favorite_fruits), and another config target conditionally targets a different subtree (could_not_live_without).Possible cases
I think there is a need for multiple different cases:
toConfigcan return a value that must be set at the property path for thisConfigTarget⇒ return that value ← this already works in HEAD ✅toConfigcan SOMEHOW indicate that it's "no-op", because it knows some otherConfigTarget'stoConfigcallable will set this property path ← this is the case in #53/CdnSettingsForm, and in that particular example an unconditional "no-op" is possible, but that's not true in all casestoConfigcan SOMEHOW indicate that the property path thisConfigTargettargets must be REMOVED ← this will become a hard requirement starting in #3364108: Configuration schema & required keys, and there may already be cases where this would be necessary (even if it there isn't explicit validation logic for it yet)Possible approaches for case #2 (a form value NOT mapped to a value in config aka "no-op")
We've only been talking about point 2 here. The 3 choices so far:
\OutOfBoundsException(@Wim Leers)ConfigTargetValueEnum::NoMapping(@phenaproxima)The limitation of choice 2.B here is that it only supports the unconditional scenario.
Not yet considered so far: case #3 (delete a key)
In principle, we don't need to support case 3 just yet, because few modules are likely to be explicitly relying on this. But AFAICT it is going to happen (we've got +1s from @bircher and @alexpott), plus it's possible some modules already are doing something like this, so we should take that into account here already. Hence added explicit test coverage for case 3.We could choose two different exception types, but both @alexpott and @phenaproxima raised concerns about communicating using exceptions.
Conclusion
So then … @phenaproxima's proposal actually seems the most appealing, because then there would be at least two cases:
Combined with
match(), this can then be implemented very elegantly:Just pushed explicit test coverage, which IMO makes it obvious that this is not yet supported and should be.
Comment #55
wim leersIssue summary updated for #54 and its conclusion + implementation.
Comment #56
phenaproximaIf there's not yet a need to explicitly unset keys...but there will be (you seem pretty certain about that, Wim, and I believe you), then yes, the
enumseems like the best solution to me as well.Comment #57
phenaproximaI think Wim's points make sense, and I don't really have anything to complain about at the code level.
Comment #58
wim leers@alexpott in Drupal Slack:
NoOp.ToConfig::NoOpandToConfig::DeleteKey?Implemented those renames: see commits I just pushed. Since it's "just" naming, keeping at RTBC.
Comment #59
wim leersAdded docs to make the connection between
\Drupal\Core\Form\ConfigTargetand\Drupal\Core\Form\ToConfigexplicit.Update IS for #58.
Comment #60
wim leersCreated draft update to the change record — preview it here.
Also updated contrib module patch at #3394172-22: Adopt Drupal core 10.3 config validation infrastructure.
Comment #61
wim leersReally hoping this will land prior to
10.2.0-beta1, because this is the very last piece to make#config_target/validatable simple config forms be usable everywhere and have a nicer DX 🤞Comment #62
alexpottCommitted and pushed 421942795e4 to 11.x and 728f592f538 to 10.2.x. Thanks!
I backported this to 10.2.x because #config_target is new and this completes the functionality so that it can be used for more complex UIs like the cdn module.
Comment #65
wim leers🥳
Deleted the obsolete draft CR at https://www.drupal.org/node/3399660.
Very glad (and relieved) to have this in
10.2!Comment #66
wim leersThis unblocked #3384790: Update all remaining ConfigFormBase subclasses in Drupal core to use #config_target 👍
Now we can also start thinking about how to deprecate the old infrastructure: see #3384782-7: [PP-1] Follow-up for #3364506: add deprecation once all simple config forms in core implement + #3400033-7: Deprecate \Drupal\Core\Form\ConfigFormBase::getEditableConfigNames() - use #config_target instead.
Comment #67
alexpottComment #69
wim leersNew follow-up: #3420770: Expand what #config_target can do: automatically set #attributes[pattern] for HTML5 client-side validation when RegEx constraint is present.
Comment #70
idebr commentedAnother interesting follow-up where a config value has to be mapping to multiple element properties: #3463868: Two #config_targets error when used on a text_format form element