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;
}
}
