diff --git a/modules/cart/tests/src/FunctionalJavascript/AddToCartFieldReplacementTest.php b/modules/cart/tests/src/FunctionalJavascript/AddToCartFieldReplacementTest.php index 72bf304e80..1de3e4b9ce 100644 --- a/modules/cart/tests/src/FunctionalJavascript/AddToCartFieldReplacementTest.php +++ b/modules/cart/tests/src/FunctionalJavascript/AddToCartFieldReplacementTest.php @@ -68,6 +68,7 @@ protected function setUp() { 'field_name' => 'field_number', 'entity_type' => 'commerce_product_variation', 'bundle' => 'default', + 'label' => 'Test Number Field', 'settings' => [], ])->save(); diff --git a/modules/cart/tests/src/FunctionalJavascript/AddToCartFieldReplacementTest.php.orig b/modules/cart/tests/src/FunctionalJavascript/AddToCartFieldReplacementTest.php.orig new file mode 100644 index 0000000000..72bf304e80 --- /dev/null +++ b/modules/cart/tests/src/FunctionalJavascript/AddToCartFieldReplacementTest.php.orig @@ -0,0 +1,195 @@ +setComponent('purchased_entity', [ + 'type' => 'commerce_product_variation_title', + ]); + $order_item_form_display->save(); + + // Create an optional field that will have a value only on 1 variation. + FieldStorageConfig::create([ + 'field_name' => 'field_number', + 'entity_type' => 'commerce_product_variation', + 'type' => 'integer', + ])->save(); + + FieldConfig::create([ + 'field_name' => 'field_number', + 'entity_type' => 'commerce_product_variation', + 'bundle' => 'default', + 'settings' => [], + ])->save(); + + // Set up the Full view modes. + EntityViewMode::create([ + 'id' => 'commerce_product.full', + 'label' => 'Full', + 'targetEntityType' => 'commerce_product', + ])->save(); + EntityViewMode::create([ + 'id' => 'commerce_product_variation.full', + 'label' => 'Full', + 'targetEntityType' => 'commerce_product_variation', + ])->save(); + + // Use a different price widget for the two displays, to use that as + // an indicator of the right view mode being used. + $default_view_display = EntityViewDisplay::load('commerce_product_variation.default.default'); + if (!$default_view_display) { + $default_view_display = EntityViewDisplay::create([ + 'targetEntityType' => 'commerce_product_variation', + 'bundle' => 'default', + 'mode' => 'default', + 'status' => TRUE, + ]); + } + $default_view_display->setComponent('price', [ + 'type' => 'commerce_price_default', + ]); + $default_view_display->save(); + $full_view_display = EntityViewDisplay::load('commerce_product_variation.default.full'); + if (!$full_view_display) { + $full_view_display = EntityViewDisplay::create([ + 'targetEntityType' => 'commerce_product_variation', + 'bundle' => 'default', + 'mode' => 'full', + 'status' => TRUE, + ]); + } + $full_view_display->setComponent('field_number', [ + 'type' => 'number_integer', + ]); + $full_view_display->setComponent('price', [ + 'type' => 'commerce_price_plain', + ]); + $full_view_display->save(); + + $this->firstVariation = $this->createEntity('commerce_product_variation', [ + 'title' => 'First variation', + 'type' => 'default', + 'sku' => 'first-variation', + 'price' => [ + 'number' => 10, + 'currency_code' => 'USD', + ], + 'field_number' => 202, + ]); + $this->secondVariation = $this->createEntity('commerce_product_variation', [ + 'title' => 'Second variation', + 'type' => 'default', + 'sku' => 'second-variation', + 'price' => [ + 'number' => 20, + 'currency_code' => 'USD', + ], + ]); + $this->product = $this->createEntity('commerce_product', [ + 'type' => 'default', + 'title' => 'Test product', + 'stores' => [$this->store], + 'variations' => [$this->firstVariation, $this->secondVariation], + ]); + } + + /** + * Tests the field replacement. + * + * Expectations: + * 1) The initial view mode is preserved on AJAX refresh. + * 2) Optional fields are correctly replaced even if the field is empty. + */ + public function testFieldReplacement() { + $this->drupalGet($this->product->toUrl()); + + $page = $this->getSession()->getPage(); + $renderer = $this->container->get('renderer'); + + $first_variation_price = [ + '#theme' => 'commerce_price_plain', + '#number' => $this->firstVariation->getPrice()->getNumber(), + '#currency' => Currency::load($this->firstVariation->getPrice()->getCurrencyCode()), + ]; + $first_variation_price = trim($renderer->renderPlain($first_variation_price)); + $second_variation_price = [ + '#theme' => 'commerce_price_plain', + '#number' => $this->secondVariation->getPrice()->getNumber(), + '#currency' => Currency::load($this->secondVariation->getPrice()->getCurrencyCode()), + ]; + $second_variation_price = trim($renderer->renderPlain($second_variation_price)); + + $price_field_selector = '.product--variation-field--variation_price__' . $this->product->id(); + $integer_field_selector = '.product--variation-field--variation_field_number__' . $this->product->id(); + + $this->assertSession()->elementExists('css', $price_field_selector); + $this->assertSession()->elementExists('css', $integer_field_selector); + $this->assertSession()->elementTextContains('css', $price_field_selector . ' .field__item', $first_variation_price); + $this->assertSession()->elementTextContains('css', $integer_field_selector . ' .field__item', $this->firstVariation->get('field_number')->value); + $this->assertSession()->fieldValueEquals('purchased_entity[0][variation]', $this->firstVariation->id()); + $page->selectFieldOption('purchased_entity[0][variation]', $this->secondVariation->id()); + $this->assertSession()->assertWaitOnAjaxRequest(); + + $this->assertSession()->elementExists('css', $price_field_selector); + $this->assertSession()->elementExists('css', $integer_field_selector); + $this->assertSession()->elementTextContains('css', $price_field_selector . ' .field__item', $second_variation_price); + $this->assertSession()->elementNotExists('css', $integer_field_selector . ' .field__item'); + + $page->selectFieldOption('purchased_entity[0][variation]', $this->firstVariation->id()); + $this->assertSession()->assertWaitOnAjaxRequest(); + $this->assertSession()->elementExists('css', $price_field_selector); + $this->assertSession()->elementExists('css', $integer_field_selector); + $this->assertSession()->elementTextContains('css', $price_field_selector . ' .field__item', $first_variation_price); + $this->assertSession()->elementTextContains('css', $integer_field_selector . ' .field__item', $this->firstVariation->get('field_number')->value); + } + +} diff --git a/modules/price/src/Plugin/Field/FieldType/PriceItem.php b/modules/price/src/Plugin/Field/FieldType/PriceItem.php index 8e11f169a9..7c4f17d220 100644 --- a/modules/price/src/Plugin/Field/FieldType/PriceItem.php +++ b/modules/price/src/Plugin/Field/FieldType/PriceItem.php @@ -3,6 +3,7 @@ namespace Drupal\commerce_price\Plugin\Field\FieldType; use Drupal\commerce_price\Price; +use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldItemBase; use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\Core\Form\FormStateInterface; @@ -150,4 +151,24 @@ public function toPrice() { return new Price($this->number, $this->currency_code); } + /** + * {@inheritdoc} + */ + public static function generateSampleValue(FieldDefinitionInterface $field_definition) { + $available_currencies = array_filter($field_definition->getSetting('available_currencies')); + if (count($available_currencies) === 0) { + /** @var \Drupal\commerce_price\Entity\CurrencyInterface[] $currencies */ + $currencies = \Drupal::entityTypeManager()->getStorage('commerce_currency')->loadMultiple(); + $sample_currency_code = reset($currencies)->getCurrencyCode(); + } + else { + $sample_currency_code = reset($available_currencies); + } + $values = [ + 'number' => '9.99', + 'currency_code' => $sample_currency_code, + ]; + return $values; + } + } diff --git a/modules/product/commerce_product.module b/modules/product/commerce_product.module index 1bec0aa8ed..3bc196ac9a 100644 --- a/modules/product/commerce_product.module +++ b/modules/product/commerce_product.module @@ -5,6 +5,8 @@ * Defines the Product entity and associated features. */ +use Drupal\commerce_product\Plugin\Block\VariationFieldBlock; +use Drupal\Component\Plugin\PluginBase; use Drupal\entity\BundleFieldDefinition; use Drupal\commerce\EntityHelper; use Drupal\commerce_product\Entity\ProductTypeInterface; @@ -345,3 +347,17 @@ function commerce_product_config_schema_info_alter(&$definitions) { ]; } } + +/** + * Implements hook_block_alter(). + */ +function commerce_product_block_alter(array &$info) { + if (\Drupal::moduleHandler()->moduleExists('layout_builder')) { + $base_plugin_id = 'field_block' . PluginBase::DERIVATIVE_SEPARATOR . 'commerce_product_variation' . PluginBase::DERIVATIVE_SEPARATOR; + foreach ($info as $block_plugin_id => $block_definition) { + if (strpos($block_plugin_id, $base_plugin_id) !== FALSE) { + $info[$block_plugin_id]['class'] = VariationFieldBlock::class; + } + } + } +} diff --git a/modules/product/commerce_product.module.orig b/modules/product/commerce_product.module.orig new file mode 100644 index 0000000000..1bec0aa8ed --- /dev/null +++ b/modules/product/commerce_product.module.orig @@ -0,0 +1,347 @@ +getTargetEntityTypeId() == 'commerce_product_variation' && $form_display->getMode() == 'default') { + $attribute_field_manager = \Drupal::service('commerce_product.attribute_field_manager'); + $attribute_field_manager->clearCaches(); + } +} + +/** + * Implements hook_ENTITY_TYPE_update(). + */ +function commerce_product_entity_view_display_update(EntityInterface $entity) { + // The product view uses the variation view and needs to be cleared, which doesn't + // happen automatically because we're editing the variation, not the product. + if (substr($entity->getConfigTarget(), 0, 27) === 'commerce_product_variation.') { + Cache::invalidateTags(['commerce_product_view']); + } +} + +/** + * Implements hook_theme_registry_alter(). + */ +function commerce_product_theme_registry_alter(&$theme_registry) { + // The preprocess function must run after quickedit_preprocess_field(). + $theme_registry['field']['preprocess functions'][] = 'commerce_product_remove_quickedit'; +} + +/** + * Turn off Quick Edit for injected variation fields, to avoid warnings. + */ +function commerce_product_remove_quickedit(&$variables) { + $entity_type_id = $variables['element']['#entity_type']; + if ($entity_type_id != 'commerce_product_variation' || empty($variables['element']['#ajax_replace_class'])) { + return; + } + + if (isset($variables['attributes']['data-quickedit-field-id'])) { + unset($variables['attributes']['data-quickedit-field-id']); + $context_key = array_search('user.permissions', $variables['#cache']['contexts']); + unset($variables['#cache']['contexts'][$context_key]); + } +} + +/** + * Implements hook_theme(). + */ +function commerce_product_theme() { + return [ + 'commerce_product_form' => [ + 'render element' => 'form', + ], + 'commerce_product' => [ + 'render element' => 'elements', + ], + 'commerce_product_variation' => [ + 'render element' => 'elements', + ], + 'commerce_product_attribute_value' => [ + 'render element' => 'elements', + ], + ]; +} + +/** + * Implements hook_theme_suggestions_commerce_product(). + */ +function commerce_product_theme_suggestions_commerce_product(array $variables) { + return _commerce_entity_theme_suggestions('commerce_product', $variables); +} + +/** + * Implements hook_theme_suggestions_commerce_product_variation(). + */ +function commerce_product_theme_suggestions_commerce_product_variation(array $variables) { + return _commerce_entity_theme_suggestions('commerce_product_variation', $variables); +} + +/** + * Implements hook_theme_suggestions_commerce_product_commerce_product_attribute_value(). + */ +function commerce_product_theme_suggestions_commerce_product_attribute_value(array $variables) { + return _commerce_entity_theme_suggestions('commerce_product_attribute_value', $variables); +} + +/** + * Prepares variables for product templates. + * + * Default template: commerce-product.html.twig. + * + * @param array $variables + * An associative array containing: + * - elements: An associative array containing rendered fields. + * - attributes: HTML attributes for the containing element. + */ +function template_preprocess_commerce_product(array &$variables) { + /** @var Drupal\commerce_product\Entity\ProductInterface $product */ + $product = $variables['elements']['#commerce_product']; + + $variables['product_entity'] = $product; + $variables['product_url'] = $product->isNew() ? '' : $product->toUrl(); + $variables['product'] = []; + foreach (Element::children($variables['elements']) as $key) { + $variables['product'][$key] = $variables['elements'][$key]; + } +} + +/** + * Prepares variables for product variation templates. + * + * Default template: commerce-product-variation.html.twig. + * + * @param array $variables + * An associative array containing: + * - elements: An associative array containing rendered fields. + * - attributes: HTML attributes for the containing element. + */ +function template_preprocess_commerce_product_variation(array &$variables) { + /** @var Drupal\commerce_product\Entity\ProductVariationInterface $product_variation */ + $product_variation = $variables['elements']['#commerce_product_variation']; + $product = $product_variation->getProduct(); + + $variables['product_variation_entity'] = $product_variation; + $variables['product_url'] = ''; + if ($product && !$product->isNew()) { + $variables['product_url'] = $product->toUrl(); + } + + $variables['product_variation'] = []; + foreach (Element::children($variables['elements']) as $key) { + $variables['product_variation'][$key] = $variables['elements'][$key]; + } +} + +/** + * Prepares variables for product attribute value templates. + * + * Default template: commerce-product-attribute-value.html.twig. + * + * @param array $variables + * An associative array containing: + * - elements: An associative array containing rendered fields. + * - attributes: HTML attributes for the containing element. + */ +function template_preprocess_commerce_product_attribute_value(array &$variables) { + /** @var Drupal\commerce_product\Entity\ProductAttributeValueInterface $product */ + $attribute_value = $variables['elements']['#commerce_product_attribute_value']; + + $variables['product_attribute_value_entity'] = $attribute_value; + $variables['product_attribute_value'] = []; + foreach (Element::children($variables['elements']) as $key) { + $variables['product_attribute_value'][$key] = $variables['elements'][$key]; + } +} + +/** + * Adds the default stores field to a product. + * + * @param \Drupal\commerce_product\Entity\ProductTypeInterface $product_type + * The product type. + * + * @deprecated in commerce:8.x-2.16 and is removed from commerce:3.x. The stores + * field is now a base field. + * + * @see https://www.drupal.org/node/3090561 + */ +function commerce_product_add_stores_field(ProductTypeInterface $product_type) { + @trigger_error('commerce_product_add_stores_field() function is deprecated in commerce:8.x-2.16 and is removed from commerce:3.x. The stores field is now a base field. See https://www.drupal.org/node/3090561', E_USER_DEPRECATED); +} + +/** + * Adds the default body field to a product type. + * + * @param \Drupal\commerce_product\Entity\ProductTypeInterface $product_type + * The product type. + * @param string $label + * (optional) The label for the body instance. Defaults to 'Body'. + */ +function commerce_product_add_body_field(ProductTypeInterface $product_type, $label = 'Body') { + $field_definition = BundleFieldDefinition::create('text_with_summary') + ->setTargetEntityTypeId('commerce_product') + ->setTargetBundle($product_type->id()) + ->setName('body') + ->setLabel($label) + ->setTranslatable(TRUE) + ->setSetting('display_summary', FALSE) + ->setDisplayOptions('form', [ + 'type' => 'text_textarea_with_summary', + 'weight' => 1, + ]) + ->setDisplayOptions('view', [ + 'label' => 'hidden', + 'type' => 'text_default', + ]); + + $configurable_field_manager = \Drupal::service('commerce.configurable_field_manager'); + $configurable_field_manager->createField($field_definition, FALSE); +} + +/** + * Adds the default variations field to a product type. + * + * @param \Drupal\commerce_product\Entity\ProductTypeInterface $product_type + * The product type. + * + * @deprecated in commerce:8.x-2.16 and is removed from commerce:3.x. The + * variations field is now a base field. + * + * @see https://www.drupal.org/node/3090561 + */ +function commerce_product_add_variations_field(ProductTypeInterface $product_type) { + @trigger_error('commerce_product_add_variations_field() function is deprecated in commerce:8.x-2.16 and is removed from commerce:3.x. The variations field is now a base field. See https://www.drupal.org/node/3090561', E_USER_DEPRECATED); +} + +/** + * Implements hook_field_widget_form_alter(). + */ +function commerce_product_field_widget_form_alter(&$element, FormStateInterface $form_state, $context) { + /** @var \Drupal\Core\Field\FieldDefinitionInterface $field_definition */ + $field_definition = $context['items']->getFieldDefinition(); + $field_name = $field_definition->getName(); + $entity_type = $field_definition->getTargetEntityTypeId(); + $widget_name = $context['widget']->getPluginId(); + $required = $field_definition->isRequired(); + if ($field_name == 'path' && $entity_type == 'commerce_product' && $widget_name == 'path') { + $element['alias']['#description'] = t('The alternative URL for this product. Use a relative path. For example, "/my-product".'); + } + elseif ($field_name == 'title' && $entity_type == 'commerce_product_variation' && !$required) { + // The title field is optional only when its value is automatically + // generated, in which case the widget needs to be hidden. + $element['#access'] = FALSE; + } +} + +/** + * Implements hook_form_FORM_ID_alter() for 'entity_form_display_edit_form'. + * + * Don't allow referencing existing variations, since a variation must + * always belong to a single product only. + */ +function commerce_product_form_entity_form_display_edit_form_alter(array &$form, FormStateInterface $form_state) { + if ($form['#entity_type'] != 'commerce_product') { + return; + } + + if (isset($form['fields']['variations']['plugin']['settings_edit_form']['settings'])) { + $settings = &$form['fields']['variations']['plugin']['settings_edit_form']['settings']; + if (isset($settings['allow_existing'])) { + $settings['allow_existing']['#access'] = FALSE; + $settings['match_operator']['#access'] = FALSE; + } + } +} + +/** + * Implements hook_form_FORM_ID_alter() for 'field_storage_config_edit_form'. + * + * Hide the cardinality setting for attribute fields. + */ +function commerce_product_form_field_storage_config_edit_form_alter(array &$form, FormStateInterface $form_state) { + /** @var \Drupal\field\FieldStorageConfigInterface $field_storage */ + $field_storage = $form_state->getFormObject()->getEntity(); + $entity_type_id = $field_storage->getTargetEntityTypeId(); + $target_type = $field_storage->getSetting('target_type'); + if ($entity_type_id === 'commerce_product_variation' && $target_type === 'commerce_product_attribute_value') { + $form['cardinality_container']['#access'] = FALSE; + $form['cardinality_container']['cardinality']['#value'] = 'number'; + $form['cardinality_container']['cardinality_number']['#value'] = '1'; + } +} + +/** + * Implements hook_search_api_views_handler_mapping_alter(). + * + * Search API views filters do not use the options filter by default + * for all entity bundle fields. + * + * @see https://www.drupal.org/project/search_api/issues/2847994 + */ +function commerce_product_search_api_views_handler_mapping_alter(array &$mapping) { + $mapping['entity:commerce_product_type'] = [ + 'argument' => [ + 'id' => 'search_api', + ], + 'filter' => [ + 'id' => 'search_api_options', + 'options callback' => 'commerce_product_type_labels', + ], + 'sort' => [ + 'id' => 'search_api', + ], + ]; +} + +/** + * Gets the list of available product type labels. + * + * @return string[] + * The product type labels, keyed by product type ID. + */ +function commerce_product_type_labels() { + $product_type_storage = \Drupal::entityTypeManager()->getStorage('commerce_product_type'); + $product_types = $product_type_storage->loadMultiple(); + + return EntityHelper::extractLabels($product_types); +} + +/** + * Implements hook_config_schema_info_alter(). + * + * This method provides a compatibility layer to allow new config schemas to be + * used with older versions of Drupal. + */ +function commerce_product_config_schema_info_alter(&$definitions) { + if (!isset($definitions['field.widget.settings.entity_reference_autocomplete']['mapping']['match_limit'])) { + $definitions['field.widget.settings.entity_reference_autocomplete']['mapping']['match_limit'] = [ + 'type' => 'integer', + 'label' => 'Maximum number of autocomplete suggestions.', + ]; + } +} diff --git a/modules/product/commerce_product.services.yml b/modules/product/commerce_product.services.yml index 14fc3f5d24..b106df094e 100644 --- a/modules/product/commerce_product.services.yml +++ b/modules/product/commerce_product.services.yml @@ -25,7 +25,12 @@ services: commerce_product.product_route_context: class: Drupal\commerce_product\ContextProvider\ProductRouteContext - arguments: ['@current_route_match'] + arguments: ['@current_route_match', '@entity_type.manager'] + tags: + - { name: 'context_provider' } + commerce_product.product_variation_route_context: + class: Drupal\commerce_product\ContextProvider\ProductVariationContext + arguments: ['@current_route_match', '@entity_type.manager'] tags: - { name: 'context_provider' } diff --git a/modules/product/src/ContextProvider/ProductRouteContext.php b/modules/product/src/ContextProvider/ProductRouteContext.php index f27715c4ad..081e6a2f78 100644 --- a/modules/product/src/ContextProvider/ProductRouteContext.php +++ b/modules/product/src/ContextProvider/ProductRouteContext.php @@ -4,6 +4,7 @@ use Drupal\commerce_product\Entity\Product; use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Plugin\Context\Context; use Drupal\Core\Plugin\Context\EntityContextDefinition; use Drupal\Core\Plugin\Context\ContextProviderInterface; @@ -26,14 +27,24 @@ class ProductRouteContext implements ContextProviderInterface { */ protected $routeMatch; + /** + * The product storage. + * + * @var \Drupal\Core\Entity\ContentEntityStorageInterface + */ + protected $productStorage; + /** * Constructs a new ProductRouteContext object. * * @param \Drupal\Core\Routing\RouteMatchInterface $route_match * The route match. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager. */ - public function __construct(RouteMatchInterface $route_match) { + public function __construct(RouteMatchInterface $route_match, EntityTypeManagerInterface $entity_type_manager) { $this->routeMatch = $route_match; + $this->productStorage = $entity_type_manager->getStorage('commerce_product'); } /** @@ -45,9 +56,8 @@ public function getRuntimeContexts(array $unqualified_context_ids) { if ($product = $this->routeMatch->getParameter('commerce_product')) { $value = $product; } - elseif ($this->routeMatch->getRouteName() == 'entity.commerce_product.add_form') { - $product_type = $this->routeMatch->getParameter('commerce_product_type'); - $value = Product::create(['type' => $product_type->id()]); + elseif ($product_type = $this->routeMatch->getParameter('commerce_product_type')) { + $value = $this->productStorage->createWithSampleValues($product_type->id()); } $cacheability = new CacheableMetadata(); diff --git a/modules/product/src/ContextProvider/ProductVariationContext.php b/modules/product/src/ContextProvider/ProductVariationContext.php new file mode 100644 index 0000000000..0f658c0487 --- /dev/null +++ b/modules/product/src/ContextProvider/ProductVariationContext.php @@ -0,0 +1,108 @@ +routeMatch = $route_match; + $this->productVariationStorage = $entity_type_manager->getStorage('commerce_product_variation'); + } + + /** + * {@inheritdoc} + */ + public function getRuntimeContexts(array $unqualified_context_ids) { + $context_definition = new ContextDefinition('entity:commerce_product_variation', new TranslatableMarkup('Product variation')); + $value = $this->routeMatch->getParameter('commerce_product_variation'); + if ($value === NULL) { + if ($product = $this->routeMatch->getParameter('commerce_product')) { + $value = $this->productVariationStorage->loadFromContext($product); + } + /** @var \Drupal\commerce_product\Entity\ProductTypeInterface $product_type */ + elseif ($product_type = $this->routeMatch->getParameter('commerce_product_type')) { + $value = $this->productVariationStorage->createWithSampleValues($product_type->getVariationTypeId()); + } + // @todo Simplify this logic once EntityTargetInterface is available + // @see https://www.drupal.org/project/drupal/issues/3054490 + elseif (strpos($this->routeMatch->getRouteName(), 'layout_builder') !== FALSE) { + /** @var \Drupal\layout_builder\SectionStorageInterface $section_storage */ + $section_storage = $this->routeMatch->getParameter('section_storage'); + if ($section_storage instanceof DefaultsSectionStorageInterface) { + $context = $section_storage->getContextValue('display'); + assert($context instanceof EntityDisplayInterface); + if ($context->getTargetEntityTypeId() === 'commerce_product') { + $product_type = ProductType::load($context->getTargetBundle()); + $value = $this->productVariationStorage->createWithSampleValues($product_type->getVariationTypeId()); + } + } + elseif ($section_storage instanceof OverridesSectionStorageInterface) { + $context = $section_storage->getContextValue('entity'); + if ($context instanceof ProductInterface) { + $value = $context->getDefaultVariation(); + if ($value === NULL) { + $product_type = ProductType::load($context->bundle()); + $value = $this->productVariationStorage->createWithSampleValues($product_type->getVariationTypeId()); + } + } + } + } + } + + $cacheability = new CacheableMetadata(); + $cacheability->setCacheContexts(['route']); + $context = new Context($context_definition, $value); + $context->addCacheableDependency($cacheability); + + return ['commerce_product_variation' => $context]; + } + + /** + * {@inheritdoc} + */ + public function getAvailableContexts() { + return $this->getRuntimeContexts([]); + } + +} diff --git a/modules/product/src/Plugin/Block/VariationFieldBlock.php b/modules/product/src/Plugin/Block/VariationFieldBlock.php new file mode 100644 index 0000000000..ffe16978c9 --- /dev/null +++ b/modules/product/src/Plugin/Block/VariationFieldBlock.php @@ -0,0 +1,36 @@ +get('commerce_product.variation_field_renderer'); + $display_settings = $this->getConfiguration()['formatter']; + $entity = $this->getEntity(); + assert($entity instanceof ProductVariationInterface); + try { + $build = $variation_field_renderer->renderField($this->fieldName, $entity, $display_settings); + } + catch (\Exception $e) { + $build = []; + $this->logger->warning('The field "%field" failed to render with the error of "%error".', ['%field' => $this->fieldName, '%error' => $e->getMessage()]); + } + CacheableMetadata::createFromObject($this)->applyTo($build); + return $build; + } + +} diff --git a/modules/product/src/Plugin/Field/FieldFormatter/AddToCartFormatter.php b/modules/product/src/Plugin/Field/FieldFormatter/AddToCartFormatter.php index e4d783823d..348ce4cda4 100644 --- a/modules/product/src/Plugin/Field/FieldFormatter/AddToCartFormatter.php +++ b/modules/product/src/Plugin/Field/FieldFormatter/AddToCartFormatter.php @@ -64,10 +64,23 @@ public function settingsSummary() { */ public function viewElements(FieldItemListInterface $items, $langcode) { $elements = []; + + $product = $items->getEntity(); + if (!empty($product->in_preview)) { + $elements[0]['add_to_cart_form'] = [ + '#type' => 'actions', + ['#type' => 'button', '#value' => $this->t('Add to cart')], + ]; + return $elements; + } + if ($product->id() === NULL) { + return []; + } + $elements[0]['add_to_cart_form'] = [ '#lazy_builder' => [ 'commerce_product.lazy_builders:addToCartForm', [ - $items->getEntity()->id(), + $product->id(), $this->viewMode, $this->getSetting('combine'), $langcode, diff --git a/modules/product/src/ProductViewBuilder.php b/modules/product/src/ProductViewBuilder.php index dc48bbd5e0..21aa12f056 100644 --- a/modules/product/src/ProductViewBuilder.php +++ b/modules/product/src/ProductViewBuilder.php @@ -10,6 +10,7 @@ use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Entity\EntityViewBuilder; use Drupal\Core\Language\LanguageManagerInterface; +use Drupal\layout_builder\Entity\LayoutEntityDisplayInterface; use Drupal\Core\Theme\Registry; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -81,7 +82,8 @@ protected function alterBuild(array &$build, EntityInterface $entity, EntityView $variation_storage = $this->entityTypeManager->getStorage('commerce_product_variation'); /** @var \Drupal\commerce_product\Entity\ProductTypeInterface $product_type */ $product_type = $product_type_storage->load($entity->bundle()); - if ($product_type->shouldInjectVariationFields() && $entity->getDefaultVariation()) { + $is_layout_builder = $display instanceof LayoutEntityDisplayInterface && $display->isLayoutBuilderEnabled(); + if (!$is_layout_builder && $product_type->shouldInjectVariationFields() && $entity->getDefaultVariation()) { $variation = $variation_storage->loadFromContext($entity); $variation = $this->entityRepository->getTranslationFromContext($variation, $entity->language()->getId()); $attribute_field_names = $variation->getAttributeFieldNames(); diff --git a/modules/product/tests/src/Functional/ProductVariationFieldInjectionTest.php b/modules/product/tests/src/Functional/ProductVariationFieldInjectionTest.php index ec40e6e5da..4e22b59bb3 100644 --- a/modules/product/tests/src/Functional/ProductVariationFieldInjectionTest.php +++ b/modules/product/tests/src/Functional/ProductVariationFieldInjectionTest.php @@ -112,6 +112,15 @@ public function testInjectedVariationDefault() { $this->assertSession()->pageTextContains('$999.00'); } + /** + * Verifies installing Layout Builder does not break field injection. + */ + public function testInjectedFieldsWithLayoutBuilderInstalled() { + $this->container->get('module_installer')->install(['layout_builder']); + $this->rebuildContainer(); + $this->testInjectedVariationDefault(); + } + /** * Tests that the default injected variation respects the URL context. */ diff --git a/modules/product/tests/src/FunctionalJavascript/ProductLayoutBuilderIntegrationTest.php b/modules/product/tests/src/FunctionalJavascript/ProductLayoutBuilderIntegrationTest.php new file mode 100644 index 0000000000..eb0868a1e2 --- /dev/null +++ b/modules/product/tests/src/FunctionalJavascript/ProductLayoutBuilderIntegrationTest.php @@ -0,0 +1,245 @@ +setComponent('sku', [ + 'label' => 'hidden', + 'type' => 'string', + ]); + $variation_view_display->save(); + + $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-DEFAULT', + 'price' => [ + 'number' => '9.99', + 'currency_code' => 'USD', + ], + ]), + ], + ]); + $this->drupalGet($product->toUrl()); + $this->assertSession()->pageTextContains('$9.99'); + $this->assertSession()->pageTextContains('INJECTION-DEFAULT'); + + $this->enableLayoutsForBundle('default'); + + $this->drupalGet($product->toUrl()); + $this->assertSession()->pageTextNotContains('$9.99'); + $this->assertSession()->pageTextNotContains('INJECTION-DEFAULT'); + } + + /** + * Tests configuring the default layout for a product type. + */ + public function testConfiguringDefaultLayout() { + $this->enableLayoutsForBundle('default'); + $this->configureDefaultLayout('default'); + } + + /** + * Tests configuring a layout override for a product. + */ + public function testConfiguringOverrideLayout() { + $this->enableLayoutsForBundle('default', TRUE); + $this->configureDefaultLayout('default'); + + $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-DEFAULT', + 'price' => [ + 'number' => '9.99', + 'currency_code' => 'USD', + ], + ]), + ], + ]); + $this->drupalGet($product->toUrl()); + $this->assertSession()->pageTextNotContains('INJECTION-DEFAULT'); + $this->getSession()->getPage()->clickLink('Layout'); + $this->assertSession()->pageTextContains('You are editing the layout for this Default product.'); + $this->addBlockToLayout('SKU'); + $this->getSession()->getPage()->pressButton('Save layout'); + $this->assertSession()->pageTextContains('The layout override has been saved.'); + + $this->drupalGet($product->toUrl()); + $this->assertSession()->pageTextContains('INJECTION-DEFAULT'); + } + + /** + * Test field injection on a Layout Builder enabled product. + * + * @group debug + */ + public function testFieldInjectionOverAjax() { + $variation_type = ProductVariationType::load('default'); + $variation_type->setGenerateTitle(FALSE); + $variation_type->save(); + + // Use the title widget so that we do not need to use attributes. + $order_item_form_display = EntityFormDisplay::load('commerce_order_item.default.add_to_cart'); + $order_item_form_display->setComponent('purchased_entity', [ + 'type' => 'commerce_product_variation_title', + ]); + $order_item_form_display->save(); + + $first_variation = $this->createEntity('commerce_product_variation', [ + 'title' => 'First variation', + 'type' => 'default', + 'sku' => 'first-variation', + 'price' => [ + 'number' => 10, + 'currency_code' => 'USD', + ], + ]); + $second_variation = $this->createEntity('commerce_product_variation', [ + 'title' => 'Second variation', + 'type' => 'default', + 'sku' => 'second-variation', + 'price' => [ + 'number' => 20, + 'currency_code' => 'USD', + ], + ]); + $product = $this->createEntity('commerce_product', [ + 'type' => 'default', + 'title' => $this->randomMachineName(), + 'stores' => $this->stores, + 'body' => ['value' => 'Testing product variation field injection!'], + 'variations' => [ + $first_variation, + $second_variation, + ], + ]); + + $this->enableLayoutsForBundle('default'); + $this->configureDefaultLayout('default'); + + $this->drupalGet($product->toUrl()); + + $price_field_selector = '.block-field-blockcommerce-product-variationdefaultprice'; + $this->assertSession()->elementExists('css', $price_field_selector); + $this->assertSession()->elementTextContains('css', $price_field_selector . ' .field__item', '$10'); + $this->assertSession()->fieldValueEquals('purchased_entity[0][variation]', $first_variation->id()); + $this->getSession()->getPage()->selectFieldOption('purchased_entity[0][variation]', $second_variation->id()); + $this->assertSession()->assertWaitOnAjaxRequest(); + + $this->assertSession()->elementExists('css', $price_field_selector); + $this->saveHtmlOutput(); + $this->assertSession()->elementTextContains('css', $price_field_selector . ' .field__item', '$20'); + + $this->getSession()->getPage()->selectFieldOption('purchased_entity[0][variation]', $first_variation->id()); + $this->assertSession()->assertWaitOnAjaxRequest(); + $this->assertSession()->elementExists('css', $price_field_selector); + $this->assertSession()->elementTextContains('css', $price_field_selector . ' .field__item', '$10'); + } + + /** + * Configures a default layout for a product type. + * + * @param string $bundle + * The bundle to configure. + */ + protected function configureDefaultLayout($bundle) { + $this->drupalGet(Url::fromRoute('entity.entity_view_display.commerce_product.default', [ + 'commerce_product_type' => $bundle, + ])); + $this->getSession()->getPage()->clickLink('Manage layout'); + $this->assertSession()->pageTextNotContains('$9.99'); + + $this->addBlockToLayout('Price', function () { + $this->assertSession()->pageTextContainsOnce('Currency display'); + $this->getSession()->getPage()->checkField('Strip trailing zeroes after the decimal point.'); + }); + + $this->assertSession()->pageTextContainsOnce('$9.99'); + + $this->addBlockToLayout('Variations', function () { + $this->getSession()->getPage()->selectFieldOption('Label', '- Hidden -'); + $this->getSession()->getPage()->selectFieldOption('Formatter', 'Add to cart form'); + }); + + $save_layout = $this->getSession()->getPage()->findButton('Save layout'); + $save_layout->focus(); + $save_layout->click(); + $this->assertSession()->pageTextContains('The layout has been saved.'); + } + + // flexible \Drupal\Tests\layout_builder\FunctionalJavascript\LayoutBuilderTest::enableLayoutsForBundle + // open patch for core for this, make entity type configurable. + protected function enableLayoutsForBundle($bundle, $allow_custom = FALSE) { + $this->drupalGet(Url::fromRoute('entity.entity_view_display.commerce_product.default', [ + 'commerce_product_type' => $bundle, + ])); + $this->getSession()->getPage()->checkField('layout[enabled]'); + if ($allow_custom) { + $this->getSession()->getPage()->checkField('layout[allow_custom]'); + } + $this->getSession()->getPage()->pressButton('Save'); + $this->assertNotEmpty($this->assertSession()->waitForElementVisible('css', '#edit-manage-layout')); + $this->assertSession()->linkExists('Manage layout'); + } + + // have the first parameter be the grouping label so we scope the block name correctly + protected function addBlockToLayout($block_name, callable $configure = NULL) { + $add_block = $this->getSession()->getPage()->findLink('Add Block'); + $add_block->focus(); + $add_block->click(); + $this->assertSession()->assertWaitOnAjaxRequest(); + $this->getSession()->getPage()->clickLink($block_name); + $this->assertSession()->assertWaitOnAjaxRequest(); + if ($configure !== NULL) { + $configure(); + } + $this->getSession()->getPage()->pressButton('Add Block'); + $this->assertSession()->assertWaitOnAjaxRequest(); + } + +}