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.

Comments

bojanz created an issue. See original summary.

alexpott’s picture

+1,000,000

In core we have \Drupal\Core\Form\SubformState - but I think this is more forms inside of forms.

bojanz’s picture

Assigned: Unassigned » bojanz

Back on this issue, finally.

bojanz’s picture

Status: Active » Needs review
StatusFileSize
new47.45 KB

Initial 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.

mglaman’s picture

First, 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.

  1. +++ b/modules/payment/src/Element/PaymentGatewayForm.php
    @@ -3,15 +3,15 @@
    + * @deprecated Use the payment_gateway_form inline form instead.
    + *
    

    Should processForm throw a trigger_error, or however core logs deprecation notices

  2. +++ b/modules/payment/src/Form/PaymentAddForm.php
    @@ -26,6 +27,13 @@ class PaymentAddForm extends FormBase implements ContainerInjectionInterface {
    @@ -38,11 +46,14 @@ class PaymentAddForm extends FormBase implements ContainerInjectionInterface {
    

    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.

  3. +++ b/src/Annotation/CommerceInlineForm.php
    @@ -0,0 +1,34 @@
    +   *
    +   * @ingroup plugin_translatable
    +   *
    +   * @var \Drupal\Core\Annotation\Translation
    +   */
    

    nit: phpcs says these should be flipped.

  4. +++ b/src/Plugin/Commerce/InlineForm/EntityInlineFormBase.php
    @@ -0,0 +1,52 @@
    +    if (empty($this->entity)) {
    +      throw new \RuntimeException(sprintf('No entity was provided to "%s".', $this->pluginId));
    +    }
    +    if (!($this->entity instanceof $interface)) {
    +      throw new \RuntimeException(sprintf('Invalid entity provided to "%s", expecting an implementation of "%s".', $this->pluginId, $interface));
    +    }
    

    These checks are technically the same. Do we have both for verbosity in error reporting?

  5. +++ b/src/Plugin/Commerce/InlineForm/EntityInlineFormInterface.php
    @@ -0,0 +1,32 @@
    +interface EntityInlineFormInterface extends InlineFormInterface {
    

    I agree, I like having this specific interface.

bojanz’s picture

Should processForm throw a trigger_error, or however core logs deprecation notices

Yep, forgot that.

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.

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.

These checks are technically the same. Do we have both for verbosity in error reporting?

Yeah, I wanted to differentiate "wrong entity" from "no entity", in case NULL was accidentally passed. Easier to debug.

alexpott’s picture

  1. +++ b/modules/payment/src/Element/PaymentGatewayForm.php
    @@ -142,38 +124,10 @@ class PaymentGatewayForm extends RenderElement {
    +    $form_state->setValueForElement($element, $inline_form->getEntity());
    

    Are 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?

  2. +++ b/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentInformation.php
    @@ -41,11 +41,11 @@ class PaymentInformation extends CheckoutPaneBase {
       /**
    -   * The messenger.
    +   * The inline form manager.
        *
    -   * @var \Drupal\Core\Messenger\MessengerInterface
    +   * @var \Drupal\commerce\InlineFormManager
        */
    -  protected $messenger;
    +  protected $inlineFormManager;
     
       /**
        * The payment options builder.
    @@ -69,16 +69,16 @@ class PaymentInformation extends CheckoutPaneBase {
    
    @@ -69,16 +69,16 @@ class PaymentInformation extends CheckoutPaneBase {
        *   The entity type manager.
        * @param \Drupal\Core\Session\AccountInterface $current_user
        *   The current user.
    -   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
    -   *   The messenger.
    +   * @param \Drupal\commerce\InlineFormManager $inline_form_manager
    +   *   The inline form manager.
        * @param \Drupal\commerce_payment\PaymentOptionsBuilderInterface $payment_options_builder
        *   The payment options builder.
        */
    -  public function __construct(array $configuration, $plugin_id, $plugin_definition, CheckoutFlowInterface $checkout_flow, EntityTypeManagerInterface $entity_type_manager, AccountInterface $current_user, MessengerInterface $messenger, PaymentOptionsBuilderInterface $payment_options_builder) {
    +  public function __construct(array $configuration, $plugin_id, $plugin_definition, CheckoutFlowInterface $checkout_flow, EntityTypeManagerInterface $entity_type_manager, AccountInterface $current_user, InlineFormManager $inline_form_manager, PaymentOptionsBuilderInterface $payment_options_builder) {
    

    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.

  3. +++ b/modules/payment/src/Plugin/Commerce/InlineForm/PaymentGatewayForm.php
    @@ -0,0 +1,180 @@
    +  public function defaultConfiguration() {
    +    return [
    +      'operation' => NULL,
    +      // The url to which the user will be redirected if an exception is thrown
    +      // while building the form. If empty, the error will be shown inline.
    +      'exception_url' => '',
    +      'exception_message' => $this->t('An error occurred while contacting the gateway. Please try again later.'),
    +    ];
    +  }
    ...
    +      if (!empty($inline_form['#page_title'])) {
    

    Should #page_title = NULL be in the default config as a way of documenting this?

bojanz’s picture

Status: Needs review » Needs work

Manual 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?

bojanz’s picture

Status: Needs work » Needs review
StatusFileSize
new63 KB
new17.41 KB

This 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.

bojanz’s picture

StatusFileSize
new87.49 KB
new36.91 KB

This 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.

Status: Needs review » Needs work

The last submitted patch, 10: 3003121-10.patch, failed testing. View results

bojanz’s picture

Status: Needs work » Needs review
StatusFileSize
new87.47 KB
new2.41 KB

The 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).

  • bojanz committed 2bb50de on 8.x-2.x
    Issue #3003121 by bojanz, alexpott, mglaman: Add an InlineForm API, stop...
bojanz’s picture

Note that #3017558: Regression in PaymentGatewayForm::submitInlineForm() has changed the semantics, inline forms are now automatically validated/submitted.
The change record has been updated.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

vacho’s picture

Sorry. What is the steps to use these plugins? for set a inline form with custom fields for one gateway.

johnpitcairn’s picture

Haha, 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_build callbacks 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.