Problem/Motivation
When a DonationItemPane is instantiated, it stores the donation item on an internal property. When the pane's form is submittedDonationItemPaneBase::submitPaneForm, that donation item is saved. However, the donation object stored in the pane instance holds all data as it existed when the pane was created. This means that if the donation order item is updated between pane instantiation and form submission, those changes are overridden with the values on the order item as they existed when the pane was instantiated.
Steps to reproduce
Add a custom donation_pane_validate() or donation_pane_submit() as below that updates the price of the donation.
function my_donation_pane_validate(array $form, \Drupal\Core\Form\FormStateInterface $form_state) {
$route = \Drupal::routeMatch();
/** @var \Drupal\commerce_order\Entity\OrderInterface $order */
$order = $route->getParameter('commerce_order');
foreach ($order->getItems() as $order_item) {
if ($order_item->get('type')->isEmpty()
|| $order_item->get('type')->target_id !== 'donation') {
continue;
}
$set_unit_price = 300; // Some logic to get your new donation value.
$price = new \Drupal\commerce_price\Price(
$set_unit_price,
$order->getStore()->getDefaultCurrencyCode()
);
// These are both needed as the donation amount is
// used by commerce_donation_flow separately from the unit price
$order_item->setUnitPrice($price, TRUE);
$order_item->set('field_donation_amount', $price);
$order_item->save();
}
}
If the validation function updated the price from $10 to $300, submitForm reverts the price back to $10 because the original version of the order item, with the old unit price, is being saved.
Proposed resolution
Add a refresher method to ensure that the donationItem prop is current before executing the save. Attaching a patch to achieve this.
| Comment | File | Size | Author |
|---|---|---|---|
| #3 | 3243454--refresh-order-item-in-pane-submitForm--3.patch | 3.39 KB | mrweiner |
Comments
Comment #2
mrweiner commentedComment #3
mrweiner commentedComment #4
mrweiner commentedComment #5
mrweiner commentedActually this may need work. I see that you're setting data on $this->donationItem in the validate function as well. I don't know whether or not this solution conflicts at all with what's going on in there.
Comment #6
mrweiner commentedAnother note,
submitFormwas also problematic if theorderItemis deleted in the custom validate/submit callback. If instead of the snippet in the issue description you do something like:There's some error like "cannot update entity ID during save," which I think is from the same bug. The order item entity has been deleted, but the pane is saving the old instance with the ID that not longer exists, causing some sort of conflict in Drupal's internal entity handler. I wonder if we really need to save the item in
submitFormat all?Comment #7
fathershawnIn our original scenario, the OrderItem was the purchaseable entity. A pane for in a step essentially built the data for the purchase on each step: amount and honoree.
This validate method in
DonationItemPaneBaseshould be updating the pane's copy of the item with the values on the pane's form.I'll re-check. Thanks for your help in getting a solid, generalized, code base.
Comment #8
mrweiner commentedNo problem! :D
Okay yeah that's what I thought. I think the problems are that this is ineffective if either the changes happen outside of the context of the
$form_stateor any custom callbacks fire between the built-invalidatePaneFormandsubmitPaneForm, as both situations will lead to a mismatch in internal vs and external donationItem metadata.Comment #9
fathershawnSince the OrderItem is what's being purchased here, and it doesn't get saved until submit, it seems like we need to operate on a locally held copy of the it. Hooks or callbacks can't load it from storage. If the local copy isn't in the pane, the only other option that occurs to me immediately is the FormState object since we're in form context.
Also, there's no need to load the order from the route. The checkout flow object has it and stores it in
\Drupal\commerce_checkout\Plugin\Commerce\CheckoutPane\CheckoutPaneBase::__constructComment #10
mrweiner commentedHooks and callbacks can load it from storage, though, if they are looping over $order->getItems() (whether $order is coming from the pane, the route, or wherever). The source of truth for a third-party is really the item on the order, not necessarily the donationItem in the pane, because the pane's donationItem is just a stored reference from when the pane is initialized.
I think this issue stems from modifying the the order item in
validatePaneForm, but saving it insubmitPaneForm, because it can lead to this flow:$this->donationItemDonationItemPaneBase::validatePaneFormfires, which sets state on the local copy of the donation item$this->donationItemin the pane's state (since it is a copy). Now there is a mismatch where the pane has a stale version of the item.submitPaneFormsaves the stale, local version of the order item, which undoes any modifications done by the custom callback. It's like loading the order item entity, setting all values to the old ones, and then saving the entity.In step 4, even if they were just to modify the
$form_state, then step 5 would still be out of sync since it doesn't do any discovery on the of the$form_state. If the order item entity is deleted in step 4 instead of just modified, then you end up with the "cannot update entity ID during save" from step 5.I will say that moving our logic from a #validate callback to a #submit callback avoids the problem. It may be accurate that a third-party callback should not modify+save the entity inside of #validate. But, if they do, it is awkward that the changes are tossed out without any notice or an error is thrown if the entity was deleted.
As far as loading the $order from the route, you're right that it is accessible when you're in the pane, but if you're in a custom callback attached to the checkout form itself then I'm not sure how to load it other than from the route. I don't think it's available in
$form_stateor the$form array, is it?Comment #11
fathershawnGood points all - I'm going to have a look at some other checkout panes, and core entity forms for some models.
The pane gets it from the flow. Does that work for your context?
\Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowInterface::getOrderComment #12
fathershawnI don't see this in our code (
commerce_donation_flow\Plugin\Commerce\CheckoutFlow\DonationCheckoutFlow::buildSummaries) nor incommerce_checkout\Plugin\Commerce\CheckoutPane\OrderSummary. I want to account for these cases in a refactor, so if you could point me to examples that would save time.Comment #13
mrweiner commentedRight, it's not present in commerce_donation_flow code. Everything that I'm referring to is the result of us (as in me, on my own project, not us as in commerce_donation_flow) adding a callback on the checkout form in another module that modifies the donation item.
Comment #14
mrweiner commentedTo clarify, when DonationItemPaneBase::create() does
$instance->donationItem = reset($donations);,$instance->donationItemis now a copy of the entity. That's what I'm referring to by "stale". It is not affected by outside modifications to the donation item itself, because it's a copy of the object as it existed at instantiation.Comment #15
mrweiner commentedI don't think that the checkout flow is available from the callback, unless there's a service I'm missing that can fetch it. It's not on $form or $form_state directly.
Comment #16
fathershawnThere was an issue fork for #3243886: Alter method of getting the donation item - See MR there for an approach that solves that issue and this.
Comment #17
fathershawnComment #18
fathershawn