diff --git a/modules/cart/src/Form/AddToCartForm.php b/modules/cart/src/Form/AddToCartForm.php index 8a6de1e6a1..e3607ee568 100644 --- a/modules/cart/src/Form/AddToCartForm.php +++ b/modules/cart/src/Form/AddToCartForm.php @@ -59,13 +59,11 @@ class AddToCartForm extends ContentEntityForm { protected $chainPriceResolver; /** - * The form instance ID. + * The form instance IDs of purchased entities. * - * Numeric counter used to ensure form ID uniqueness. - * - * @var int + * @var array */ - protected static $formInstanceId = 0; + protected static $FormInstanceIds = []; /** * The current user. @@ -105,8 +103,6 @@ public function __construct(EntityManagerInterface $entity_manager, EntityTypeBu $this->storeContext = $store_context; $this->chainPriceResolver = $chain_price_resolver; $this->currentUser = $current_user; - - self::$formInstanceId++; } /** @@ -144,9 +140,21 @@ public function getFormId() { if ($this->operation != 'default') { $form_id = $form_id . '_' . $this->operation; } - $form_id .= '_' . self::$formInstanceId; + $id = sha1(serialize($this->entity->getPurchasedEntity()->toArray())); + // For the case when on a page 2+ exactly the same purchased entities. + while (in_array($id, static::$FormInstanceIds)) { + $id = sha1($id . $id); + } + static::$FormInstanceIds[] = $id; + + return $form_id . '_' . $id . '_form'; + } - return $form_id . '_form'; + /** + * {@inheritdoc} + */ + public function getFormInstanceIds() { + return static::$FormInstanceIds; } /** diff --git a/modules/cart/tests/src/Functional/AddToCartFormTest.php b/modules/cart/tests/src/Functional/AddToCartFormTest.php index 23bcab6ff9..309bed1682 100644 --- a/modules/cart/tests/src/Functional/AddToCartFormTest.php +++ b/modules/cart/tests/src/Functional/AddToCartFormTest.php @@ -174,9 +174,12 @@ public function testProductAttributeDisabledIfOne() { $product->variations->appendItem($variation); } $product->save(); + $product_variations = $product->getVariations(); + $current_variation = current($product_variations); + $id = sha1(serialize($current_variation->toArray())); $this->drupalGet($product->toUrl()); - $this->assertSession()->elementExists('xpath', '//select[@id="edit-purchased-entity-0-attributes-attribute-color" and @disabled]'); + $this->assertSession()->elementExists('xpath', '//select[@id="edit-purchased-entity-0-attributes-attribute-color-' . $id . '" and @disabled]'); } /** @@ -283,11 +286,14 @@ public function testOptionalProductAttribute() { $product->variations->appendItem($variation); } $product->save(); + $product_variations = $product->getVariations(); + $current_variation = current($product_variations); + $id = sha1(serialize($current_variation->toArray())); // The color element should be required because each variation has a color. $this->drupalGet($product->toUrl()); $this->assertSession()->fieldExists('purchased_entity[0][attributes][attribute_size]'); - $this->assertSession()->elementExists('xpath', '//select[@id="edit-purchased-entity-0-attributes-attribute-color" and @required]'); + $this->assertSession()->elementExists('xpath', '//select[@id="edit-purchased-entity-0-attributes-attribute-color-' . $id . '" and @required]'); // Remove the color value from all variations. // The color element should now be hidden. diff --git a/modules/cart/tests/src/Functional/CartBrowserTestBase.php b/modules/cart/tests/src/Functional/CartBrowserTestBase.php index f417d06451..7648775cd0 100644 --- a/modules/cart/tests/src/Functional/CartBrowserTestBase.php +++ b/modules/cart/tests/src/Functional/CartBrowserTestBase.php @@ -11,6 +11,7 @@ use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; use Drupal\Tests\commerce_order\Functional\OrderBrowserTestBase; +use Behat\Mink\Element\NodeElement as BehatNodeElement; /** * Defines base class for commerce_cart test cases. @@ -228,4 +229,111 @@ protected function assertOrderItemInOrder(ProductVariationInterface $variation, ])); } + /** + * Helper method to fetch values from the Add to Cart form displayed on a page. + * + * Works with the following variation attribute form display widgets: select + * list, radio buttons, rendered attribute and variation titles select list. + * + * @param \Behat\Mink\Element\NodeElement $form + * The Add to Cart form object. + * + * @return array + * The array of values: + * - "product_id": The form parent product ID. + * - "form_id": The Add to cart form ID. + * - "title": The selected variation title. + * - "sku": The selected variation SKU. + * - "price": The selected variation formatted price. + * - "attributes": Attributes labels and values keyed by a field label: + * - "ignored"|"chosen": The (not) selected/(un)checked attribute data. + * - "label": The label for an attribute. + * - "value": The ID of an attribute. + * + * @see \Drupal\Tests\commerce_cart\FunctionalJavascript\MultipleCartFormsTest->testMultipleCartsOnPage() + */ + protected function getAddToCartFormValues(BehatNodeElement $form) { + $values = []; + $values['product_id'] = ''; + $values['form_id'] = $form->getAttribute('id'); + $grand_parent = $form->getParent()->getParent(); + $title = $grand_parent->find('css', '[class^="product--variation-field--variation_title__"]'); + $values['title'] = is_object($title) ? $title->getText() : ''; + $sku = $grand_parent->find('css', '[class^="product--variation-field--variation_sku__"]'); + $values['sku'] = is_object($sku) ? $sku->getText() : ''; + if (!empty($values['sku'])) { + foreach (explode(' ', $sku->getAttribute('class')) as $class) { + $parts = explode('product--variation-field--variation_sku__', $class); + if (count($parts) == 2 && is_numeric($parts[1])) { + $values['product_id'] = $parts[1]; + } + } + } + $price = $grand_parent->find('css', '[class^="product--variation-field--variation_price__"]'); + $values['price'] = is_object($price) ? $price->find('css', '.field__item')->getText() : ''; + $attributes = $form->find('css', '[id^="edit-purchased-entity-0-attributes"]') ?: $form->find('css', '.form-item-purchased-entity-0-variation'); + $values['attributes'] = []; + if (is_object($attributes)) { + $titles = $attributes->findAll('css', '[id^="edit-purchased-entity-0-variation"]'); + $attributes = $titles ?: $attributes->findAll('css', '[id^="edit-purchased-entity-0-attributes-attribute-"]'); + if (!empty($attributes)) { + foreach ($attributes as $attribute) { + $element = $attribute->getTagName(); + if ($element == 'select') { + $parent = $attribute->getParent()->find('css', '[for^="edit-purchased-entity-0-attributes-attribute-"]'); + $parent = $parent ?: $attributes[0]->getParent()->find('css', '[for^="edit-purchased-entity-0-variation"]'); + $field_label = $parent->getText(); + foreach ($attribute->findAll('named', ['option', '']) as $option) { + $values['attributes'][$field_label][$option->isSelected() ? 'chosen' : 'ignored'][] = [ + 'label' => $option->getText(), + 'value' => $option->getValue(), + ]; + } + } + elseif ($element == 'fieldset' && $legend = $attribute->find('css', '.fieldset-legend')) { + $field_label = $legend->getText(); + foreach ($attribute->findAll('named', ['radio', '']) as $radio) { + $values['attributes'][$field_label][$radio->isChecked() ? 'chosen' : 'ignored'][] = [ + 'label' => $radio->getParent()->find('css', '[for^="edit-purchased-entity-0-attributes-attribute-"]')->getText(), + 'value' => $radio->getAttribute('value'), + ]; + } + } + } + } + } + + return $values; + } + + /** + * Helper method to fetch values from the shopping cart displayed on a page. + * + * @param \Behat\Mink\Element\NodeElement $cart + * The Shopping Cart form object. + * + * @return array + * The array of order items having the following values: + * - "title": The title of an order item. + * - "quantity": The quantity of an order item. + * - "price": The price of an order item. + * - "total": The total of an order item. + * + * @see \Drupal\Tests\commerce_cart\FunctionalJavascript\MultipleCartFormsTest->assertAddToCartFormValues() + */ + protected function getShoppingCartValues(BehatNodeElement $cart) { + $order_items = []; + foreach ($cart->findAll('css', 'td.views-field-purchased-entity') as $item) { + $row = $item->getParent(); + $order_items[] = [ + 'title' => $row->find('css', '.field--name-title')->getText(), + 'quantity' => $row->find('css', '[id^="edit-edit-quantity"]')->getValue(), + 'price' => $row->find('css', '.views-field-unit-price__number')->getText(), + 'total' => $row->find('css', '.views-field-total-price__number')->getText(), + ]; + } + + return $order_items; + } + } diff --git a/modules/cart/tests/src/FunctionalJavascript/MultipleCartFormsTest.php b/modules/cart/tests/src/FunctionalJavascript/MultipleCartFormsTest.php new file mode 100644 index 0000000000..575bec52de --- /dev/null +++ b/modules/cart/tests/src/FunctionalJavascript/MultipleCartFormsTest.php @@ -0,0 +1,203 @@ +variation->bundle()); + + $color_attributes = $this->createAttributeSet($variation_type, 'color', [ + 'red' => 'Red', + 'blue' => 'Blue', + ]); + $size_attributes = $this->createAttributeSet($variation_type, 'size', [ + 'small' => 'Small', + 'medium' => 'Medium', + 'large' => 'Large', + ]); + + // The matrix is intentionally uneven, blue / large is missing. + $attribute_values_matrix = [ + ['red', 'small'], + ['red', 'medium'], + ['red', 'large'], + ['blue', 'small'], + ['blue', 'medium'], + ]; + + $price_number_matrix = [ + 1 => [ + [1 => '1'], + [2 => '2'], + [3 => '3'], + [4 => '4'], + [5 => '5'], + ], + 2 => [ + [1 => '6'], + [2 => '7'], + [3 => '8'], + [4 => '9'], + [5 => '10'], + ], + 3 => [ + [1 => '11'], + [2 => '12'], + [3 => '13'], + [4 => '14'], + [5 => '15'], + ], + 4 => [ + [1 => '16'], + [2 => '17'], + [3 => '18'], + [4 => '19'], + [5 => '20'], + ], + 5 => [ + [1 => '21'], + [2 => '22'], + [3 => '23'], + [4 => '24'], + [5 => '25'], + ], + ]; + + for ($i = 1; $i < 6; $i++) { + // Generate products with variations off of the attributes values matrix. + $j = 0; + $variations = []; + foreach ($attribute_values_matrix as $key => $value) { + $variation = $this->createEntity('commerce_product_variation', [ + 'type' => $variation_type->id(), + 'sku' => $this->randomMachineName(), + 'price' => new Price($price_number_matrix[$i][$j][$j + 1], 'USD'), + 'attribute_color' => $color_attributes[$value[0]], + 'attribute_size' => $size_attributes[$value[1]], + ]); + $variations[] = $variation; + $j++; + } + if ($i == 1) { + $product = $this->variation->getProduct(); + $product->setVariations($variations); + $product->updateOriginalvalues(); + $product->save(); + $this->products[] = $product; + } + else { + $this->products[] = $this->createEntity('commerce_product', [ + 'type' => 'default', + 'title' => $this->randomMachineName(), + 'stores' => [$this->store], + 'variations' => $variations, + ]); + } + } + } + + /** + * Tests that a page with multiple add to cart forms works properly. + */ + public function testMultipleCartsOnPage() { + // The matrix to change values on Add to cart forms in the given offset + // order. Don't use Red and Small values as they are selected by default. + $offset_matrix = [ + 3 => ['size' => 'Large'], + 1 => ['color' => 'Blue'], + 0 => ['size' => 'Medium'], + 2 => ['color' => 'Blue'], + 4 => ['size' => 'Large'], + ]; + + foreach ($offset_matrix as $offset => $attribute) { + $this->drupalGet('/test-multiple-cart-forms'); + /** @var \Behat\Mink\Element\NodeElement[] $forms */ + $forms = $this->getSession()->getPage()->findAll('css', '.commerce-order-item-add-to-cart-form'); + $this->assertCount(5, $forms, 'Displayed 5 Add to cart forms.'); + $values = $this->addProductVariationToCart($forms, $offset, $attribute); + // Assert expected form values before and after submission. + $this->assertAddToCartFormValues($values); + } + } + + /** + * {@inheritdoc} + */ + protected function addProductVariationToCart(array $forms, $offset = 0, $attribute = ['size' => 'Medium']) { + $init = $this->getAddToCartFormValues($forms[$offset]); + $init['count'] = count($forms); + $field = array_keys($attribute)[0]; + $label = reset($attribute); + + // Change the attribute option to trigger the AJAX form reloading. + $forms[$offset]->selectFieldOption("purchased_entity[0][attributes][attribute_{$field}]", $label); + $this->waitForAjaxToFinish(); + // Extract updated form values. + $forms = $this->getSession()->getPage()->findAll('css', '.commerce-order-item-add-to-cart-form'); + $after = $this->getAddToCartFormValues($forms[$offset]); + $this->submitForm([], 'Add to cart', $after['form_id']); + + return [ + 'init' => $init, + 'after' => $after, + 'offset' => $offset, + 'field' => $field, + 'label' => $label, + ]; + } + + /** + * {@inheritdoc} + */ + protected function assertAddToCartFormValues(array $values) { + $this->drupalGet('cart'); + $cart = $this->getSession()->getPage()->find('css', '[id^="views-form-commerce-cart-form-default"]'); + $this->assertTrue(is_object($cart), 'The Shopping cart is displayed.'); + $cart = $this->getShoppingCartValues($cart); + $cart = end($cart); + $label = ucfirst($values['field']); + $init = $values['init']['attributes'][$label]['chosen']; + $after = $values['after']['attributes'][$label]['chosen']; + + // Ensure we are on the same product where we had triggered an AJAX action. + $this->assertSame($values['init']['product_id'], $values['after']['product_id'], 'The product ID is the same.'); + // Both titles must be the same. + $this->assertSame($cart['title'], $values['after']['title'], 'The Shopping cart title and Add to cart title are equal.'); + // All the other values are expected being replaced by new ones. + $this->assertNotEquals($values['init']['form_id'], $values['after']['form_id'], 'The Add to cart form ID is changed.'); + $this->assertNotEquals($values['init']['title'], $values['after']['title'], 'The Add to cart form title is changed.'); + $this->assertNotEquals($values['init']['sku'], $values['after']['sku'], 'The variation SKU is changed.'); + $this->assertNotEquals($values['init']['price'], $values['after']['price'], 'The variation price is changed.'); + $this->assertNotEquals($init[0]['label'], $after[0]['label'], "The {$label} attribute label is changed."); + $this->assertNotEquals($init[0]['value'], $after[0]['value'], "The {$label} attribute value is changed."); + // Prices on the Shopping cart page are formatted without decimals ($99). + // Extract price number value to recreate object and properly compare then. + $cart['price'] = new Price(ltrim($cart['price'], '$'), 'USD'); + $values['after']['price'] = new Price(ltrim($values['after']['price'], '$'), 'USD'); + $this->assertTrue($cart['price']->equals($values['after']['price']), 'The Shopping cart price and Add to cart price are equal.'); + } + +} diff --git a/modules/product/src/Plugin/Field/FieldWidget/ProductVariationAttributesWidget.php b/modules/product/src/Plugin/Field/FieldWidget/ProductVariationAttributesWidget.php index 7407ad2d43..5e94ce13c8 100644 --- a/modules/product/src/Plugin/Field/FieldWidget/ProductVariationAttributesWidget.php +++ b/modules/product/src/Plugin/Field/FieldWidget/ProductVariationAttributesWidget.php @@ -112,15 +112,17 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen } // Build the full attribute form. - $wrapper_id = Html::getUniqueId('commerce-product-add-to-cart-form'); + $ids = $form_state->getFormObject()->getFormInstanceIds(); + $id = end($ids); + $wrapper_id = Html::getUniqueId('commerce-product-add-to-cart-form-' . $id); $form += [ '#wrapper_id' => $wrapper_id, '#prefix' => '
', '#suffix' => '
', ]; - $parents = array_merge($element['#field_parents'], [$items->getName(), $delta]); - $user_input = (array) NestedArray::getValue($form_state->getUserInput(), $parents); - if (!empty($user_input)) { + if ($form_state->getTriggeringElement()) { + $parents = array_merge($element['#field_parents'], [$items->getName(), $delta]); + $user_input = (array) NestedArray::getValue($form_state->getUserInput(), $parents); $selected_variation = $this->selectVariationFromUserInput($variations, $user_input); } else { @@ -146,6 +148,7 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen ]; foreach ($this->getAttributeInfo($selected_variation, $variations) as $field_name => $attribute) { $element['attributes'][$field_name] = [ + '#id' => Html::getUniqueId('edit-purchased-entity-0-attributes-' . $field_name . '-' . $id), '#type' => $attribute['element_type'], '#title' => $attribute['title'], '#options' => $attribute['values'], @@ -176,6 +179,7 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen if (!isset($element['attributes'][$field_name]['#empty_value'])) { $element['attributes'][$field_name]['#required'] = TRUE; } + } return $element;