diff --git a/config/schema/commerce.schema.yml b/config/schema/commerce.schema.yml index bd7bed11b0..f6904610e3 100644 --- a/config/schema/commerce.schema.yml +++ b/config/schema/commerce.schema.yml @@ -76,6 +76,64 @@ field.widget.settings.commerce_entity_select: type: string label: 'Autocomplete placeholder' +field.widget.settings.commerce_auto_sku: + type: mapping + label: 'Commerce auto SKU format settings' + mapping: + custom_label: + type: label + label: 'Custom label' + uniqid_enabled: + type: boolean + label: 'Enable unique auto SKU values generation' + more_entropy: + type: boolean + label: 'More unique' + hide: + type: boolean + label: 'Hide SKU' + prefix: + type: label + label: 'SKU prefix' + suffix: + type: label + label: 'SKU suffix' + size: + type: integer + label: 'Size of textfield' + placeholder: + type: label + label: 'Placeholder' + +field.widget.settings.commerce_auto_sku: + type: mapping + label: 'Commerce auto SKU format settings' + mapping: + custom_label: + type: label + label: 'Custom label' + uniqid_enabled: + type: boolean + label: 'Enable unique auto SKU values generation' + more_entropy: + type: boolean + label: 'More unique' + hide: + type: boolean + label: 'Hide SKU' + prefix: + type: label + label: 'SKU prefix' + suffix: + type: label + label: 'SKU suffix' + size: + type: integer + label: 'Size of textfield' + placeholder: + type: label + label: 'Placeholder' + views.argument_validator.commerce_current_user: type: mapping label: 'Current user' diff --git a/modules/product/commerce_product.module b/modules/product/commerce_product.module index 313362a0b4..db4a98121b 100644 --- a/modules/product/commerce_product.module +++ b/modules/product/commerce_product.module @@ -259,12 +259,153 @@ function commerce_product_field_widget_form_alter(&$element, FormStateInterface $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; + switch (TRUE) { + // The filtering only current owner stores is now moved to: https://github.com/drugan/commerce_multistore +// case ($field_name == 'stores'): +// $user = \Drupal::currentUser(); +// // If current user has no admin permission then display only those stores +// // which are owned by the user. +// if (!$user->hasPermission('administer commerce_store')) { +// $stores = \Drupal::service('entity.manager')->getStorage('commerce_store')->loadMultiple(); +// $id = $user->id(); +// $i = 0; +// foreach ($stores as $store) { +// if ($store->getOwnerId() != $id) { +// $element['alter_data_' . $i] = [ +// '#parents' => ['target_id', 'value'], +// $store->id() => [], +// ]; +// $i++; +// } +// } +// if ($i) { +// $creator = \Drupal::service('commerce_product.variation_bulk_creator'); +// $element['#after_build'][] = [get_class($creator), 'afterBuildPreRenderArrayAlter']; +// } +// } +// break; + case ($field_name == 'variations' && $entity_type == 'commerce_product'): + /** @var \Drupal\commerce_product\ProductVariationBulkCreator $creator */ + $creator = \Drupal::service('commerce_product.variation_bulk_creator'); + /** @var \Drupal\commerce_product\Entity\Product $product */ + $product = $form_state->getFormObject()->getEntity(); + /** @var \Drupal\commerce_product\Entity\ProductVariation $variation */ + $variation = $creator->getProductVariation($product); + $sku_settings = $creator::getSkuwidget($variation)->getSettings(); + $form_state->setValue('sku_settings', $sku_settings); + extract($sku_settings); + $widget_settings = $context['widget']->getSettings(); + $one = isset($widget_settings['label_singular']) ? $widget_settings['label_singular'] : FALSE; + $many = $one ? $widget_settings['label_plural'] : FALSE; + // Disable autocompletion and autocreation if the widget is not inline + // entity form or if all commerce SKU settings are empty. + if (!$one || (empty($uniqid_enabled) && empty($prefix) && empty($suffix))) { + break; + } + $ief = isset($element['form']['inline_entity_form']); + $add = isset($element['actions']['ief_add']); + $add_existing = isset($element['actions']['ief_add_existing']); + $autocomplete = isset($element['form']['entity_id']); + $id = isset($element['#ief_id']) ? $element['#ief_id'] : FALSE; + if ($new = !isset($element['entities']['0'])) { + $all = $creator->getNotUsedAttributesCombination([$variation]); + $count = $all ? $all['count'] : 0; + if ($count > 1 && ($add || $add_existing || $autocomplete || $ief)) { + $i = 0; + // The disabled button is shown to reveal the bulk creator service + // availability and the number of variations to create. + $parents = ['form', 'inline_entity_form', 'actions', 'ief_add_all']; + $element['alter_data_' . $i] = [ + '#parents' => $add_existing ? ['actions'] : ($autocomplete ? ['form', 'actions'] : $parents), + '#type' => 'submit', + '#value' => t('Create @count @variations', ['@count'=> $count, '@variations' => $count > 1 ? $many : $one]), + '#weight' => 10, + '#disabled' => TRUE, + '#attributes'=> [ + 'title' => t('To enable this functionality you need to create at least one @variation', ['@variation'=> $one]), + ], + ]; + if ($ief) { + $i++; + foreach ($all['not_used_combination'] as $field_name => $value) { + // Do respect the default value defined on the attribute field. + if (empty($variation->getFieldDefinition($field_name)->getDefaultValueLiteral())) { + $element['alter_data_' . $i] = [ + '#parents' => ['form', 'inline_entity_form', $field_name, 'widget'], + '#value' => $value == '_none' ? [] : ['target_id' => $value], + ]; + $i++; + } + } + } + $element['#after_build'][] = [get_class($creator), 'afterBuildPreRenderArrayAlter']; + } + } + elseif ($id && $all = $creator->getIefFormNotUsedAttributesCombination($form_state, $id)) { + if ($all['duplicated']) { + $placeholders = [ + '@variations' => $all['duplicated'] > 1 ? $many : $one, + '@count' => $all['duplicated'], + '@labels' => $all['duplications_list'], + ]; + $warning = t('You have @count @variations duplicated: @labels', $placeholders); + drupal_set_message($warning, 'warning'); + } + if (!empty($all['not_used_combination'])) { + $form_state->setValue('next_attribute_value_ids', $all['not_used_combination']); + /** @var \Drupal\commerce_product\Entity\ProductVariation $all['last_variation'] */ + !$new && $form_state->setValue('last_added_price', $all['last_variation']->getPrice()); + if (!$new && ($add || $add_existing)) { + $count = $all['count'] - $all['used']; + $not_all = isset($all['not_all']) ? $all['not_all'] : 0; + $out_of = $not_all && $all['count'] > $not_all ? t('(out of @count)', ['@count'=> $count]) : ''; + $value = t('Create @not_used @out_of @variations (done @used)', ['@used' => $all['used'], '@not_used' => $all['not_used'], '@out_of'=> $out_of, '@variations' => $count > 1 ? $many : $one]); + $warning = t('Be reasonable! An attempt to autocreate a huge amount of data in one go may not work and even freeze your system.'); + $description = t('Automatically create @variations based on price and attributes combination of the last added @variation. Other specific values such as @variation image or SKU may be edited inline later. Helpful for not missing any @variation.', + ['@variation'=> $one, '@variations'=> $many]); + $element['actions']['ief_add_all'] = [ + '#submit' => [[$creator, 'createAllIefFormVariations']], + '#value' => $value, + '#weight' => 10, + '#attributes'=> [ + 'title' => $not_all && $all['used'] > $not_all ? $warning : $description, + ], + ] + $element['actions']['ief_add' . ($add ? '' : '_existing')]; + } + } + } + break; + case ($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".'); + break; + case ($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; + break; + case ($field_name == 'price' && empty($element['#default_value']) && ($price = $form_state->getValue('last_added_price'))): + $element['#default_value'] = $price->toArray(); + break; + case (($ids = $form_state->getValue('next_attribute_value_ids')) && isset($ids[$field_name]) && empty($element['#default_value'])): + $element['#default_value'] = $ids[$field_name]; + break; + case ($field_name == 'sku'): + if (!$sku_settings = $form_state->getValue('sku_settings')) { + $creator = \Drupal::service('commerce_product.variation_bulk_creator'); + $product = $form_state->getFormObject()->getEntity(); + $variation = $creator->getProductVariation($product); + $sku_settings = $creator::getSkuwidget($variation)->getSettings(); + } + extract($sku_settings); + if (!empty($uniqid_enabled) && $hide) { + $element['value']['#type'] = 'value'; + $element['value']['#value'] = $element['value']['#default_value']; + } + else { + global $base_url; + $setup_link = t('Set up default SKU.', [':href' => $base_url . '/admin/commerce/config/product-variation-types/' . $context['form']['#bundle'] . '/edit/form-display']); + $element['value']['#description'] = implode(' ', [$element['value']['#description'], $setup_link]); + } + break; } } diff --git a/modules/product/commerce_product.services.yml b/modules/product/commerce_product.services.yml index cca824dbe1..af487f7aae 100644 --- a/modules/product/commerce_product.services.yml +++ b/modules/product/commerce_product.services.yml @@ -10,3 +10,7 @@ services: commerce_product.variation_field_renderer: class: Drupal\commerce_product\ProductVariationFieldRenderer arguments: ['@entity_type.manager', '@entity_field.manager'] + + commerce_product.variation_bulk_creator: + class: Drupal\commerce_product\ProductVariationBulkCreator + arguments: ['@entity_type.manager'] diff --git a/modules/product/config/install/core.entity_form_display.commerce_product_variation.default.default.yml b/modules/product/config/install/core.entity_form_display.commerce_product_variation.default.default.yml index c699947d4c..1d4fc86feb 100644 --- a/modules/product/config/install/core.entity_form_display.commerce_product_variation.default.default.yml +++ b/modules/product/config/install/core.entity_form_display.commerce_product_variation.default.default.yml @@ -16,9 +16,15 @@ content: settings: { } third_party_settings: { } sku: - type: string_textfield + type: commerce_auto_sku weight: -4 settings: + custom_label: '' + uniqid_enabled: true + more_entropy: false + hide: false + prefix: 'sku-' + suffix: '' size: 60 placeholder: '' third_party_settings: { } diff --git a/modules/product/src/Entity/ProductVariation.php b/modules/product/src/Entity/ProductVariation.php index 052607be27..6b968314a2 100644 --- a/modules/product/src/Entity/ProductVariation.php +++ b/modules/product/src/Entity/ProductVariation.php @@ -392,11 +392,12 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { $fields['sku'] = BaseFieldDefinition::create('string') ->setLabel(t('SKU')) ->setDescription(t('The unique, machine-readable identifier for a variation.')) + ->setDefaultValueCallback('Drupal\commerce_product\ProductVariationBulkCreator::getAutoSku') ->setRequired(TRUE) ->addConstraint('ProductVariationSku') ->setSetting('display_description', TRUE) ->setDisplayOptions('form', [ - 'type' => 'string_textfield', + 'type' => 'commerce_auto_sku', 'weight' => -4, ]) ->setDisplayConfigurable('form', TRUE) diff --git a/modules/product/src/ProductVariationBulkCreator.php b/modules/product/src/ProductVariationBulkCreator.php new file mode 100644 index 0000000000..9ba4fa8d77 --- /dev/null +++ b/modules/product/src/ProductVariationBulkCreator.php @@ -0,0 +1,409 @@ +entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + public static function getSkuwidget(ProductVariation $variation) { + $form_display = entity_get_form_display($variation->getEntityTypeId(), $variation->bundle(), 'default'); + + return $form_display->getRenderer('sku'); + } + + /** + * {@inheritdoc} + */ + public static function getSkuSettings(ProductVariation $variation) { + /** @var Drupal\commerce_product\Plugin\Field\FieldWidget\ProductVariationSkuWidget $widget */ + /** @var Drupal\Core\Field\Plugin\Field\FieldWidget\StringTextfieldWidget $widget */ + $widget = static::getSkuwidget($variation); + // If no one widget is enabled, then we need to asign uniqid() SKUs at the + // background to avoid having variations without SKU at all. + $default_sku_settings = [ + 'uniqid_enabled' => TRUE, + 'more_entropy' => FALSE, + 'prefix' => 'default_sku-', + 'suffix' => '', + ]; + + return $widget ? $widget->getSettings() : $default_sku_settings; + } + + /** + * {@inheritdoc} + */ + public static function getAutoSku(ProductVariation $variation) { + extract(static::getSkuSettings($variation)); + + // Do return empty string in case of StringTextfieldWidget. + return isset($uniqid_enabled) ? ($uniqid_enabled ? \uniqid($prefix, $more_entropy) . $suffix : "{$prefix}{$suffix}") : ''; + } + + /** + * {@inheritdoc} + */ + public static function afterBuildPreRenderArrayAlter(array $element) { + $i = 0; + while (isset($element['alter_data_' . $i]) && $data = $element['alter_data_' . $i]) { + $parents = []; + if (isset($data['#parents'])) { + $parents = $data['#parents']; + unset($data['#parents']); + } + unset($element['alter_data_' . $i]); + $key_exists = NULL; + $old_data = NestedArray::getValue($element, $parents, $key_exists); + if (is_array($old_data)) { + $data = array_replace($old_data, $data); + } + elseif ($key_exists && !in_array($old_data, $data)) { + $data[] = $old_data; + } + NestedArray::setValue($element, $parents, $data); + $i++; + } + + return $element; + } + + /** + * {@inheritdoc} + */ + public function getProductVariation(Product $product) { + $variations = $product->getVariations(); + $variation = end($variations); + $timestamp = time(); + if (!$variation instanceof ProductVariation) { + $variation = $this->entityTypeManager->getStorage('commerce_product_variation')->create([ + 'type' => $product->getFieldDefinition('variations')->getSettings()['handler_settings']['target_bundles'][0], + 'created' => $timestamp, + 'changed' => $timestamp, + ]); + } + + return $variation; + } + + /** + * {@inheritdoc} + */ + public function createProductVariation(Product $product, array $variation_custom_values = []) { + $variation = $this->getProductVariation($product); + $field = $this->getAttributeFieldOptionIds($variation); + if ($all = $this->getAttributesCombinations([$variation])) { + foreach (reset($all['not_used_combinations']) as $field_name => $id) { + $variation->get($field_name)->setValue(['target_id' => $id == '_none' ? NULL : $id]); + } + } + $sku = static::getAutoSku($variation); + $variation->setSku(empty($sku) ? \uniqid() : $sku); + + foreach ($variation_custom_values as $name => $value) { + $variation->set($name, $value); + } + if (!$variation->getPrice() instanceof Price) { + $currency_storage = $this->entityTypeManager->getStorage('commerce_currency'); + $currencies = array_keys($currency_storage->loadMultiple()); + $currency = empty($currencies) ? 'USD' : $currencies[0]; + // Decimals are omitted intentionally as $currency format is unknown here. + // The prices still will have valid format after saving. + $variation->setPrice(new Price('1', $currency)); + } + $variation->updateOriginalValues(); + + return $variation; + } + + /** + * {@inheritdoc} + */ + public function createAllProductVariations(Product $product, array $variation_custom_values = []) { + $variations = $product->getVariations(); + $timestamp = time(); + if (empty($variations) || !empty($variation_custom_values)) { + $variations[] = $this->createProductVariation($product, $variation_custom_values); + $timestamp--; + } + + if (!$all = $this->getAttributesCombinations($variations)) { + return; + } + + // Improve perfomance by getting sku settings just once instead of + // calling static::getAutoSku() in the loop. + extract(static::getSkuSettings($all['last_variation'])); + $prefix = isset($prefix) ? $prefix : ''; + $suffix = isset($suffix) ? $suffix : ''; + $more_entropy = isset($more_entropy) ? $more_entropy : FALSE; + foreach ($all['not_used_combinations'] as $combination) { + $variation = $all['last_variation']->createDuplicate() + ->setSku(\uniqid($prefix, $more_entropy) . $suffix) + ->setChangedTime($timestamp) + ->setCreatedTime($timestamp); + foreach ($combination as $field_name => $id) { + $variation->get($field_name)->setValue(['target_id' => $id == '_none' ? NULL : $id]); + } + $variation->updateOriginalValues(); + $variations[] = $variation; + // To avoid the same CreatedTime on multiple variations decrease the + // $timestamp by one second instead of calling time() in the loop. + $timestamp--; + } + + return $variations; + } + + /** + * {@inheritdoc} + */ + public function createAllIefFormVariations(array $form, FormStateInterface $form_state) { + // Rid of entity type manager here as that prevents to use instance of + // ProductVariationBulkCreator as an AJAX callback therefore forcing to use + // just the class name instead of object and define all functions as static. + $this->entityTypeManager = NULL; + $ief_id = $form['variations']['widget']['#ief_id']; + $ief_entities = $form_state->get(['inline_entity_form', $ief_id, 'entities']) ?: []; + if (!$all = $this->getAttributesCombinations(array_column($ief_entities, 'entity'))) { + return; + } + // The attributes (ids and options) may be quite heavy, so unset them. + unset($all['attributes']); + $timestamp = time(); + $ief_entity = end($ief_entities); + extract(static::getSkuSettings($all['last_variation'])); + $prefix = isset($prefix) ? $prefix : ''; + $suffix = isset($suffix) ? $suffix : ''; + $more_entropy = isset($more_entropy) ? $more_entropy : FALSE; + foreach ($all['not_used_combinations'] as $combination) { + $variation = $all['last_variation']->createDuplicate() + ->setSku(\uniqid($prefix, $more_entropy) . $suffix) + ->setChangedTime($timestamp) + ->setCreatedTime($timestamp); + foreach ($combination as $field_name => $id) { + $variation->get($field_name)->setValue(['target_id' => $id == '_none' ? NULL : $id]); + } + $variation->updateOriginalValues(); + $ief_entity['entity'] = $variation; + $ief_entity['weight'] += 1; + $ief_entity['needs_save'] = TRUE; + array_push($ief_entities, $ief_entity); + $timestamp--; + } + // Before continuing unset $all['*combinations'] which might be a huge data. + unset($all); + $form_state->set(['inline_entity_form', $ief_id, 'entities'], $ief_entities); + $form_state->setRebuild(); + } + + /** + * {@inheritdoc} + */ + public function getIefFormNotUsedAttributesCombination(FormStateInterface $form_state, $ief_id = '') { + $this->entityTypeManager = NULL; + $ief_entities = $form_state->get(['inline_entity_form', $ief_id, 'entities']) ?: []; + + return $this->getNotUsedAttributesCombination(array_column($ief_entities, 'entity')); + } + + /** + * {@inheritdoc} + */ + public function getNotUsedAttributesCombination(array $variations) { + if (!$all = $this->getDuplicationsHtmlList($variations)) { + return; + } + $all['not_used_combination'] = reset($all['not_used_combinations']); + // Rid of unecessary data which might be quite heavy. + unset($all['used_combinations'], $all['not_used_combinations'], $all['attributes']); + + return $all; + } + + /** + * {@inheritdoc} + */ + public function getUsedAttributesCombinations(array $variations) { + $all = []; + $all['duplicated'] = $all['used_combinations'] = []; + $all['last_variation'] = end($variations); + $all['attributes'] = $this->getAttributeFieldOptionIds(end($variations)); + $nones = array_fill_keys(array_keys($all['attributes']['ids']), '_none'); + foreach ($variations as $index => $variation) { + // ProductVariation->getAttributeValueIds() does not return empty optional + // fields. Merge 'field_name' => '_none' as a choice in the combination. + // @todo Render '_none' option on an Add to Cart form. + // @see ProductVariationAttributesWidget->formElement() + // @see CommerceProductRenderedAttribute::processRadios() + $combination = array_merge($nones, $variation->getAttributeValueIds()); + if (in_array($combination, $all['used_combinations'])) { + $all['duplicated'][$index] = $combination; + } + else { + $all['used_combinations'][$index] = $combination; + } + } + $all['used'] = count($all['used_combinations']); + $all['count'] = $all['attributes']['count']; + + return $all; + } + + /** + * {@inheritdoc} + */ + public function getDuplicationsHtmlList(array $variations) { + if (!$all = $this->getAttributesCombinations($variations)) { + return; + } + if (!empty($all['duplicated'])) { + $all['duplications_list'] = '