commercetools-8.x-1.2-alpha1/modules/commercetools_content/src/Service/CommercetoolsContentComponents.php

modules/commercetools_content/src/Service/CommercetoolsContentComponents.php
<?php

namespace Drupal\commercetools_content\Service;

use Drupal\commercetools\Exception\CommercetoolsOperationFailedException;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Form\FormBuilderInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Pager\PagerManagerInterface;
use Drupal\Core\Security\TrustedCallbackInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\commercetools\CacheableCommercetoolsResponse;
use Drupal\commercetools\CommercetoolsApiServiceInterface;
use Drupal\commercetools\CommercetoolsConfiguration;
use Drupal\commercetools\CommercetoolsLocalization;
use Drupal\commercetools\CommercetoolsProducts;
use Drupal\commercetools\CommercetoolsService;
use Drupal\commercetools\Routing\UiModulesRouteProviderBase;
use Drupal\commercetools_content\Form\CatalogFiltersForm;
use Drupal\commercetools_content\ProductListConfigurationDto;
use Drupal\commercetools_content\Routing\RouteProvider;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

/**
 * Provides components for rendering commercetools content.
 */
class CommercetoolsContentComponents implements TrustedCallbackInterface {

  use StringTranslationTrait;

  const URL_PARAMS_LIST = [
    'text',
    'filters',
    'queryFilters',
    'facetFilters',
    'sorts',
    'search',
    'facets',
    'category',
  ];

  /**
   * A storage of product list block configurations by the product list index.
   *
   * Used to get the configuration in the filters block and other blocks that
   * should know the current product list filters.
   *
   * @var array
   */
  protected $configurationPerIndex = [];

  /**
   * A cache of the product list results to reuse in different components.
   *
   * @var array
   */
  protected $productListResultsCache = [];

  /**
   * Constructs a CommercetoolsContentComponents object.
   *
   * @param \Drupal\commercetools\CommercetoolsProducts $ctProducts
   *   The Commercetools products service.
   * @param \Drupal\commercetools\CommercetoolsService $ct
   *   The Commercetools service.
   * @param \Drupal\commercetools\CommercetoolsConfiguration $ctConfig
   *   The commercetools configuration service.
   * @param \Drupal\Core\Pager\PagerManagerInterface $pagerManager
   *   The pager manager.
   * @param \Drupal\Core\Form\FormBuilderInterface $formBuilder
   *   The form builder service.
   * @param \Drupal\commercetools\CommercetoolsApiServiceInterface $ctApi
   *   The Commercetools API service.
   * @param \Symfony\Component\HttpFoundation\RequestStack $requestStack
   *   The request stack service.
   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
   *   The messenger service.
   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
   *   Config factory.
   * @param \Drupal\commercetools\CommercetoolsLocalization $ctLocalization
   *   The Commercetools localization service.
   */
  public function __construct(
    protected CommercetoolsProducts $ctProducts,
    protected CommercetoolsService $ct,
    protected CommercetoolsConfiguration $ctConfig,
    protected PagerManagerInterface $pagerManager,
    protected FormBuilderInterface $formBuilder,
    protected CommercetoolsApiServiceInterface $ctApi,
    protected RequestStack $requestStack,
    protected MessengerInterface $messenger,
    protected ConfigFactoryInterface $configFactory,
    protected CommercetoolsLocalization $ctLocalization,
  ) {
  }

  /**
   * {@inheritdoc}
   */
  public static function trustedCallbacks() {
    return ['buildFilters', 'buildProductListComponent'];
  }

  /**
   * Add a product list placeholder.
   *
   * Build the product list component after adding
   * all product list configuration from other components.
   */
  public function getProductListComponent(ProductListConfigurationDto $configuration, int $productListIndex = 0): array {
    $this->applyProductListConfiguration($configuration, $productListIndex);
    return [
      '#pre_render' => [[$this, 'buildProductListComponent']],
      '#product_list_index' => $productListIndex,
    ];
  }

  /**
   * Add a filter placeholder.
   *
   * Build the form after adding all product list configuration
   * from other components.
   */
  public function getFiltersForm(int $productListIndex, $targetPage = NULL, $enabledFilters = [], $enabledFacets = []) {
    $facets = [];
    foreach ($enabledFacets as $facet) {
      if ($facet['path'] === CommercetoolsService::CT_VARIANTS_PRICE_AMOUNT) {
        $facet['graphql'] = $this->ct->buildRangeFacetGraphql($facet['path']);
      }
      else {
        $facet['graphql'] = $this->ct->buildFacetGraphql($facet['path'], $facet['widget_type'] === 'facet_count');
      }

      $facets[] = $facet;
    }
    $configuration = new ProductListConfigurationDto(facets: $facets);
    $this->applyProductListConfiguration($configuration, $productListIndex);

    return [
      '#pre_render' => [[$this, 'buildFilters']],
      '#product_list_index' => $productListIndex,
      '#target_page' => $targetPage,
      '#enabled_filters' => $enabledFilters,
    ];
  }

  /**
   * Main method to build the product list.
   */
  public function buildProductListComponent(array $build) {
    $productListIndex = $build['#product_list_index'];
    $output = [];
    $productListConfiguration = $this->getProductListConfiguration($productListIndex);
    try {
      $products = $this->getProductsForProductList($productListIndex);
      if (empty($products->getData())) {
        return $output;
      }
      $totalAvailableProducts = $products->getData()['total'] ?? 0;
      $effectiveLimit = isset($productListConfiguration->limit) && $productListConfiguration->limit > 0
        ? min($productListConfiguration->limit, $totalAvailableProducts)
        : $totalAvailableProducts;
      $currentPage = $this->pagerManager->findPage($productListIndex);
      $output = $this->buildRenderArray($products, $productListConfiguration, $currentPage, $effectiveLimit, $productListConfiguration->itemsPerPage);
      $cache = $products->getCacheableMetadata();
      $cache->applyTo($output);
    }
    catch (\Exception $e) {
      if ($e instanceof CommercetoolsOperationFailedException) {
        return [
          '#theme' => 'status_messages',
          '#message_list' => [
            'error' => [
              $this->t('An error occurred while building the list: @message', [
                '@message' => $e->getMessage(),
              ]),
            ],
          ],
          '#attributes' => [
            'class' => [
              'commercetools-block-render-failed',
            ],
          ],
          '#cache' => [
            'max-age' => 0,
          ],
        ];
      }
      else {
        throw $e;
      }
    }
    return $output;
  }

  /**
   * Build facets form.
   */
  public function buildFilters(array $build) {
    $productListIndex = $build['#product_list_index'];
    // This is preRender callback, and if it throws an exception,
    // this leads to infinite recursion, so we need to catch it
    // and return empty array to avoid the loop.
    // @todo Cover this by tests.
    try {
      $result = $this->getProductsForProductList($productListIndex);
    }
    catch (\Exception) {
      return [];
    }
    $productData = $result->getData();
    $activeFilters = (array) $this->getRequestQueryParams($productListIndex)['filters'] ?? [];
    $appliedSorts = $this->getRequestQueryParams($productListIndex)['sorts'] ?? [];
    $args = [
      $productData ?? [],
      $build['#enabled_filters'],
      $activeFilters,
      $appliedSorts,
      $productListIndex,
      $build['#target_page'] ?? NULL,
    ];
    $build['form'] = $this->formBuilder->getForm(CatalogFiltersForm::class, ...$args);
    // Wrapper to support ajax form.
    $build['#prefix'] = '<div>';
    $build['#suffix'] = '</div>';
    return $build;
  }

  /**
   * Gets the product list block configuration by the product list index.
   */
  private function getProductListConfiguration(int $productListIndex): ?ProductListConfigurationDto {
    $configuration = $this->configurationPerIndex[$productListIndex] ?? NULL;
    if (!$configuration) {
      // @todo Rework to loading all the available blocks on the current page
      // and search for the product list block with the specified component
      // index.
      $configuration = new ProductListConfigurationDto();
    }
    return $configuration;
  }

  /**
   * Apply product list configuration.
   */
  protected function applyProductListConfiguration($configuration, $productListIndex) {
    if (empty($this->configurationPerIndex[$productListIndex])) {
      $this->configurationPerIndex[$productListIndex] = $configuration;
    }
    else {
      $this->configurationPerIndex[$productListIndex]->merge($configuration);
    }
  }

  /**
   * Extracts valid query parameters from the request.
   *
   * @param int|null $productListIndex
   *   The product list index. Optional.
   *
   * @return array
   *   All available valid query parameters.
   */
  public function getRequestQueryParams(?int $productListIndex = NULL): array {
    $params = [];
    $request = $this->requestStack->getCurrentRequest();
    $queryParams = $request->query->all();

    if (isset($productListIndex)) {
      foreach (self::URL_PARAMS_LIST as $key) {
        $params[$key] = $queryParams[self::getParamNameByIndex($key, $productListIndex)] ?? NULL;
      }
    }
    else {
      // All query parameters if no index provided.
      $params = $queryParams;
    }

    return $params;
  }

  /**
   * Build and execute product list query.
   */
  protected function getProductsForProductList($productListIndex): CacheableCommercetoolsResponse {
    $configuration = $this->getProductListConfiguration($productListIndex);
    $filtersFromRequest = $this->getRequestQueryParams($productListIndex);

    // Check the cached requests to not execute the GraphQL request again.
    $cacheKey = 'commercetools_content:product_list:' . md5(serialize($configuration) . serialize($filtersFromRequest));
    if ($cachedResult = $this->productListResultsCache[$cacheKey] ?? NULL) {
      return $cachedResult;
    }

    $sortValue = ($configuration->sortBy && $configuration->sortOrder)
      ? trim($configuration->sortBy . ' ' . $configuration->sortOrder)
      : NULL;
    $baseQueryArguments = [
      'text' => $filtersFromRequest['search'],
      'facets' => $configuration->facets ?? NULL,
      'sorts' => $sortValue,
    ];

    if (isset($filtersFromRequest['filters'])) {
      foreach ($filtersFromRequest['filters'] as $path => $activeFilter) {

        // Special handling for price range filter.
        if (str_contains($path, CommercetoolsService::CT_VARIANTS_PRICE_AMOUNT)) {
          $convertedFrom = '0';
          $convertedTo = '*';
          $fractionDigits = $this->ctLocalization->getFractionDigits();

          // Calculate the factor: 10^fractionDigits (eg: 10^2 = 100).
          $factor = pow(10, $fractionDigits);
          if (isset($activeFilter['from'])) {
            $convertedFrom = (string) ($activeFilter['from'] * $factor);
          }
          if (isset($activeFilter['to'])) {
            $convertedTo = (string) ($activeFilter['to'] * $factor);
          }

          $values['from'] = $convertedFrom;
          $values['to'] = $convertedTo;
          $type = CommercetoolsService::FILTER_TYPE_RANGE;
        }
        else {
          $values = is_array($activeFilter) ? array_values($activeFilter) : $activeFilter;
          $type = CommercetoolsService::FILTER_TYPE_VALUE;
        }

        $filterValue = $this->ct->buildFilter($path, $values, $type);
        $baseQueryArguments['queryFilters'][] = $filterValue;
      }
    }

    if (!empty($filtersFromRequest['sorts'])) {
      $baseQueryArguments['sorts'][] = $filtersFromRequest['sorts'];
    }

    // Filter by categories.
    $categoriesList = [];
    // Explicitly set category in component setting has priority.
    if (!empty($configuration->categories)) {
      $categoriesList = array_map('strval', array_unique(array_values($configuration->categories)));
    }
    // If not explicitly set, check dynamic query parameter.
    elseif (!empty($filtersFromRequest['category'])) {
      $categoriesList[] = $filtersFromRequest['category'];
    }
    if ($categoriesList) {
      $baseQueryArguments['queryFilters'][] = $this->ct->buildFilter(
        'categories.id',
        $categoriesList,
        CommercetoolsService::FILTER_TYPE_TREE,
      );
    }

    if (!empty($configuration->skus)) {
      $baseQueryArguments['filters'][] = $this->ct->buildFilter('variants.sku', array_values($configuration->skus));
    }

    if (!empty($configuration->customFilters)) {
      foreach ($configuration->customFilters as $customFilter) {
        $baseQueryArguments['filters'][] = $customFilter;
      }
    }
    $currentPage = $this->pagerManager->findPage($productListIndex);
    $offset = $currentPage * $configuration->itemsPerPage;

    $queryArguments = array_merge($baseQueryArguments, [
      'limit' => $configuration->itemsPerPage,
      'offset' => $offset,
    ]);

    $result = $this->ctProducts->getProducts($queryArguments);
    $this->productListResultsCache[$cacheKey] = $result;
    return $result;
  }

  /**
   * Builds the render array for products.
   */
  protected function buildRenderArray(
    $productListCacheable,
    ProductListConfigurationDto $configuration,
    int $currentPage,
    int $effectiveLimit,
    int $itemsPerPage,
    int $productListIndex = 0,
  ): array {
    $style = $configuration->style;
    $productData = $productListCacheable->getData();
    $unavailableDataText = $configuration->unavailableDataText;
    $totalDisplayableProducts = $effectiveLimit;
    if ($totalDisplayableProducts > 0) {
      $this->pagerManager->createPager($totalDisplayableProducts, $itemsPerPage, $productListIndex);
    }
    $totalPages = $totalDisplayableProducts > 0
      ? ceil($totalDisplayableProducts / $itemsPerPage)
      : 0;
    $noProductsFound = $totalDisplayableProducts === 0 || ($currentPage >= $totalPages);
    $remainingItems = $effectiveLimit - ($currentPage * $itemsPerPage);
    $maxItemsToDisplay = max(0, min($remainingItems, $itemsPerPage));
    $results = array_slice($productData['results'] ?? [], 0, $maxItemsToDisplay);

    $items = [];
    foreach ($results as $product) {
      if (!$product['slug']) {
        $this->messenger->addError('The product slug value is missing for a product.');
        $url = Url::fromRoute(RouteProvider::ROUTE_PREFIX . UiModulesRouteProviderBase::PAGE_PRODUCT_ROUTE, ['slug' => CommercetoolsProducts::MISSING_SLUG_VALUE]);
      }
      else {
        $url = Url::fromRoute(RouteProvider::ROUTE_PREFIX . UiModulesRouteProviderBase::PAGE_PRODUCT_ROUTE, ['slug' => $product['slug']]);
      }

      // Apply an image style to images.
      if ($product['images']) {
        $product['images'] = array_map(function ($image) {
          return ['url' => $this->applyImageStyle($image['url'])] + $image;
        }, $product['images']);
      }

      $items[] = [
        '#theme' => 'commercetools_product_' . $style . '_item',
        '#unavailable_data_text' => $unavailableDataText,
        '#product' => $product,
        '#url' => $url,
        '#attributes' => ['data-product-list-index' => $productListIndex],
      ];
    }
    // If no products found and we are not on the first page, throw a 404.
    // @todo Throw only when rendering for the full page, not for the block.
    if (empty($items) && $currentPage > 0) {
      throw new NotFoundHttpException('No products found.');
    }

    return [
      'catalog' => [
        '#theme' => 'commercetools_product_' . $style,
        '#no_products_found' => $noProductsFound,
        '#items' => $items,
        '#columns_number' => $configuration->columnsNumber,
      ],
      'pager' => !$noProductsFound && $totalDisplayableProducts > 0
        ? [
          '#type' => 'pager',
          '#element' => $productListIndex,
        ]
        : [],
    ];
  }

  /**
   * Prepares the GET parameter name taking the component index into account.
   *
   * @param string $paramName
   *   The name of parameter.
   * @param int|null $productListIndex
   *   The component index number. Optional.
   *
   * @return string
   *   Component suffix.
   */
  public static function getParamNameByIndex(string $paramName, ?int $productListIndex = NULL): string {
    return $paramName . (empty($productListIndex) ? '' : "_{$productListIndex}");
  }

  /**
   * Apply an image style to the image.
   *
   * @param string $imageUrl
   *   Image url.
   * @param string|null $style
   *   The name of the style. All possible styles are
   *   listed in https://docs.commercetools.com/api/projects/products#image.
   *
   * @return string
   *   URL of the image with the applied style.
   */
  public function applyImageStyle(string $imageUrl, ?string $style = NULL): string {
    $style ??= $this->ctConfig->settings->get(CommercetoolsService::CONFIG_CARD_IMAGE_STYLE);
    return $style ? preg_replace('/(\.[^.\/]+)$/', '-' . $style . '$1', $imageUrl) : $imageUrl;
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc