Problem/Motivation
Postponed on #3364506: Add optional validation constraint support to ConfigFormBase.
Based on @effulgentsia's proposal at #3364506-79: Add optional validation constraint support to ConfigFormBase, which @lauriii, @borisson_ and I preferred to handle in a follow-up because it A) won't work for every config form, B) there's a lot of nitpicking potential 🤓
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:
protected const CONFIG_NAME = 'update.settings'; protected static $formElementNamesToConfigKeys = [ 'update_check_disabled' => [self::CONFIG_NAME, 'check.disabled_extensions'], 'update_check_frequency' => [self::CONFIG_NAME, 'check.interval_days'], 'update_notify_emails' => [self::CONFIG_NAME, 'notification.emails', ['transform' => MultilineToSequence::class]], 'update_notification_threshold' => [self::CONFIG_NAME, 'notification.threshold'], ];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:function getConfigValueFromFormValue($form_value) function getFormValueFromConfigValue($config_value) function getSingleViolationMessage($violation_messages)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:$form['update_check_frequency'] = [ ... '#default_value' => $config->get('check.interval_days'),to:
$form['update_check_frequency'] = [ ... '#default_value' => $this->getConfigValueFor('update_check_frequency'),or even remove that line entirely and have
ConfigFormBaseautomatically populate it :)
Steps to reproduce
N/A
Proposed resolution
(See #13 for where this proposal surfaced from @phenaproxima.)
Since the goal of this issue is to make it very, very easy for simple config forms to adopt validation constraints, we should introduce a new Form API property called #config_target, which allows developers to define the one-to-one mapping of a form element to a config property.
Here's a simple example, from \Drupal\update\UpdateSettingsForm:
$form['update_check_disabled'] = [
'#type' => 'checkbox',
'#title' => $this->t('Check for updates of uninstalled modules and themes'),
'#config_target' => 'update.settings:check.disabled_extensions',
];
This tells ConfigFormBase to load the element's default value from the check.disabled_extensions property of the update.settings config, and to put the element's submitted value there during form validation and submission. If the element had a #default_value key already, then it would "win" instead of #config_target.
If the value needs to be transformed during form build (when the value is being read from config) or submit (when the value is being saved to config), it is possible to specify transformation callbacks as needed. Here's another example from UpdateSettingsForm:
use Drupal\Core\Form\ConfigTarget;
$form['update_notify_emails'] = [
'#type' => 'textarea',
'#title' => $this->t('Email addresses to notify when updates are available'),
'#rows' => 4,
'#config_target' => new ConfigTarget(
'update.settings',
'notification.emails',
fromConfig: static::class . '::arrayToMultiLineString',
toConfig: static::class . '::multiLineStringToArray'
),
'#description' => $this->t('Whenever your site checks for available updates and finds new releases, it can notify a list of users via email. Put each address on a separate line. If blank, no emails will be sent.'),
];
public static function multiLineStringToArray(string $value): array {
return array_map('trim', explode("\n", trim($value)));
}
public static function arrayToMultiLineString(array $value): string {
return implode("\n", $value);
}
The callback must be a public static method, or a procedural function. It receives only one argument -- the value to transform.
Since transformation callbacks are a bit more of an advanced case, the syntax for defining them (using the lightweight ConfigTarget class) is a bit more verbose. But that's a deliberate trade-off made in the name of clarity.
From this #config_target property, we can infer (during the form's #process and #after_build stages) which elements are responsible for which config properties, and we can store that mapping and use it for both validation and final submission.
Remaining tasks
None.
User interface changes
None.
API changes
- No changes to shipped APIs, as pointed out by @larowlan on the MR.
- This removes the unshipped APIs
ConfigFormBase::mapConfigKeyToFormElementName (),ConfigFormBase::defaultMapConfigKeyToFormElementName()andConfigFormBase::copyFormValuesToConfig(). - This adds of a new optional Form API property:
#config_target, which accepts a<config name>:<property path>string if no transformations are needed, and aConfigTargetobject if transformations are needed. Example:UpdateSettingsForm, which was the only form to have been updated to use validation constraints back in [#3364506].
Data model changes
None.
Release notes snippet
TBD
Issue fork drupal-3382510
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:
Comments
Comment #5
wim leersAdded a working PoC implementation to the IS 😊
Comment #6
wim leersSee #3364506-103: Add optional validation constraint support to ConfigFormBase.
Comment #7
wim leersBlocks #3384790: Update all remaining ConfigFormBase subclasses in Drupal core to use #config_target.
Comment #9
wim leers#3364506: Add optional validation constraint support to ConfigFormBase is in!
Comment #10
wim leersDid what I said in #6.
Observations:
MediaSettingsForm,iframe_domainmust either be a valid URI ornull, NOT the empty stringBookSettingsForm,#type => checkboxesis used, and that results in'0'in form values for the unchecked checkboxes … so it MUST be filtered awayBookSettingsForm::submitForm()there was some weird logic from almost exactly a decade ago (#1933548: Book allowed_types settings repetitive and in under certain conditions can change unexpectedly) … that I was able to remove by specifyingorderby: keyinstead (see #2361539: Config export key order is not predictable for sequences, add orderby property to config schema ).BookSettingsForm::validateForm()cannot be deleted here yet because it needs that validation logic to be converted to a validation constraint. That'd be too much out of scope here. So I'd say we should either revertBookSettingsFormchanges or create a follow-up and point to that.Curious what y'all think.
Comment #11
phenaproximaI like the concept here, but I have some questions about the implementation...
Comment #12
wim leersComment #13
phenaproximaI spent most of the day thinking about this issue and, long story short, I think we should change the approach here and go for syntax like this (proposed in another issue, but I'm not sure which):
(This example comes from
MediaSettingsForm.) Let me explain more about how this could work:When loading the default value from config:
ConfigFormBase::buildForm()could recursively loop through all the form's elements, and load the config value for any element which has a#config_targetkey, but not a#default_valuekey. Most (?) config forms callparent::buildForm(), so they would be automatically opted into this behavior.When saving the value to config: We could loop through the entire form recursively (starting from
$form_state->getCompleteForm()), and for any element which defines a#config_targetkey, we could use the element's#parentsproperty to get the submitted value from$form_state->getValue(). Then we could set that value in the specified config object and property path.But what if we need to transform the value before saving? Well,
#config_targetwould make an allowance for that. It could either be a string, if there is no transformation to be done:'#config_target' => 'media.settings:iframe_domain'Or, if there is a transformation needed, it could be an array whose second element is the name of a string callable which will transform it:
'#config_target' => ['media.settings:iframe_domain', '::nonEmptyStringOrNull']It could be a fully-qualified static callable as a string:
'#config_target' => ['media.settings:iframe_domain', HelperClass::class . '::transformationMethod']If it was not fully qualified, as in the first example, we would expect it to be the name of a trusted callable. That would be enforced by DoTrustedCallbackTrait.
I think that this approach, although old-school, will have these benefits:
Comment #14
wim leersThis is a fairly big assumption. Transformations will be necessary in many cases. So it will be more complex than presented here in many cases.
It's not common for simple config forms though. Even more so because the only reasons for contrib modules to alter a simple config form are to:
But you're right it MUST be supported! 👍
Except that it's not a good example 😅, because it applies the second pattern I mentioned above: it alters the form provided by
UpdateSettingsFormforupdate.settings, but stores its values inautomatic_updates.settings! So … config validation is irrelevant in that case anyway, since\Drupal\update\UpdateSettingsForm::getEditableConfigNames()says:(The only way it could add config validation is to subclass
UpdateSettingsForm, addautomatic_updates.settingsto that and override theupdate.settingsroute definition. Which is fine … except that it then prevents other modules from doing the same! Horizontal extensibility bites once again!)Based on a private Slack between you, @effulgentsia and I, AFAICT @effulgentsia is on board with this change in direction. The primary reason being: if it's a static array, contrib form alters become impossible to support.
Tagging for that.
Should we make this a non-blocker and do #3384790: Update all remaining ConfigFormBase subclasses in Drupal core to use #config_target first?
Comment #15
phenaproximaComment #17
wim leersComment #18
phenaproximaComment #19
phenaproximaComment #20
borisson_Looking at the implementations in the last few commits, this seems like such a simple DX, works very well.
I had one remark on the pull request, but it might not be something we often do.
I don't see the link to the comment, so https://git.drupalcode.org/issue/drupal-3382510/-/commit/8e4abd89e594877...
Other than that small remark, I think this is good to go, waiting for rtbc on an answer on that comment. I'm happy if we feel like that goes too far.
Comment #21
wim leersI have a bit more than one remark 😅
But: excellent work here, @phenaproxima! 👏
mapConfigKeyToFormElementName(), which #3364506: Add optional validation constraint support to ConfigFormBase added during the10.2.xcycle (and which we're still in). So it's fine to remove, but we need to articulate why. We arrived at that solution after long and careful debate.core/modules/update/src/UpdateSettingsForm.phpmake this really convincing. FAR less code! And yay for the test coverage that was added in the previous issue, that means I am immediately confident this works correctly for sequences too 🥳::submitForm()overrides! 🤩 (That also means that my statement at #2408549-178 is no longer true!)→ we could ensure this by validating that each
#config_targetvalue points to a config name that actually exists and is one of thegetEditableConfigNames()and its property path actually exists in the schema::submitForm()override exists. Most of the time you should not need that. As is the presence of#default_value.→ this is now no longer true. But ideally we'd be able to adopt the same pattern for config entity forms, although then it wouldn't be something like
'#config_target' => 'node.settings:key', but something like'#config_target' => 'node.type.*:key'. Could we do a brief PoC to verify that this would be possible? 🙏NegotiationSessionForm.php#config_targetis a certain indicator.Comment #22
phenaproximaUpdated the issue summary to explain the rationale for removing
copyFormValuesToConfig()from the API.Comment #23
phenaproximaEntity forms already kinda do this mapping in a half-assed way; see
\Drupal\Core\Entity\EntityForm::copyFormValuesToEntity().So we could probably just rely on that method (even though it is rife with problems) and, in validateForm(), validate the config entity as typed data, mapping any violations back to the elements which correspond to the invalid property paths.
Comment #24
phenaproximaComment #25
phenaproximaI updated the change record to explain how to use this (as a new revision of the existing CR): https://www.drupal.org/node/3373502/revisions/13264455/view
Comment #26
phenaproxima🏓
Comment #27
wim leersI think this is 99% done. The one thing that is "iffy" to use @phenaproxima's words (the bidirectional transformation methods) actually suggests we should keep
copyFormValuesToConfig()available as an escape hatch for more complex use cases.Note: once #3364109: Configuration schema & required values: add test coverage for `nullable: true` validation support lands, we could not only remove
#default_value, but also#required😄Comment #28
phenaproximaMakes sense to me. Tagging this issue for post-commit change record updates (since there are already change record updates needed, and I can't go edit the revision that has the required changes).
Committers, please note that this should be set to "needs work" post-commit, so that I can make the change record explain when one should override
copyFormValuesToConfig().Comment #29
phenaproximaComment #30
wim leersComment #31
wim leersPer @lauriii.
Comment #33
wim leersNote that @alexpott first proposed this solution ~6 years ago in #2408549-82: Display status message on configuration forms when there are overridden values, with a first implementation at #2408549-85: Display status message on configuration forms when there are overridden values.
Comment #34
longwaveI am not convinced that the transformer function should be bidirectional; per single responsibility principle, a function should generally only have one purpose - here the transformer has two depending on the type of the argument. Because form API is not currently strictly typed (numeric inputs arrive as strings, for example) then I think it might end up being tricky detecting the direction of transformation - especially if the config value is also a string. If we don't want to declare two functions should each transformer be a class that implements an interface with two methods? Or for one off cases, could we allow two closures to be declared in the FAPI array?
Comment #35
phenaproximaWhat if we added a new param to the transform function --
bool $is_saving? That way the function would have more to go on than just "how does the argument look".I agree that it sort of flies in the face of the single responsibility principle, but IMHO that's a sacrifice worth making in the name of keeping the DX as simple as possible.
We can't use closures, as far as I know, because form arrays are serialized and cached, and you can't serialize a closure (PHP will fatal).
Comment #36
claudiu.cristeaMaybe we can replace that array/string with a very light class:
If
$transformOnSaveis missing,$transformOnLoadOrSaveis bidirectional.Then we have:
Comment #37
phenaproximaNow that I made the separation between save and load callbacks explicit, this needs review again.
Comment #38
borisson_I agree that there are usecases where the saving and loading are different, and it is a pattern that we also use in different places.
The argument that this is now a single responsibility instead of doing both ways is a good argument as well.
I looked at the changes made in the last commit and they make sense as well, discussed this with @lauriii at drupalcon.
Does this still need subsystem maintainer review?
Comment #39
longwaveAdded some comments.
Comment #40
alexpottDiscussed with @Wim Leers, @borisson_ and @longwave. The callbacks - save_callback and load_callback are tricky to name. They are not loading or saving anything. As the new document says they are for transforming the value between the config and the form system.
We discussed several ideas...
1. Add a new
ConfigTransformerclass & interface that hastoForm()and toConfig()as static methods.So the code to use this becomes
The advantage of this is that the transformers are reusable and well documented. The disadvantage of this is that the transforms are moving away from the config form class and we'll have more and more classes.
2. Add a new
ConfigTargetvalue objectThe code to use this becomes
The advantage of this is that we're not building on the massive ArrayPI of forms so it is easy to discovery what is possible and documenting what the callbacks do is much simpler. Ie. the class would have a method like
ConfigTarget::getFormToConfigTransform()which in the above instance would return'::storeEmailsInConfig'3. Should these transforms happen using the Form API?
The form API already has
#value_callbackthat transforms input to an expected format. That's what we're doing here. However there's no API for manipulating#default_valueso we'd need to add that. Also using#value_callbackwould mean that if we use a Checkboxes element we'd become responsible for calling\Drupal\Core\Render\Element\Checkboxes::valueCallback()which does not feel right. So#value_callbackis happening at the incorrect level.4. Come up with better names for save_callback and load_callback
Maybe something like
config_to_form_transform()and form_to_config_transform()While writing this comment I realised that option 3 is untenable and thought that there is an option 4 so I'll add that. This option was not discussed with the others.
I think I prefer option 1 or 2 but could live with 4 is other agree.
Comment #41
claudiu.cristeaI'm for 2., which is more or less what I've proposed in #36, because:
Comment #42
phenaproximaHere's another idea: what if we introduce two new protected functions to ConfigFormBase (I'm not married to these specific names):
If you need to do transformations, you can override one or both of these methods. This way, all the complexity of
#config_targetdisappears: it's no longer an ArrayPI; you don't need to write a bidirectional transformation function; and it's very clear how to do a transformation in either direction.Since transformations are a relatively "advanced" use case, I think we can rely on developers to grok this.
Comment #43
longwaveTransformer functions are hopefully the exception rather than the rule and for good DX given the rest of form API lives in a single class we want to try and keep the transformers in the same class as well. Can we use (or perhaps abuse) PHP attributes so transformer methods can live in the same class?
Multiple config keys can share the same transformer functions if they need to. If there is no method tagged with the attribute no transformation is done.
Not sure how to scale this when we want to share transformers between forms though.
Comment #44
wim leersOhhh! I think I like that? The major downside to that (besides new syntax nobody is familiar with) is I think that it becomes a very abstract, relatively disjointed DX, where you have to remember the various moving parts. I do think we could add logic to check that if a "from" exists, the "to" must also exist, and vice versa?
throw new \LogicException()if either is missing? 🤔In cases like these, reuse is IMHO not very important. Just copy/paste these snippets? Otherwise all of these need to become APIs too. This approach would mean it's not actually a public API, but … if the need arises, we could have a
CommonConfigFormTransformersutility class that one could reuse?Comment #45
claudiu.cristeaI don't think so. For example
strtolower()is also a transformer. Why creating a method just to do that? Then[\Drupal\Component\Utility\Bytes::class, 'toNumber']is also a transformer. Could we use it directly but still allow::transform()(which is in the class)?Comment #46
phenaproxima👎 I don't recommend this. Of the cases where transformations have to be done in this MR, only one of them needs both to- and from- transformations. The rest of them only need to be changed when the value is being saved.
What if we allowed the attributes to reference other callables (like
strtolower()), if they needed to?So for example, if you do this, you are saying that
stringToArray()is the transformation callback forupdate.settings:notification.emails:But you could also do this, on
buildForm():In this case, you're saying "I want to use
strtolower()as the transformer for thesystem.site:nameconfig property." This would be a very advanced use case but it would address what @claudiu.cristea pointed out in #45 and make reuse possible.But also...this is all quite a bit more complex than what I proposed in #42. What was wrong with that idea?
Comment #47
alexpottI'm not that keen on #46 and the attribute stuff because I think that have attributes + #config_target in form array feels odd and hard to get used to.
I think #42 might be okay especially if we encourage the use of match. But I think I've come up with a reason why attributes and #42 won't work in the long run. Form alters. How is dblog_form_system_logging_settings_alter() or something similar able to use these. Therefore I think we need this information in the form array.
@longwave and I discussed whether or not we could use schema for this. This is kind of how config translation works - see
config_translation_config_schema_info_alter(). This method adds a newform_element_classto config schema. The problem putting the transformer information into schema is that this will tie schema and fomr stuff together. So I don't think this is correct.I think of all the options is something like #36 (sorry @claudiu.cristea for not crediting you with the idea in #40 is the best. It can be used by form alters, it can use both functions on the form and functions anywhere else and the very light class can clearly document with the transformers do.
Comment #48
phenaproximaOkay, I took a shot at implementing this with a ConfigTarget value object.
To keep things frictionless for simpler cases, I made it so that you can still have #config_target be a string, and it will automatically be converted to a ConfigTarget object for you. These objects can be serialized, so they can be stored directly in the config-key-to-form-element map without trouble.
For advanced cases (that is, if you need a transformation in either or both directions), I think we can expect people to call
ConfigTarget::create().Everything in ConfigTarget is immutable (except for a couple of explicitly internal properties), but if a form alter needs to change it, they can just replace it with a completely new ConfigTarget instance.
Comment #50
wim leersThat is looking very encouraging! Thanks for the fast turnaround time 🙏
Curious what @longwave & @alexpott think 😊
Comment #51
wim leersI did a very thorough review, and could barely find anything to complain about.
This looks great. It's _almost_ a net-zero diff 😮
A few more nits fixed, and I'll confidently RTBC 😊
Comment #52
phenaproximaBack to you for final review!
Comment #53
wim leersZero remarks left. 🤩
Issue summary clarified (and slightly updated, it was slightly wrong/stale).
I previously advocated in #3364506: Add optional validation constraint support to ConfigFormBase that this approach could not work. Clearly I was wrong! This is a massive improvement DX-wise. It'll make adoption of config validation much simpler and makes #2408549: Display status message on configuration forms when there are overridden values trivial to bring to Drupal core too! (And as a bonus: it actually does most of #3384790: Update all remaining ConfigFormBase subclasses in Drupal core to use #config_target already, to prove it works 👏)
@phenaproxima: you previously created a change record revision with the then-up-to-date
#config_targetstuff, can you update it? 🙏Comment #54
phenaproximaChange record updated!
Comment #55
phenaproximaComment #56
phenaproximaCrediting myself for writing the current approach, Wim for reviewing it, and @claudiu.cristea for suggesting the approach we ultimately used.
Comment #57
phenaproximaOh, and @longwave for review.
Comment #58
phenaproximaComment #59
phenaproximaComment #60
bircherre my comment on the MR: simpler is good so that was not meant to hold up anything.
I much prefer this over the unshipped API so +1 for RTBC.
The issue summary update looks good too now.
Comment #61
alexpottCreditting @bircher as they were involved in the conversations at Drupalcon that led to the current API.
Comment #62
alexpottSpent lots of time discussing this at Drupalcon. I think the new API is the easiest way forward for now. I do wonder if something else will come up as we go forward and I have ideas for two follow-ups but I think doing them will only change internal implementation details so I'm not worried about that.
The two ideas are:
getEditableConfigNames()- this would make doing form alters like dblog_form_system_logging_settings_alter() really easy as you would not need dblog_logging_settings_submit()Committed e1c95b2 and pushed to 11.x. Thanks!
I'm going to ask the RMs if we can backport this to 10.2
Comment #65
alexpottDiscussed with @catch and we agreed to backport this.
Comment #66
wim leersYay! 🥳
RE: Follow-ups
ConfigFormBasesubclass is not being altered. And I think I see a way how: i) use::getFormId()to determine if there any form alters for this form, ii) if no form alters exist: inform the developer that this config target does not make sense, iii) if they do exist: allow arbitrary targetsNote: this MR did not introduce any validation of the
#config_targetproperty! It only is not going to validate or save config targets outside of:: getEditableConfigNames ()(I think we discussed verifying the
#config_targetmakes sense, but it was not considered required, because Form API in general has very few guardrails. )Unblocked 🚀
Comment #67
gábor hojtsyI think this should be part of a config validation related release highlight segment :) Feel free to identify more bits to include in that.
Comment #68
alexpottI've created #3398891: Do not require the config in #config_target to be listed in getEditableConfigNames() - I disagree with
- I'm not sure why they need to exist and what they are for. Let's debate on the issue.
Comment #69
wim leersThis introduced a regression: #3398974: Follow-up for #3382510: FormStateInterface::setErrorByName() needs not #name but a variation. 🐛
Comment #70
wim leersOne more thing: this massively improved DX, at one important cost: it hardcoded the assumption that UI/form elements map 1:1 to config property paths (and hence violations for property paths).
This makes better UIs (that present things in the user's mental model) impossible in some cases: #3398982: ConfigFormBase + validation constraints: support non-1:1 form element-to-config property mapping again.
Comment #71
alexpottOpened #3399295: Allow all callables in ConfigTarget to address #62.2
Comment #73
matthieuscarset commentedIs there any documentation about how `#config_target` should/can be used?