Commerce loves its inline forms. Some examples include the coupon redemption form, and payment gateway forms ("add payment method", "refund payment", etc). These forms are inline because they are expected to be used from multiple places at once. A coupon might be redeemed on the cart and during checkout. A payment method might be collected during checkout or in the user pages. Right now these inline forms are implemented as form elements.
The problem
Using form elements for inline forms seemed like a logical idea, but has proven to be one of our biggest mistakes in all of 2.x development:
1. Form elements weren't built to embed complex forms. Once #ajax is involved, all hell breaks loose, with Drupal not being able to correctly determine the triggering element. This delayed the profile select work by months.
2. Form elements can't be swapped out by a module wanting to change the UX, the plugin manager has no alter hook.
3. Form elements don't support dependency injection.
4. Form elements make it hard to alter forms, since a hook_form_alter() can't reach the sub elements anymore, requiring custom alter hooks to be implemented, or tricks with altering in a #process / #after_build callback that gets access to the built form.
The solution
All we wanted was to be able to reuse subforms. So, let's introduce our own mechanism for this. An InlineForm is an object with the usual build/validate/submit methods. It needs to be swappable (and have some kind of an ID). It needs to support dependency injection. It needs to be able to define settings, and default values for those settings. We already have a mechanism for all of this: plugins. Real plugins that is, not the half-baked version form elements use.
We introduce a @CommerceInlineForm plugin type. We convert at least one form (let's say PaymentGatewayForm) to inline forms (ProfileSelect and CouponRedemptionForm can happen in a followup). We deprecate the form elements and make them use the inline forms internally. We stop using the form elements in Commerce. All of our problems, gone.
Patch in progress.
| Comment | File | Size | Author |
|---|---|---|---|
| #12 | interdiff.txt | 2.41 KB | bojanz |
| #12 | 3003121-12.patch | 87.47 KB | bojanz |
| #10 | interdiff.txt | 36.91 KB | bojanz |
| #10 | 3003121-10.patch | 87.49 KB | bojanz |
| #9 | interdiff.txt | 17.41 KB | bojanz |
Comments
Comment #2
alexpott+1,000,000
In core we have \Drupal\Core\Form\SubformState - but I think this is more forms inside of forms.
Comment #3
bojanz commentedBack on this issue, finally.
Comment #4
bojanz commentedInitial patch for your reviewing pleasure.
Scroll down to the PaymentOperationForm changes for the best example of how the API has changed.
Converted all usages of the commerce_payment_gateway_form element, except the one in PaymentMethodAddForm, so that the tests can confirm that the BC layer is working properly. That usage will be removed before the patch is committed.
We're still missing a kernel test for InlineFormManager.
Review notes:
1) I initially passed &$complete_form as the third parameter to buildInlineForm() / validateInlineForm() / submitInlineForm(), just like I do for checkout panes, but I decided to remove the parameter, since it's not common for an inline form to need to modify the complete form, and since $form_state->getCompleteForm() can be used instead (and is used by the PaymentGatewayForm). That also matches the signature of plugin forms.
2) EntityInlineFormInterface and EntityInlineFormBase were initially EntityAwareInterface and EntityAwareTrait, but that meant that submitInlineForm() and getEntity() were on separate interfaces, making type-hinting difficult in user code (such as PaymentMethodEditForm or PaymentOperationForm).
3) I played with the idea of automatically invoking validateInlineForm() and submitInlineForm(), by having InlineFormBase::buildInlineForm() set $inline_form['#inline_form'] = $this, and register special #element_validate / #commerce_element_submit callbacks which would call the appropriate methods, but it felt a bit too implicit, since the majority case requires parent submitForm() to do $form['payment']['#inline_form']->getEntity(), and having to wonder where #inline_form came from. The alternative would have been to always set the entity as the form state value, but that too felt implicit, and Drupal likes form state values to remain an array.
Comment #5
mglamanFirst, this is great. And amazing how simple it looks.
1) I agree. No need for the third parameter. It can be fetched via the form state. It's passed by reference (for better or worse) anyways: &getCompleteForm()
2) Agree.
3) It's worth keeping less magical until we roll out more implementations, I'd say.
Should processForm throw a trigger_error, or however core logs deprecation notices
While adjusting this, should we directly inject the messenger service, so we do not need to rely on the messenger() method which reaches into \Drupal::service() if the property is not set? Or, not. A bunch of plugins could use this. Maybe it's just a follow up to keep the diff simpler.
nit: phpcs says these should be flipped.
These checks are technically the same. Do we have both for verbosity in error reporting?
I agree, I like having this specific interface.
Comment #6
bojanz commentedYep, forgot that.
Core added the MessengerTrait to both FormBase and PluginBase. Core usage seems to prefer using messenger() everywhere, so the only open question is whether we should inject one of our own and assign it to the property. Not yet sure it's worth the extra verbosity in every class.
Yeah, I wanted to differentiate "wrong entity" from "no entity", in case NULL was accidentally passed. Easier to debug.
Comment #7
alexpottAre we guaranteed something that implements EntityInlineFormInterface. InlineFormInterface hints that this method might not always be available. How can we ensure that the payment_gateway_form implements this? Do we even care?
This type of change is always a bit tricky. It depends if you think this class is widely inherited from in contrib / custom. If it is then leaving the constructor arguments as there are and adding $inline_form_manager = NULL to the end is preferable because then you can do a BC layer.
Wrt to using ->messenger() vs injection - I'm not sure it matters all that much but injection is preferred.
Should #page_title = NULL be in the default config as a way of documenting this?
Comment #8
bojanz commentedManual testing has shown that modifying the $complete_form doesn't work right now, if you click "Void" on a payment listing, the title is not overridden. We're missing test coverage.
Working on a reroll that deprecates commerce_profile_select and moves the logic to a CustomerProfile inline form.
Added a change record.
Regarding #5.3, core seems to mostly use the current ordering, and phpcs tells me nothing?
Comment #9
bojanz commentedThis doesn't address #7 and #8 yet.
Changes:
1) Converted PaymentMethodAddForm (last usage of the old form element.
2) Added a CustomerProfile inline form.
3) Deprecated commerce_profile_select (and made it use the inline form)
4) Converted PaymentInformation to use the CustomerProfile inline form.
We are still using commerce_profile_select in one place (the payment method add plugin form), kept to test the BC layer, just like in #4.
Comment #10
bojanz commentedThis patch escalated, time to land it.
#7.1 is addressed by the assert() I added to InlineFormManager.
I don't have a problem with #7.2 cause we try to discourage people from inheriting from these panes, and will require them to update their code anyway if they used the old form elements.
Fixed the $complete_form problems from #8, and adds test coverage for the title.
This also addresses #7.3.
Converted all remaining places where commerce_profile_select was used.
Fixed wrong docs for $inline_form['#parents'].
Plus, other cleanups.
Comment #12
bojanz commentedThe test fails were triggered by a really odd bug.
If the customer profile form is nested inside the payment method add form (both inline forms), only the first address ajax will work, the rest just get an empty $form['billing_information']. This was fixed by no longer assigning the form display to $this->formDisplay, but instead doing EntityFormDisplay::collectRenderDisplay() every time, just like in the old form element. Don't ask me why this makes a difference (EDIT: cause serialization. DependencySerializationTrait doesn't fix formDisplay cause it's an entity).
Comment #14
bojanz commentedFinally!
Followups:
#3016357: Convert the coupon redemption form to an inline form
#3016358: Create a "content_entity" inline form to replace usage of the IEF form element
Comment #15
bojanz commentedNote that #3017558: Regression in PaymentGatewayForm::submitInlineForm() has changed the semantics, inline forms are now automatically validated/submitted.
The change record has been updated.
Comment #17
vacho commentedSorry. What is the steps to use these plugins? for set a inline form with custom fields for one gateway.
Comment #18
johnpitcairn commentedHaha, I've just discovered this while making a custom private fork of Commerce Profile Panes based instead on the current implementation of the commerce customer profile. So everything is now available for alteration in the parent checkout flow form, instead of needing
hook_inline_entity_form_entity_form_alter()(though you will still need#after_buildcallbacks on any complex form widgets within that).A bit of code to refactor, but the result is looking totally worth it for inline entity forms in checkout panes. Many thanks.