diff --git a/modules/cart/src/Tests/AddToCartFormTest.php b/modules/cart/src/Tests/AddToCartFormTest.php index 5e71534704..c7b570de79 100644 --- a/modules/cart/src/Tests/AddToCartFormTest.php +++ b/modules/cart/src/Tests/AddToCartFormTest.php @@ -318,6 +318,60 @@ public function testRenderedAttributeElement() { $this->assertText('Magenta (Rendered)'); } + /** + * Tests that the cart refreshes rendered variation fields. + */ + public function testRenderedVariationFields() { + /** @var \Drupal\commerce_product\Entity\ProductVariationTypeInterface $variation_type */ + $variation_type = ProductVariationType::load($this->variation->bundle()); + + $color_attribute_values = $this->createAttributeSet($variation_type, 'color', [ + 'cyan' => 'Cyan', + 'magenta' => 'Magenta', + ], TRUE); + + /** @var \Drupal\commerce_product\Entity\ProductVariationInterface $variation1 */ + $variation1 = $this->createEntity('commerce_product_variation', [ + 'type' => 'default', + 'sku' => 'RENDERED_VARIATION_TEST_CYAN', + 'price' => [ + 'amount' => 999, + 'currency_code' => 'USD', + ], + 'attribute_color' => $color_attribute_values['cyan'], + ]); + /** @var \Drupal\commerce_product\Entity\ProductVariationInterface $variation2 */ + $variation2 = $this->createEntity('commerce_product_variation', [ + 'type' => 'default', + 'sku' => 'RENDERED_VARIATION_TEST_MAGENTA', + 'price' => [ + 'amount' => 999, + 'currency_code' => 'USD', + ], + 'attribute_color' => $color_attribute_values['magenta'], + ]); + $product = $this->createEntity('commerce_product', [ + 'type' => 'default', + 'title' => 'RENDERED_VARIATION_TEST', + 'stores' => [$this->store], + 'variations' => [$variation1, $variation2], + ]); + + $this->drupalGet($product->toUrl()); + + $this->assertText($variation1->getSku(), 'The SKU for the first variation is visible'); + + $response = $this->drupalPostAjaxForm(NULL, [ + 'purchased_entity[0][attributes][attribute_color]' => $color_attribute_values['magenta']->id(), + ], 'purchased_entity[0][attributes][attribute_color]'); + + foreach ($response as $command) { + if ($command['command'] == 'insert' && $command['method'] == 'replaceWith' && $command['selector'] == '.product--variation-field--variation_sku__2') { + $this->assertTrue(strpos($command['data'], $variation2->getSku()) !== FALSE); + } + } + } + /** * Creates an attribute field and set of attribute values. * diff --git a/modules/product/commerce_product.module b/modules/product/commerce_product.module index 06acdcdc29..bb4acb85bd 100644 --- a/modules/product/commerce_product.module +++ b/modules/product/commerce_product.module @@ -10,6 +10,8 @@ use Drupal\field\Entity\FieldStorageConfig; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\Display\EntityFormDisplayInterface; +use Drupal\Core\Entity\Display\EntityViewDisplayInterface; +use Drupal\commerce_product\Entity\ProductType; /** * Implements hook_entity_create(). @@ -70,6 +72,43 @@ function commerce_product_theme() { ]; } +/** + * Implements hook_ENTITY_TYPE_view(). + */ +function commerce_product_commerce_product_view(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) { + /** @var \Drupal\commerce_product\Entity\ProductInterface $entity */ + $product_type = ProductType::load($entity->bundle()); + if ($product_type->shouldInjectVariationFields()) { + /** @var \Drupal\commerce_product\ProductVariationFieldRendererInterface $variation_field_renderer */ + $variation_field_renderer = \Drupal::service('commerce_product.variation_field_renderer'); + + $rendered_fields = $variation_field_renderer->renderFields($entity->getDefaultVariation(), $view_mode); + foreach ($rendered_fields as $field_name => $rendered_field) { + $build['product_variation_' . $field_name] = $rendered_field; + } + } +} + +/** + * Implements hook_ENTITY_TYPE_view(). + */ +function commerce_product_commerce_product_variation_view(array &$build, \Drupal\Core\Entity\EntityInterface $entity, \Drupal\Core\Entity\Display\EntityViewDisplayInterface $display, $view_mode) { + $stop = null; +} + +/** + * Implements hook_entity_display_build_alter(). + */ +function commerce_product_entity_display_build_alter(&$build, $context) { + if ($context['entity'] instanceof \Drupal\commerce_product\Entity\ProductVariationInterface) { + foreach ($build as $key => &$item) { + $field_class = \Drupal::service('commerce_product.variation_field_renderer')->getAjaxReplacementClass($key, $context['entity']->getProductId()); + $item['#attributes']['class'][] = $field_class; + $item['#ajax_replace_class'] = $field_class; + } + } +} + /** * Implements hook_theme_suggestions_commerce_product(). */ diff --git a/modules/product/commerce_product.services.yml b/modules/product/commerce_product.services.yml index 3022106030..5237d5accc 100644 --- a/modules/product/commerce_product.services.yml +++ b/modules/product/commerce_product.services.yml @@ -10,3 +10,7 @@ services: commerce_product.lazy_builders: class: Drupal\commerce_product\ProductLazyBuilders arguments: ['@entity_type.manager', '@entity.form_builder', '@commerce_product.line_item_type_map'] + + commerce_product.variation_field_renderer: + class: Drupal\commerce_product\ProductVariationFieldRenderer + arguments: ['@entity_type.manager', '@entity_field.manager'] diff --git a/modules/product/config/schema/commerce_product.schema.yml b/modules/product/config/schema/commerce_product.schema.yml index 8ffad95f89..40c0a0f65b 100644 --- a/modules/product/config/schema/commerce_product.schema.yml +++ b/modules/product/config/schema/commerce_product.schema.yml @@ -14,6 +14,9 @@ commerce_product.commerce_product_type.*: variationType: type: string label: 'Variation type' + injectVariationFields: + type: boolean + label: 'Inject product variation fields into the rendered product' commerce_product.commerce_product_variation_type.*: type: config_entity diff --git a/modules/product/src/Entity/Product.php b/modules/product/src/Entity/Product.php index 4980204c1a..abd15528b2 100644 --- a/modules/product/src/Entity/Product.php +++ b/modules/product/src/Entity/Product.php @@ -183,6 +183,20 @@ public function setOwnerId($uid) { return $this->set('uid', $uid); } + /** + * {@inheritdoc} + */ + public function getDefaultVariation() { + foreach ($this->variations as $item) { + /** @var \Drupal\commerce_product\Entity\ProductVariationInterface $variation */ + $variation = $item->entity; + // Return the first active variation. + if ($variation->isActive()) { + return $variation; + } + } + } + /** * {@inheritdoc} */ diff --git a/modules/product/src/Entity/ProductInterface.php b/modules/product/src/Entity/ProductInterface.php index 54aaafaedf..5583488d9b 100644 --- a/modules/product/src/Entity/ProductInterface.php +++ b/modules/product/src/Entity/ProductInterface.php @@ -103,4 +103,12 @@ public function getStoreIds(); */ public function setStoreIds(array $store_ids); + /** + * Gets the default product variation. + * + * @return \Drupal\commerce_product\Entity\ProductVariationInterface + * The default product variation. + */ + public function getDefaultVariation(); + } diff --git a/modules/product/src/Entity/ProductType.php b/modules/product/src/Entity/ProductType.php index 26180ac2ef..66d32f6eeb 100644 --- a/modules/product/src/Entity/ProductType.php +++ b/modules/product/src/Entity/ProductType.php @@ -86,6 +86,13 @@ class ProductType extends ConfigEntityBundleBase implements ProductTypeInterface */ protected $variationType; + /** + * Indicates if variation fields should be injected. + * + * @var bool + */ + protected $injectVariationFields = TRUE; + /** * {@inheritdoc} */ @@ -116,4 +123,19 @@ public function setVariationTypeId($variation_type_id) { return $this; } + /** + * {@inheritdoc} + */ + public function shouldInjectVariationFields() { + return $this->injectVariationFields; + } + + /** + * {@inheritdoc} + */ + public function setInjectVariationFields($inject = TRUE) { + $this->injectVariationFields = (bool) $inject; + return $this; + } + } diff --git a/modules/product/src/Entity/ProductTypeInterface.php b/modules/product/src/Entity/ProductTypeInterface.php index e250ff60b4..ba9559dee7 100644 --- a/modules/product/src/Entity/ProductTypeInterface.php +++ b/modules/product/src/Entity/ProductTypeInterface.php @@ -28,4 +28,23 @@ public function getVariationTypeId(); */ public function setVariationTypeId($variation_type_id); + /** + * Gets whether variation fields should be injected into the rendered product. + * + * @return bool + * TRUE if the variation fields should be injected into the rendered + * product, FALSE otherwise. + */ + public function shouldInjectVariationFields(); + + /** + * Sets whether variation fields should be injected into the rendered product. + * + * @param bool $inject + * Whether variation fields should be injected into the rendered product. + * + * @return $this + */ + public function setInjectVariationFields($inject = TRUE); + } diff --git a/modules/product/src/Entity/ProductVariation.php b/modules/product/src/Entity/ProductVariation.php index 5a2d5f0645..486bcd0104 100644 --- a/modules/product/src/Entity/ProductVariation.php +++ b/modules/product/src/Entity/ProductVariation.php @@ -2,6 +2,7 @@ namespace Drupal\commerce_product\Entity; +use Drupal\Core\Cache\Cache; use Drupal\Core\Entity\ContentEntityBase; use Drupal\Core\Entity\EntityChangedTrait; use Drupal\Core\Entity\EntityStorageInterface; @@ -259,6 +260,18 @@ protected function getAttributeFieldNames() { return array_column($field_map, 'field_name'); } + /** + * {@inheritdoc} + */ + public function getCacheTagsToInvalidate() { + $tags = parent::getCacheTagsToInvalidate(); + // Invalidate the variations view builder and product caches. + return Cache::mergeTags($tags, [ + 'commerce_product:' . $this->getProductId(), + 'commerce_product_variation_view', + ]); + } + /** * {@inheritdoc} */ diff --git a/modules/product/src/Form/ProductTypeForm.php b/modules/product/src/Form/ProductTypeForm.php index 334c299872..873c56e895 100644 --- a/modules/product/src/Form/ProductTypeForm.php +++ b/modules/product/src/Form/ProductTypeForm.php @@ -97,6 +97,11 @@ public function form(array $form, FormStateInterface $form_state) { '#required' => TRUE, '#disabled' => !$product_type->isNew(), ]; + $form['injectVariationFields'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Inject product variation fields into the rendered product.'), + '#default_value' => $product_type->shouldInjectVariationFields(), + ]; $form['product_status'] = [ '#type' => 'checkbox', '#title' => t('Publish new products of this type by default.'), diff --git a/modules/product/src/Plugin/Field/FieldWidget/ProductVariationAttributesWidget.php b/modules/product/src/Plugin/Field/FieldWidget/ProductVariationAttributesWidget.php index 50c90c62a7..07ac368bf9 100644 --- a/modules/product/src/Plugin/Field/FieldWidget/ProductVariationAttributesWidget.php +++ b/modules/product/src/Plugin/Field/FieldWidget/ProductVariationAttributesWidget.php @@ -2,6 +2,7 @@ namespace Drupal\commerce_product\Plugin\Field\FieldWidget; +use Drupal\commerce_product\Entity\ProductVariation; use Drupal\commerce_product\Entity\ProductVariationInterface; use Drupal\commerce_product\ProductAttributeFieldManagerInterface; use Drupal\Component\Utility\Html; @@ -143,11 +144,13 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen $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); - $element['variation'] = [ '#type' => 'value', '#value' => $selected_variation->id(), ]; + // Set the selected variation in the form state for our AJAX callback. + $form_state->set('selected_variation', $selected_variation->id()); + $element['attributes'] = [ '#type' => 'container', '#attributes' => [ @@ -318,7 +321,19 @@ protected function getAttributeValues(array $variations, $field_name, callable $ * Ajax callback. */ public static function ajaxRefresh(array $form, FormStateInterface $form_state) { - return $form; + /** @var \Drupal\Core\Render\MainContent\MainContentRendererInterface $ajax_renderer */ + $ajax_renderer = \Drupal::service('main_content_renderer.ajax'); + $request = \Drupal::request(); + $route_match = \Drupal::service('current_route_match'); + /** @var \Drupal\Core\Ajax\AjaxResponse $response */ + $response = $ajax_renderer->renderResponse($form, $request, $route_match); + + $variation = ProductVariation::load($form_state->get('selected_variation')); + /** @var \Drupal\commerce_product\ProductVariationFieldRendererInterface $variation_field_renderer */ + $variation_field_renderer = \Drupal::service('commerce_product.variation_field_renderer'); + $variation_field_renderer->replaceRenderedFields($response, $variation, 'default'); + + return $response; } } diff --git a/modules/product/src/ProductVariationFieldRenderer.php b/modules/product/src/ProductVariationFieldRenderer.php new file mode 100644 index 0000000000..b5b4483973 --- /dev/null +++ b/modules/product/src/ProductVariationFieldRenderer.php @@ -0,0 +1,129 @@ +entityTypeManager = $entity_type_manager; + $this->entityFieldManager = $entity_field_manager; + $this->variationViewBuilder = $entity_type_manager->getViewBuilder('commerce_product_variation'); + } + + /** + * {@inheritdoc} + */ + public function getFieldDefinitions($variation_type_id) { + if (!isset($this->fieldDefinitions[$variation_type_id])) { + $definitions = $this->entityFieldManager->getFieldDefinitions('commerce_product_variation', $variation_type_id); + $definitions = array_filter($definitions, function ($definition) { + /** @var \Drupal\Core\Field\FieldDefinitionInterface $definition */ + $name = $definition->getName(); + if ($definition instanceof BaseFieldDefinition && !in_array($name, $this->getAllowedBaseFields())) { + return FALSE; + } + // Filter out attribute fields, they are already shown to the user as + // a part of the add to cart form. + if (strpos($name, 'attribute_') !== FALSE) { + return FALSE; + } + return TRUE; + }); + $this->fieldDefinitions[$variation_type_id] = $definitions; + } + + return $this->fieldDefinitions[$variation_type_id]; + } + + /** + * {@inheritdoc} + */ + public function renderFields(ProductVariationInterface $variation, $view_mode = 'default') { + $rendered_fields = []; + foreach ($this->getFieldDefinitions($variation->bundle()) as $field_name => $field_definition) { + $rendered_fields[$field_name] = $this->renderField($field_name, $variation, $view_mode); + } + + return $rendered_fields; + } + + /** + * {@inheritdoc} + */ + public function renderField($field_name, ProductVariationInterface $variation, $view_mode = 'default') { + $content = $this->variationViewBuilder->viewField($variation->get($field_name), $view_mode); + + return $content; + } + + /** + * {@inheritdoc} + */ + public function replaceRenderedFields(AjaxResponse $response, ProductVariationInterface $variation, $view_mode = 'default') { + $rendered_fields = $this->renderFields($variation, $view_mode); + foreach ($rendered_fields as $field_name => $rendered_field) { + $response->addCommand(new ReplaceCommand('.' . $rendered_field['#ajax_replace_class'], $rendered_field)); + } + } + + /** + * {@inheritdoc} + */ + public function getAjaxReplacementClass($field_name, $product_id) { + return 'product--variation-field--variation_' . $field_name . '__' . $product_id; + } + + /** + * Gets the allowed base field definitions for injection. + * + * @return array + * An array of base field names. + */ + protected function getAllowedBaseFields() { + return ['title', 'sku', 'price']; + } + +} diff --git a/modules/product/src/ProductVariationFieldRendererInterface.php b/modules/product/src/ProductVariationFieldRendererInterface.php new file mode 100644 index 0000000000..fcb37697e4 --- /dev/null +++ b/modules/product/src/ProductVariationFieldRendererInterface.php @@ -0,0 +1,83 @@ +createEntity('commerce_product_attribute', [ + 'id' => 'color', + 'label' => 'Color', + ]); + $attribute->save(); + \Drupal::service('commerce_product.attribute_field_manager')->createField($attribute, 'default'); + + $attribute_values = []; + foreach (['Cyan', 'Magenta', 'Yellow', 'Black'] as $color_attribute_value) { + $attribute_values[strtolower($color_attribute_value)] = $this->createEntity('commerce_product_attribute_value', [ + 'attribute' => $attribute->id(), + 'name' => $color_attribute_value, + ]); + } + + $this->product = $this->createEntity('commerce_product', [ + 'type' => 'default', + 'title' => $this->randomMachineName(), + 'stores' => $this->stores, + 'body' => ['value' => 'Testing product variation field injection!'], + 'variations' => [ + $this->createEntity('commerce_product_variation', [ + 'type' => 'default', + 'sku' => 'INJECTION-CYAN', + 'attribute_color' => $attribute_values['cyan']->id(), + 'price' => [ + 'amount' => 999, + 'currency_code' => 'USD', + ], + ]), + $this->createEntity('commerce_product_variation', [ + 'type' => 'default', + 'sku' => 'INJECTION-MAGENTA', + 'attribute_color' => $attribute_values['magenta']->id(), + 'price' => [ + 'amount' => 999, + 'currency_code' => 'USD', + ], + ]), + ], + ]); + } + + /** + * Tests the fields from the attribute render. + */ + public function testInjectedVariationDefault() { + // Hide the variations field, so it does not render the variant titles. + /** @var \Drupal\Core\Entity\Display\EntityViewDisplayInterface $product_view_display */ + $product_view_display = commerce_get_entity_display('commerce_product', $this->product->bundle(), 'view'); + $product_view_display->removeComponent('variations'); + $product_view_display->save(); + + $this->drupalGet($this->product->toUrl()); + $this->assertText('Testing product variation field injection!'); + $this->assertText('Price'); + $this->assertText('$999.00'); + $this->assertText('INJECTION-CYAN'); + $this->assertText($this->product->label() . ' - Cyan'); + + // Set a display for the color attribute. + /** @var \Drupal\Core\Entity\Entity\EntityViewDisplay $variation_view_display */ + $variation_view_display = commerce_get_entity_display('commerce_product_variation', 'default', 'view'); + $variation_view_display->removeComponent('title'); + $variation_view_display->removeComponent('sku'); + $variation_view_display->setComponent('attribute_color', [ + 'label' => 'above', + 'type' => 'entity_reference_label', + ]); + $variation_view_display->save(); + + // Save the variation and reset its view caches. For some reason saving + // the view display doesn't do this? + $this->product->getDefaultVariation()->save(); + + $this->drupalGet($this->product->toUrl()); + $this->assertNoText($this->product->label() . ' - Cyan'); + $this->assertNoText('INJECTION-CYAN'); + $this->assertText('$999.00'); + } + +} diff --git a/modules/product/templates/commerce-product.html.twig b/modules/product/templates/commerce-product.html.twig index eca14c523f..0e6198f73f 100644 --- a/modules/product/templates/commerce-product.html.twig +++ b/modules/product/templates/commerce-product.html.twig @@ -5,7 +5,7 @@ * Default theme implementation for products. * * Available variables: - * - content: Items for the content of the product variation. + * - content: Items for the content of the product. * Use 'content' to print them all, or print a subset such as * 'content.title'. Use the following code to exclude the * printing of a given child element: diff --git a/modules/product/tests/src/Kernel/Entity/ProductTest.php b/modules/product/tests/src/Kernel/Entity/ProductTest.php new file mode 100644 index 0000000000..664e05800c --- /dev/null +++ b/modules/product/tests/src/Kernel/Entity/ProductTest.php @@ -0,0 +1,71 @@ +installEntitySchema('commerce_product_variation'); + $this->installEntitySchema('commerce_product_variation_type'); + $this->installEntitySchema('commerce_product'); + $this->installEntitySchema('commerce_product_type'); + $this->installConfig(['commerce_product']); + } + + /** + * @covers ::getDefaultVariation + */ + public function testGetDefaultVariation() { + $variation1 = ProductVariation::create([ + 'type' => 'default', + 'sku' => strtolower($this->randomMachineName()), + 'title' => $this->randomString(), + 'status' => 0, + ]); + $variation1->save(); + + $variation2 = ProductVariation::create([ + 'type' => 'default', + 'sku' => strtolower($this->randomMachineName()), + 'title' => $this->randomString(), + 'status' => 1, + ]); + $variation2->save(); + + $product = Product::create([ + 'type' => 'default', + 'variations' => [$variation1, $variation2], + ]); + $product->save(); + + $this->assertEquals($product->getDefaultVariation(), $variation2); + $this->assertNotEquals($product->getDefaultVariation(), $variation1); + } + +} diff --git a/modules/product/tests/src/Kernel/ProductVariationFieldRendererTest.php b/modules/product/tests/src/Kernel/ProductVariationFieldRendererTest.php new file mode 100644 index 0000000000..dc674878ce --- /dev/null +++ b/modules/product/tests/src/Kernel/ProductVariationFieldRendererTest.php @@ -0,0 +1,143 @@ +installSchema('system', 'router'); + $this->installEntitySchema('commerce_product_variation'); + $this->installEntitySchema('commerce_product_variation_type'); + $this->installEntitySchema('commerce_product'); + $this->installEntitySchema('commerce_product_type'); + $this->installConfig(['commerce_product']); + + $this->variationFieldRenderer = $this->container->get('commerce_product.variation_field_renderer'); + + $this->firstVariationType = ProductVariationType::create([ + 'id' => 'shirt', + 'label' => 'Shirt', + ]); + $this->firstVariationType->save(); + $this->secondVariationType = ProductVariationType::create([ + 'id' => 'mug', + 'label' => 'Mug', + ]); + $this->secondVariationType->save(); + + $field_storage = FieldStorageConfig::create([ + 'field_name' => 'render_field', + 'entity_type' => 'commerce_product_variation', + 'type' => 'text', + 'cardinality' => 1, + ]); + $field_storage->save(); + + $field = FieldConfig::create([ + 'field_storage' => $field_storage, + 'bundle' => $this->secondVariationType->id(), + 'label' => 'Render field', + 'required' => TRUE, + 'translatable' => FALSE, + ]); + $field->save(); + + $attribute = ProductAttribute::create([ + 'id' => 'color', + 'label' => 'Color', + ]); + $attribute->save(); + + $this->container->get('commerce_product.attribute_field_manager') + ->createField($attribute, $this->secondVariationType->id()); + } + + /** + * @covers ::getFieldDefinitions + */ + public function testGetFieldDefinitions() { + $field_definitions = $this->variationFieldRenderer->getFieldDefinitions($this->firstVariationType->id()); + $field_names = array_keys($field_definitions); + $this->assertEquals(['sku', 'title', 'price'], $field_names, 'The title, sku, price variation fields are renderable.'); + + $field_definitions = $this->variationFieldRenderer->getFieldDefinitions($this->secondVariationType->id()); + $field_names = array_keys($field_definitions); + $this->assertEquals(['sku', 'title', 'price', 'render_field'], $field_names, 'The title, sku, price, render_field variation fields are renderable.'); + } + + /** + * @covers ::renderFields + * @covers ::renderField + */ + public function testRenderFields() { + $variation = ProductVariation::create([ + 'type' => 'default', + 'sku' => strtolower($this->randomMachineName()), + 'title' => $this->randomString(), + 'status' => 1, + ]); + $variation->save(); + $product = Product::create([ + 'type' => 'default', + 'variations' => [$variation], + ]); + $product->save(); + + $rendered_fields = $this->variationFieldRenderer->renderFields($variation); + $this->assertFalse(isset($rendered_fields['statis']), 'Variation status field was not rendered'); + $this->assertTrue(isset($rendered_fields['sku']), 'Variation SKU field was rendered'); + $this->assertEquals('product--variation-field--variation_sku__' . $variation->getProductId(), $rendered_fields['sku']['#ajax_replace_class']); + } + +}