diff --git a/src/module-elasticsuite-catalog-optimizer/Controller/Adminhtml/Optimizer/Preview.php b/src/module-elasticsuite-catalog-optimizer/Controller/Adminhtml/Optimizer/Preview.php index 26cd626b1..4b858114a 100644 --- a/src/module-elasticsuite-catalog-optimizer/Controller/Adminhtml/Optimizer/Preview.php +++ b/src/module-elasticsuite-catalog-optimizer/Controller/Adminhtml/Optimizer/Preview.php @@ -22,7 +22,6 @@ use Smile\ElasticsuiteCatalogOptimizer\Api\Data\OptimizerInterfaceFactory; use Smile\ElasticsuiteCatalogOptimizer\Model\Optimizer\PreviewFactory; use Smile\ElasticsuiteCore\Api\Search\Request\ContainerConfigurationInterface; -use Smile\ElasticsuiteCore\Search\Request\ContainerConfiguration; use Smile\ElasticsuiteCore\Search\Request\ContainerConfigurationFactory; /** diff --git a/src/module-elasticsuite-catalog/Block/Adminhtml/Search/Term/Edit/Preview.php b/src/module-elasticsuite-catalog/Block/Adminhtml/Search/Term/Edit/Preview.php new file mode 100644 index 000000000..b926c9e1b --- /dev/null +++ b/src/module-elasticsuite-catalog/Block/Adminhtml/Search/Term/Edit/Preview.php @@ -0,0 +1,84 @@ + + * @copyright 2018 Smile + * @license Open Software License ("OSL") v. 3.0 + */ + +namespace Smile\ElasticsuiteCatalog\Block\Adminhtml\Search\Term\Edit; + +use Magento\Framework\View\Element\AbstractBlock; + +/** + * Add the merchandiser button in the search term form. + * + * @category Smile + * @package Smile\ElasticsuiteCatalog + * @author Aurelien FOUCRET + */ +class Preview extends \Magento\Framework\View\Element\AbstractBlock +{ + /** + * @var \Magento\Backend\Block\Widget\Button\ButtonList + */ + private $buttonList; + + /** + * @var \Magento\Backend\Model\UrlInterface + */ + private $urlBuilder; + + /** + * Constructor. + * + * @param \Magento\Backend\Block\Widget\Context $context Block context. + * @param \Magento\Backend\Model\UrlInterface $urlBuilder URL Builder. + * @param array $data Block data. + */ + public function __construct( + \Magento\Backend\Block\Widget\Context $context, + \Magento\Backend\Model\UrlInterface $urlBuilder, + array $data = [] + ) { + $this->buttonList = $context->getButtonList(); + $this->urlBuilder = $urlBuilder; + parent::__construct($context, $data); + } + + /** + * @SuppressWarnings(PHPMD.CamelCaseMethodName) + * + * {@inheritDoc} + */ + protected function _construct() + { + parent::_construct(); + + $this->buttonList->add( + 'merchandiser-button', + [ + 'label' => __('Merchandiser'), + 'class' => 'delete', + 'onclick' => "setLocation('" . $this->getMerchandiserUrl() . "')", + ] + ); + } + + /** + * Retrieve merchandiser button URL. + * + * @return string + */ + private function getMerchandiserUrl() + { + $queryId = $this->getRequest()->getParam('id'); + + return $this->urlBuilder->getUrl('search/term_merchandiser/edit', ['id' => $queryId]); + } +} diff --git a/src/module-elasticsuite-catalog/Block/Adminhtml/Search/Term/Merchandiser/Edit/BackButton.php b/src/module-elasticsuite-catalog/Block/Adminhtml/Search/Term/Merchandiser/Edit/BackButton.php new file mode 100644 index 000000000..e01064a39 --- /dev/null +++ b/src/module-elasticsuite-catalog/Block/Adminhtml/Search/Term/Merchandiser/Edit/BackButton.php @@ -0,0 +1,53 @@ + + * @copyright 2018 Smile + * @license Open Software License ("OSL") v. 3.0 + */ +namespace Smile\ElasticsuiteCatalog\Block\Adminhtml\Search\Term\Merchandiser\Edit; + +use Magento\Framework\View\Element\UiComponent\Control\ButtonProviderInterface; + +/** + * Form back button. + * + * @category Smile + * @package Smile\ElasticsuiteCatalog + * @author Aurelien FOUCRET + */ +class BackButton extends GenericButton implements ButtonProviderInterface +{ + /** + * {@inheritDoc} + */ + public function getButtonData() + { + $data = []; + if ($this->canRender('reset')) { + $data = [ + 'label' => __('Back'), + 'class' => 'back', + 'on_click' => "setLocation('" . $this->getQueryUrl() . "');", + 'sort_order' => 10, + ]; + } + + return $data; + } + + /** + * Get back URL. + * + * @return string + */ + public function getQueryUrl() + { + return $this->getUrl('search/term/edit', ['id' => $this->getQueryId()]); + } +} diff --git a/src/module-elasticsuite-catalog/Block/Adminhtml/Search/Term/Merchandiser/Edit/GenericButton.php b/src/module-elasticsuite-catalog/Block/Adminhtml/Search/Term/Merchandiser/Edit/GenericButton.php new file mode 100644 index 000000000..119a075aa --- /dev/null +++ b/src/module-elasticsuite-catalog/Block/Adminhtml/Search/Term/Merchandiser/Edit/GenericButton.php @@ -0,0 +1,88 @@ + + * @copyright 2018 Smile + * @license Open Software License ("OSL") v. 3.0 + */ +namespace Smile\ElasticsuiteCatalog\Block\Adminhtml\Search\Term\Merchandiser\Edit; + +/** + * Generic form button. + * + * @category Smile + * @package Smile\ElasticsuiteCatalog + * @author Aurelien FOUCRET + */ +class GenericButton +{ + /** + * Url Builder + * + * @var \Magento\Framework\UrlInterface + */ + private $urlBuilder; + + /** + * Registry + * + * @var \Magento\Framework\Registry + */ + private $registry; + + /** + * Constructor + * + * @param \Magento\Backend\Block\Widget\Context $context Block context. + * @param \Magento\Framework\Registry $registry Registry. + */ + public function __construct( + \Magento\Backend\Block\Widget\Context $context, + \Magento\Framework\Registry $registry + ) { + $this->urlBuilder = $context->getUrlBuilder(); + $this->registry = $registry; + } + + /** + * Return the current query id. + * + * @return int|null + */ + public function getQueryId() + { + $query = $this->registry->registry('current_query'); + + return $query ? $query->getId() : null; + } + + /** + * Generate url by route and parameters + * + * @param string $route Route. + * @param array $params Route params. + * + * @return string + */ + public function getUrl($route = '', $params = []) + { + return $this->urlBuilder->getUrl($route, $params); + } + + /** + * Check where button can be rendered + * + * @param string $name Button name. + * + * @return string + */ + public function canRender($name) + { + return $name; + } +} diff --git a/src/module-elasticsuite-catalog/Block/Adminhtml/Search/Term/Merchandiser/Edit/ResetButton.php b/src/module-elasticsuite-catalog/Block/Adminhtml/Search/Term/Merchandiser/Edit/ResetButton.php new file mode 100644 index 000000000..e6190cd29 --- /dev/null +++ b/src/module-elasticsuite-catalog/Block/Adminhtml/Search/Term/Merchandiser/Edit/ResetButton.php @@ -0,0 +1,43 @@ + + * @copyright 2018 Smile + * @license Open Software License ("OSL") v. 3.0 + */ +namespace Smile\ElasticsuiteCatalog\Block\Adminhtml\Search\Term\Merchandiser\Edit; + +use Magento\Framework\View\Element\UiComponent\Control\ButtonProviderInterface; + +/** + * Reset button. + * + * @category Smile + * @package Smile\ElasticsuiteCatalog + * @author Aurelien FOUCRET + */ +class ResetButton extends GenericButton implements ButtonProviderInterface +{ + /** + * {@inheritDoc} + */ + public function getButtonData() + { + $data = []; + if ($this->canRender('reset')) { + $data = [ + 'label' => __('Reset'), + 'class' => 'reset', + 'on_click' => 'location.reload()', + 'sort_order' => 30, + ]; + } + + return $data; + } +} diff --git a/src/module-elasticsuite-catalog/Block/Adminhtml/Search/Term/Merchandiser/Edit/SaveAndContinueButton.php b/src/module-elasticsuite-catalog/Block/Adminhtml/Search/Term/Merchandiser/Edit/SaveAndContinueButton.php new file mode 100644 index 000000000..77e5d67aa --- /dev/null +++ b/src/module-elasticsuite-catalog/Block/Adminhtml/Search/Term/Merchandiser/Edit/SaveAndContinueButton.php @@ -0,0 +1,43 @@ + + * @copyright 2018 Smile + * @license Open Software License ("OSL") v. 3.0 + */ +namespace Smile\ElasticsuiteCatalog\Block\Adminhtml\Search\Term\Merchandiser\Edit; + +use Magento\Framework\View\Element\UiComponent\Control\ButtonProviderInterface; + +/** + * Save and continue button. + * + * @category Smile + * @package Smile\ElasticsuiteCatalog + * @author Aurelien FOUCRET + */ +class SaveAndContinueButton extends GenericButton implements ButtonProviderInterface +{ + /** + * {@inheritDoc} + */ + public function getButtonData() + { + $data = []; + if ($this->canRender('save_and_continue_edit')) { + $data = [ + 'label' => __('Save and Continue Edit'), + 'class' => 'save', + 'on_click' => '', + 'sort_order' => 90, + ]; + } + + return $data; + } +} diff --git a/src/module-elasticsuite-catalog/Block/Adminhtml/Search/Term/Merchandiser/Edit/SaveButton.php b/src/module-elasticsuite-catalog/Block/Adminhtml/Search/Term/Merchandiser/Edit/SaveButton.php new file mode 100644 index 000000000..d53d4987e --- /dev/null +++ b/src/module-elasticsuite-catalog/Block/Adminhtml/Search/Term/Merchandiser/Edit/SaveButton.php @@ -0,0 +1,43 @@ + + * @copyright 2018 Smile + * @license Open Software License ("OSL") v. 3.0 + */ +namespace Smile\ElasticsuiteCatalog\Block\Adminhtml\Search\Term\Merchandiser\Edit; + +use Magento\Framework\View\Element\UiComponent\Control\ButtonProviderInterface; + +/** + * Save button. + * + * @category Smile + * @package Smile\ElasticsuiteCatalog + * @author Aurelien FOUCRET + */ +class SaveButton extends GenericButton implements ButtonProviderInterface +{ + /** + * @return array + * @codeCoverageIgnore + */ + public function getButtonData() + { + $data = []; + if ($this->canRender('save')) { + $data = [ + 'label' => __('Save'), + 'class' => 'save primary', + 'on_click' => '', + ]; + } + + return $data; + } +} diff --git a/src/module-elasticsuite-catalog/Controller/Adminhtml/Term/Merchandiser/Edit.php b/src/module-elasticsuite-catalog/Controller/Adminhtml/Term/Merchandiser/Edit.php new file mode 100644 index 000000000..0032798bb --- /dev/null +++ b/src/module-elasticsuite-catalog/Controller/Adminhtml/Term/Merchandiser/Edit.php @@ -0,0 +1,83 @@ + + * @copyright 2018 Smile + * @license Open Software License ("OSL") v. 3.0 + */ +namespace Smile\ElasticsuiteCatalog\Controller\Adminhtml\Term\Merchandiser; + +use Magento\Framework\Controller\ResultFactory; + +/** + * Search term merchandiser edit form controller. + * + * @category Smile + * @package Smile\ElasticsuiteCatalog + * @author Aurelien FOUCRET + */ +class Edit extends \Magento\Search\Controller\Adminhtml\Term +{ + /** + * Core registry + * + * @var \Magento\Framework\Registry + */ + private $coreRegistry; + + /** + * @var \Magento\Search\Model\QueryFactory + */ + private $queryFactory; + + /** + * Constructor. + * + * @param \Magento\Backend\App\Action\Context $context Controller context. + * @param \Magento\Framework\Registry $coreRegistry Registry. + * @param \Magento\Search\Model\QueryFactory $queryFactory Search query factory. + */ + public function __construct( + \Magento\Backend\App\Action\Context $context, + \Magento\Framework\Registry $coreRegistry, + \Magento\Search\Model\QueryFactory $queryFactory + ) { + parent::__construct($context); + $this->coreRegistry = $coreRegistry; + $this->queryFactory = $queryFactory; + } + + /** + * {@inheritDoc} + */ + public function execute() + { + $queryId = $this->getRequest()->getParam('id'); + $model = $this->queryFactory->create(); + $result = null; + + if (!$queryId) { + $this->messageManager->addErrorMessage(__('No search specified.')); + $result = $this->resultFactory->create(ResultFactory::TYPE_REDIRECT)->setPath('search/term/index'); + } + + $model->load($queryId); + if (!$model->getId()) { + $this->messageManager->addErrorMessage(__('This search no longer exists.')); + $result = $this->resultFactory->create(ResultFactory::TYPE_REDIRECT)->setPath('search/term/index'); + } + + if ($result === null) { + $this->coreRegistry->register('current_query', $model); + $result = $this->createPage(); + $result->getConfig()->getTitle()->prepend(__('Search results for "%1"', $model->getQueryText())); + } + + return $result; + } +} diff --git a/src/module-elasticsuite-catalog/Controller/Adminhtml/Term/Merchandiser/Load.php b/src/module-elasticsuite-catalog/Controller/Adminhtml/Term/Merchandiser/Load.php new file mode 100644 index 000000000..eab02a55f --- /dev/null +++ b/src/module-elasticsuite-catalog/Controller/Adminhtml/Term/Merchandiser/Load.php @@ -0,0 +1,81 @@ + + * @copyright 2018 Smile + * @license Open Software License ("OSL") v. 3.0 + */ +namespace Smile\ElasticsuiteCatalog\Controller\Adminhtml\Term\Merchandiser; + +/** + * Search term merchandiser preview load controller. + * + * @category Smile + * @package Smile\ElasticsuiteCatalog + * @author Aurelien FOUCRET + */ +class Load extends \Magento\Search\Controller\Adminhtml\Term +{ + /** + * @var \Magento\Search\Model\QueryFactory + */ + private $queryFactory; + + /** + * @var \Magento\Framework\Json\Helper\Data + */ + private $jsonHelper; + + /** + * @var \Smile\ElasticsuiteCatalog\Model\Search\PreviewFactory + */ + private $previewFactory; + + /** + * Constructor. + * + * @param \Magento\Backend\App\Action\Context $context Controller context. + * @param \Magento\Search\Model\QueryFactory $queryFactory Search query factory. + * @param \Magento\Framework\Json\Helper\Data $jsonHelper Json Helper. + * @param \Smile\ElasticsuiteCatalog\Model\Search\PreviewFactory $previewFactory Preview factory. + */ + public function __construct( + \Magento\Backend\App\Action\Context $context, + \Magento\Search\Model\QueryFactory $queryFactory, + \Magento\Framework\Json\Helper\Data $jsonHelper, + \Smile\ElasticsuiteCatalog\Model\Search\PreviewFactory $previewFactory + ) { + parent::__construct($context); + $this->queryFactory = $queryFactory; + $this->jsonHelper = $jsonHelper; + $this->previewFactory = $previewFactory; + } + + /** + * {@inheritDoc} + */ + public function execute() + { + $queryId = $this->getRequest()->getParam('query_id', 0); + $pageSize = $this->getRequest()->getParam('page_size'); + + $query = $this->queryFactory->create()->load($queryId); + + $responseData = ['products' => [], 'size' => 0]; + + if ($query->getId()) { + $productPositions = $this->getRequest()->getParam('product_position', []); + $query->setSortedProductIds(array_keys($productPositions)); + $preview = $this->previewFactory->create(['searchQuery' => $query, 'size' => $pageSize]); + $responseData = $preview->getData(); + } + + $json = $this->jsonHelper->jsonEncode($responseData); + $this->getResponse()->representJson($json); + } +} diff --git a/src/module-elasticsuite-catalog/Controller/Adminhtml/Term/Merchandiser/Save.php b/src/module-elasticsuite-catalog/Controller/Adminhtml/Term/Merchandiser/Save.php new file mode 100644 index 000000000..8525d3be6 --- /dev/null +++ b/src/module-elasticsuite-catalog/Controller/Adminhtml/Term/Merchandiser/Save.php @@ -0,0 +1,84 @@ + + * @copyright 2018 Smile + * @license Open Software License ("OSL") v. 3.0 + */ +namespace Smile\ElasticsuiteCatalog\Controller\Adminhtml\Term\Merchandiser; + +use Magento\Framework\Controller\ResultFactory; + +/** + * Search term merchandiser save controller. + * + * @category Smile + * @package Smile\ElasticsuiteCatalog + * @author Aurelien FOUCRET + */ +class Save extends \Magento\Search\Controller\Adminhtml\Term +{ + /** + * @var \Magento\Framework\Json\Helper\Data + */ + private $jsonHelper; + + /** + * @var \Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Search\Position + */ + private $resourceModel; + + /** + * + * @param \Magento\Backend\App\Action\Context $context Context. + * @param \Magento\Framework\Json\Helper\Data $jsonHelper JSON helper. + * @param \Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Search\Position $resourceModel Resource model. + */ + public function __construct( + \Magento\Backend\App\Action\Context $context, + \Magento\Framework\Json\Helper\Data $jsonHelper, + \Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Search\Position $resourceModel + ) { + parent::__construct($context); + $this->jsonHelper = $jsonHelper; + $this->resourceModel = $resourceModel; + } + + /** + * {@inheritDoc} + */ + public function execute() + { + $queryId = $this->getRequest()->getParam('query_id'); + $sortedProducts = $this->getRequest()->getParam('sorted_products', []); + + if (is_string($sortedProducts)) { + try { + $sortedProducts = $this->jsonHelper->jsonDecode($sortedProducts); + } catch (\Exception $e) { + $sortedProducts = []; + } + } + + $result = $this->resultFactory->create(ResultFactory::TYPE_REDIRECT) + ->setPath('*/term/edit', ['id' => $queryId]); + + try { + $this->resourceModel->saveProductPositions($queryId, $sortedProducts); + + if ($this->getRequest()->getParam('back') == "edit") { + $result->setPath('*/*/edit', ['id' => $queryId]); + } + } catch (\Exception $e) { + $this->messageManager->addError(__('Unable to save positions.')); + $result->setPath('*/*/edit', ['id' => $queryId]); + } + + return $result; + } +} diff --git a/src/module-elasticsuite-catalog/Model/Product/Indexer/Fulltext/Datasource/SearchPositionData.php b/src/module-elasticsuite-catalog/Model/Product/Indexer/Fulltext/Datasource/SearchPositionData.php new file mode 100644 index 000000000..ebd514866 --- /dev/null +++ b/src/module-elasticsuite-catalog/Model/Product/Indexer/Fulltext/Datasource/SearchPositionData.php @@ -0,0 +1,61 @@ + + * @copyright 2018 Smile + * @license Open Software License ("OSL") v. 3.0 + */ + +namespace Smile\ElasticsuiteCatalog\Model\Product\Indexer\Fulltext\Datasource; + +use Smile\ElasticsuiteCore\Api\Index\DatasourceInterface; +use Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Search\Position as ResourceModel; + +/** + * Datasource used to append manual search positions to product data. + * + * @category Smile + * @package Smile\ElasticsuiteCatalog + * @author Aurelien FOUCRET + */ +class SearchPositionData implements DatasourceInterface +{ + /** + * @var ResourceModel + */ + private $resourceModel; + + /** + * Constructor. + * + * @param ResourceModel $resourceModel Resource model. + */ + public function __construct(ResourceModel $resourceModel) + { + $this->resourceModel = $resourceModel; + } + + /** + * {@inheritDoc} + */ + public function addData($storeId, array $indexData) + { + $productIds = array_keys($indexData); + $searchPositions = $this->resourceModel->getByProductIds($productIds, $storeId); + + foreach ($searchPositions as $currentPosition) { + $indexData[(int) $currentPosition['product_id']]['search_query'][] = [ + 'query_id' => (int) $currentPosition['query_id'], + 'position' => (int) $currentPosition['position'], + ]; + } + + return $indexData; + } +} diff --git a/src/module-elasticsuite-catalog/Model/ProductSorter/AbstractPreview.php b/src/module-elasticsuite-catalog/Model/ProductSorter/AbstractPreview.php new file mode 100644 index 000000000..9ef807fe7 --- /dev/null +++ b/src/module-elasticsuite-catalog/Model/ProductSorter/AbstractPreview.php @@ -0,0 +1,185 @@ + + * @copyright 2018 Smile + * @license Open Software License ("OSL") v. 3.0 + */ +namespace Smile\ElasticsuiteCatalog\Model\ProductSorter; + +use \Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Fulltext\CollectionFactory as ProductCollectionFactory; +use Smile\ElasticsuiteCore\Search\Request\Query\QueryFactory; +use Smile\ElasticsuiteCore\Search\Request\QueryInterface; + +/** + * Product sorter preview. + * + * @category Smile + * @package Smile\ElasticsuiteCatalog + * @author Aurelien FOUCRET + */ +abstract class AbstractPreview implements PreviewInterface +{ + /** + * @var ProductCollectionFactory + */ + private $collectionFactory; + + /** + * @var ItemDataFactory + */ + private $itemFactory; + + /** + * @var integer + */ + private $size; + + /** + * @var integer + */ + private $storeId; + + /** + * @var QueryFactory + */ + private $queryFactory; + + /** + * Constructor. + * + * @param ProductCollectionFactory $collectionFactory Product collection factory. + * @param ItemDataFactory $itemFactory Preview item factory. + * @param QueryFactory $queryFactory ES query factory. + * @param unknown $storeId Store id. + * @param number $size Preview size. + */ + public function __construct( + ProductCollectionFactory $collectionFactory, + ItemDataFactory $itemFactory, + QueryFactory $queryFactory, + $storeId, + $size = 10 + ) { + $this->collectionFactory = $collectionFactory; + $this->itemFactory = $itemFactory; + $this->queryFactory = $queryFactory; + $this->storeId = $storeId; + $this->size = $size; + } + + /** + * {@inheritDoc} + */ + public function getData() + { + $data = $this->getUnsortedProductData(); + + $sortedProducts = $this->getSortedProducts(); + $data['products'] = $this->preparePreviewItems(array_merge($sortedProducts, $data['products'])); + + return $data; + } + + /** + * Apply custom logic to product collection. + * + * @param \Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Fulltext\Collection $collection Product collection. + * + * @return \Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Fulltext\Collection + */ + protected function prepareProductCollection(\Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Fulltext\Collection $collection) + { + return $collection; + } + + /** + * List of sorted product ids. + * + * @return array + */ + abstract protected function getSortedProductIds(); + + /** + * Convert an array of products to an array of preview items. + * + * @param \Magento\Catalog\Model\ResourceModel\Product[] $products Product list. + * + * @return Preview\Item[] + */ + private function preparePreviewItems($products = []) + { + $items = []; + + foreach ($products as $product) { + $items[$product->getId()] = $this->itemFactory->getData($product); + } + + return array_values($items); + } + + /** + * Preview base product collection. + * + * @return \Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Fulltext\Collection + */ + private function getProductCollection() + { + $productCollection = $this->collectionFactory->create(); + + $productCollection->setStoreId($this->storeId) + ->addAttributeToSelect(['name', 'small_image']); + + return $this->prepareProductCollection($productCollection); + } + + /** + * Return a collection with with products that match the current preview. + * + * @return array + */ + private function getUnsortedProductData() + { + $productCollection = $this->getProductCollection()->setPageSize($this->size); + + return ['products' => $productCollection->getItems(), 'size' => $productCollection->getSize()]; + } + + /** + * Return a collection with all products manually sorted loaded. + * + * @return \Magento\Catalog\Api\Data\ProductInterface[] + */ + private function getSortedProducts() + { + $products = []; + $productIds = $this->getSortedProductIds(); + + if ($productIds && count($productIds)) { + $productCollection = $this->getProductCollection()->setPageSize(count($productIds)); + + $idFilterParams = ['values' => $productIds, 'field' => 'entity_id']; + $idFilter = $this->queryFactory->create(QueryInterface::TYPE_TERMS, $idFilterParams); + $productCollection->addQueryFilter($idFilter); + + $products = $productCollection->getItems(); + } + + $sortedProducts = []; + + foreach ($this->getSortedProductIds() as $productId) { + if (isset($products[$productId])) { + $sortedProducts[$productId] = $products[$productId]; + } + } + + return $sortedProducts; + } +} diff --git a/src/module-elasticsuite-virtual-category/Model/Preview/Item.php b/src/module-elasticsuite-catalog/Model/ProductSorter/ItemDataFactory.php similarity index 54% rename from src/module-elasticsuite-virtual-category/Model/Preview/Item.php rename to src/module-elasticsuite-catalog/Model/ProductSorter/ItemDataFactory.php index 5944916f1..eec071cd9 100644 --- a/src/module-elasticsuite-virtual-category/Model/Preview/Item.php +++ b/src/module-elasticsuite-catalog/Model/ProductSorter/ItemDataFactory.php @@ -7,33 +7,27 @@ * * * @category Smile - * @package Smile\ElasticsuiteVirtualCategory + * @package Smile\ElasticsuiteCatalog * @author Aurelien FOUCRET - * @copyright 2016 Smile + * @copyright 2018 Smile * @license Open Software License ("OSL") v. 3.0 */ -namespace Smile\ElasticsuiteVirtualCategory\Model\Preview; +namespace Smile\ElasticsuiteCatalog\Model\ProductSorter; use Magento\Catalog\Api\Data\ProductInterface; -use Magento\Catalog\Helper\Product as ProductHelper; use Magento\Customer\Api\Data\GroupInterface; use Magento\Catalog\Helper\Image as ImageHelper; /** - * Virtual category preview item model. + * Product sorter item model. * * @category Smile - * @package Smile\ElasticsuiteVirtualCategory + * @package Smile\ElasticsuiteCatalog * @author Aurelien FOUCRET */ -class Item +class ItemDataFactory { - /** - * @var ProductInterface - */ - private $product; - /** * @var ImageHelper */ @@ -42,30 +36,30 @@ class Item /** * Constructor. * - * @param ProductInterface $product Item product. - * @param ImageHelper $imageHelper Image helper. + * @param ImageHelper $imageHelper Image helper. */ - public function __construct(ProductInterface $product, ImageHelper $imageHelper) + public function __construct(ImageHelper $imageHelper) { - $this->product = $product; $this->imageHelper = $imageHelper; } /** * Item data. * + * @param ProductInterface $product Product. + * * @return array */ - public function getData() + public function getData(ProductInterface $product) { $productItemData = [ - 'id' => $this->product->getId(), - 'sku' => $this->product->getSku(), - 'name' => $this->product->getName(), - 'price' => $this->getProductPrice(), - 'image' => $this->getImageUrl($this->product), - 'score' => $this->product->getDocumentScore(), - 'is_in_stock' => $this->isInStockProduct(), + 'id' => $product->getId(), + 'sku' => $product->getSku(), + 'name' => $product->getName(), + 'price' => $this->getProductPrice($product), + 'image' => $this->getImageUrl($product), + 'score' => $product->getDocumentScore(), + 'is_in_stock' => $this->isInStockProduct($product), ]; return $productItemData; @@ -74,12 +68,14 @@ public function getData() /** * Returns current product sale price. * + * @param ProductInterface $product Product. + * * @return float */ - private function getProductPrice() + private function getProductPrice(ProductInterface $product) { $price = 0; - $document = $this->getDocumentSource(); + $document = $this->getDocumentSource($product); if (isset($document['price'])) { foreach ($document['price'] as $currentPrice) { @@ -95,12 +91,14 @@ private function getProductPrice() /** * Returns current product stock status. * + * @param ProductInterface $product Product. + * * @return bool */ - private function isInStockProduct() + private function isInStockProduct(ProductInterface $product) { $isInStock = false; - $document = $this->getDocumentSource(); + $document = $this->getDocumentSource($product); if (isset($document['stock']['is_in_stock'])) { $isInStock = (bool) $document['stock']['is_in_stock']; } @@ -111,13 +109,13 @@ private function isInStockProduct() /** * Get resized image URL. * - * @param ProductInterface $product Current product. + * @param ProductInterface $product Product. * * @return string */ - private function getImageUrl($product) + private function getImageUrl(ProductInterface $product) { - $this->imageHelper->init($product, 'smile_elasticsuitevirtualcategory_preview'); + $this->imageHelper->init($product, 'smile_elasticsuite_product_sorter_image'); return $this->imageHelper->getUrl(); } @@ -125,10 +123,12 @@ private function getImageUrl($product) /** * Return the ES source document for the current product. * + * @param ProductInterface $product Product. + * * @return array */ - private function getDocumentSource() + private function getDocumentSource(ProductInterface $product) { - return $this->product->getDocumentSource() ? : []; + return $product->getDocumentSource() ? : []; } } diff --git a/src/module-elasticsuite-catalog/Model/ProductSorter/PreviewInterface.php b/src/module-elasticsuite-catalog/Model/ProductSorter/PreviewInterface.php new file mode 100644 index 000000000..94bbb97b8 --- /dev/null +++ b/src/module-elasticsuite-catalog/Model/ProductSorter/PreviewInterface.php @@ -0,0 +1,33 @@ + + * @copyright 2018 Smile + * @license Open Software License ("OSL") v. 3.0 + */ + +namespace Smile\ElasticsuiteCatalog\Model\ProductSorter; + +/** + * Product sorter preview interface. + * + * @category Smile + * @package Smile\ElasticsuiteCatalog + * @author Aurelien FOUCRET + */ +interface PreviewInterface +{ + /** + * Return sorted product data. + * + * @return array + */ + public function getData(); +} diff --git a/src/module-elasticsuite-catalog/Model/ResourceModel/Product/Search/Position.php b/src/module-elasticsuite-catalog/Model/ResourceModel/Product/Search/Position.php new file mode 100644 index 000000000..ed5b01e9f --- /dev/null +++ b/src/module-elasticsuite-catalog/Model/ResourceModel/Product/Search/Position.php @@ -0,0 +1,169 @@ + + * @copyright 2018 Smile + * @license Open Software License ("OSL") v. 3.0 + */ +namespace Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Search; + +use Magento\Search\Model\Query; +use Magento\Framework\Model\ResourceModel\Db\AbstractDb; + +/** + * Product search position resource model. + * + * @category Smile + * @package Smile\ElasticsuiteCatalog + * @author Aurelien FOUCRET + */ +class Position extends \Magento\Framework\Model\ResourceModel\Db\AbstractDb +{ + /** + * @var string + */ + const TABLE_NAME = 'smile_elasticsuitecatalog_search_query_product_position'; + + /** + * @var \Magento\Framework\Indexer\IndexerRegistry + */ + private $indexerRegistry; + + /** + * Constructor. + * + * @param \Magento\Framework\Model\ResourceModel\Db\Context $context Context. + * @param \Magento\Framework\Indexer\IndexerRegistry $indexerRegistry Indexer registry. + * @param string $connectionName Connection name. + */ + public function __construct( + \Magento\Framework\Model\ResourceModel\Db\Context $context, + \Magento\Framework\Indexer\IndexerRegistry $indexerRegistry, + $connectionName = null + ) { + $this->indexerRegistry = $indexerRegistry; + parent::__construct($context, $connectionName); + } + + /** + * Get query position for a product list. + * + * @param array $productIds Product ids. + * @param int $storeId Store ids. + * + * @return array + */ + public function getByProductIds(array $productIds, $storeId) + { + $select = $this->getBaseSelect() + ->joinInner($this->getTable('search_query'), 'main_table.query_id = search_query.query_id', []) + ->where('product_id IN(?)', $productIds) + ->where('store_id = ?', $storeId) + ->columns(['product_id', 'query_id', 'position']); + + return $this->getConnection()->fetchAll($select); + } + + /** + * Load product positions for the given category. + * + * @param Query|int $query Query. + * + * @return array + */ + public function getProductPositionsByQuery($query) + { + if (is_object($query)) { + $query = $query->getId(); + } + + $select = $this->getBaseSelect() + ->where('query_id = ?', (int) $query) + ->columns(['product_id', 'position']) + ->order('position'); + + return $this->getConnection()->fetchPairs($select); + } + + /** + * Save the product positions. + * + * @param int $queryId Query id. + * @param array $newProductPositions Product positions. + * + * @return \Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Search\Position + */ + public function saveProductPositions($queryId, $newProductPositions) + { + $reindexedProductIds = array_merge( + array_keys($newProductPositions), + array_keys($this->getProductPositionsByQuery($queryId)) + ); + + $deleteConditions = [ + $this->getConnection()->quoteInto('query_id = ?', (int) $queryId), + ]; + + if (!empty($newProductPositions)) { + $insertData = []; + + foreach ($newProductPositions as $productId => $position) { + $insertData[] = [ + 'query_id' => $queryId, + 'product_id' => $productId, + 'position' => $position, + ]; + } + + $deleteConditions[] = $this->getConnection()->quoteInto( + 'product_id NOT IN (?)', + array_keys($newProductPositions) + ); + $this->getConnection()->insertOnDuplicate($this->getMainTable(), $insertData, array_keys(current($insertData))); + } + + $this->getConnection()->delete($this->getMainTable(), implode(' AND ', $deleteConditions)); + $this->reindex(array_unique($reindexedProductIds)); + + return $this; + } + + /** + * @SuppressWarnings(PHPMD.CamelCaseMethodName) + * {@inheritDoc} + */ + protected function _construct() + { + $this->_setMainTable(self::TABLE_NAME); + } + + /** + * Reindex product on position change. + * + * @param array $productIds Product ids to be reindexed. + * + * @return void + */ + private function reindex($productIds) + { + $this->indexerRegistry->get(\Magento\CatalogSearch\Model\Indexer\Fulltext::INDEXER_ID)->reindexList($productIds); + } + + /** + * Init a base select with the main table. + * + * @return \Zend_Db_Select + */ + private function getBaseSelect() + { + $select = $this->getConnection()->select(); + $select->from(['main_table' => $this->getMainTable()], []); + + return $select; + } +} diff --git a/src/module-elasticsuite-catalog/Model/Search/Preview.php b/src/module-elasticsuite-catalog/Model/Search/Preview.php new file mode 100644 index 000000000..7ca65f723 --- /dev/null +++ b/src/module-elasticsuite-catalog/Model/Search/Preview.php @@ -0,0 +1,85 @@ + + * @copyright 2018 Smile + * @license Open Software License ("OSL") v. 3.0 + */ + +namespace Smile\ElasticsuiteCatalog\Model\Search; + +use Magento\Search\Model\QueryInterface; +use Magento\Catalog\Model\Product\Visibility; +use Smile\ElasticsuiteCore\Search\Request\Query\QueryFactory; +use Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Fulltext\CollectionFactory as FulltextCollectionFactory; +use Smile\ElasticsuiteCatalog\Model\ProductSorter\ItemDataFactory; +use Smile\ElasticsuiteCatalog\Model\ProductSorter\AbstractPreview; + +/** + * Search result preview model. + * + * @category Smile + * @package Smile\ElasticsuiteCatalog + * @author Aurelien FOUCRET + */ +class Preview extends \Smile\ElasticsuiteCatalog\Model\ProductSorter\AbstractPreview +{ + /** + * @var QueryInterface + */ + private $searchQuery; + + /** + * @var QueryFactory + */ + private $queryFactory; + + /** + * Constructor. + * + * @param QueryInterface $searchQuery Search query to preview. + * @param FulltextCollectionFactory $productCollectionFactory Fulltext product collection factory. + * @param ItemDataFactory $previewItemFactory Preview item factory. + * @param QueryFactory $queryFactory ES query factory. + * @param int $size Preview size. + */ + public function __construct( + QueryInterface $searchQuery, + FulltextCollectionFactory $productCollectionFactory, + ItemDataFactory $previewItemFactory, + QueryFactory $queryFactory, + $size = 10 + ) { + parent::__construct($productCollectionFactory, $previewItemFactory, $queryFactory, $searchQuery->getStoreId(), $size); + $this->searchQuery = $searchQuery; + $this->queryFactory = $queryFactory; + } + + /** + * {@inheritDoc} + */ + protected function prepareProductCollection(\Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Fulltext\Collection $collection) + { + $collection->setVisibility([Visibility::VISIBILITY_IN_SEARCH, Visibility::VISIBILITY_BOTH]); + $collection->addSearchFilter($this->searchQuery->getQueryText()); + + return $collection; + } + + /** + * Return the list of sorted product ids. + * + * @return array + */ + protected function getSortedProductIds() + { + return $this->searchQuery->getSortedProductIds(); + } +} diff --git a/src/module-elasticsuite-catalog/Plugin/LayerPlugin.php b/src/module-elasticsuite-catalog/Plugin/LayerPlugin.php index 3b092813a..79774fbc4 100644 --- a/src/module-elasticsuite-catalog/Plugin/LayerPlugin.php +++ b/src/module-elasticsuite-catalog/Plugin/LayerPlugin.php @@ -93,7 +93,11 @@ private function setSortParams( if (!$searchQuery->getQueryText() && $layer->getCurrentCategory()) { $categoryId = $layer->getCurrentCategory()->getId(); - $collection->addSortFilterParameters('position', 'category.position', 'category', ['category.category_id' => $categoryId]); + $sortFilter = ['category.category_id' => $categoryId]; + $collection->addSortFilterParameters('position', 'category.position', 'category', $sortFilter); + } elseif ($searchQuery->getId()) { + $sortFilter = ['search_query.query_id' => $searchQuery->getId()]; + $collection->addSortFilterParameters('relevance', 'search_query.position', 'search_query', $sortFilter); } foreach ($this->catalogConfig->getAttributesUsedForSortBy() as $attributeCode => $attribute) { diff --git a/src/module-elasticsuite-catalog/Setup/CatalogSetup.php b/src/module-elasticsuite-catalog/Setup/CatalogSetup.php index f45474b34..497da0be2 100644 --- a/src/module-elasticsuite-catalog/Setup/CatalogSetup.php +++ b/src/module-elasticsuite-catalog/Setup/CatalogSetup.php @@ -415,6 +415,59 @@ public function createCategoryFacetConfigurationTable(SchemaSetupInterface $setu $setup->getConnection()->createTable($table); } + /** + * Create table 'smile_elasticsuitecatalog_search_query_product_position'. + * + * @param SchemaSetupInterface $setup Setup. + * + * @return void + */ + public function createSearchPositionTable(SchemaSetupInterface $setup) + { + $tableName = 'smile_elasticsuitecatalog_search_query_product_position'; + $table = $setup->getConnection() + ->newTable($setup->getTable($tableName)) + ->addColumn( + 'query_id', + \Magento\Framework\DB\Ddl\Table::TYPE_INTEGER, + null, + ['unsigned' => true, 'nullable' => false, 'primary' => true, 'default' => '0'], + 'Query ID' + ) + ->addColumn( + 'product_id', + \Magento\Framework\DB\Ddl\Table::TYPE_INTEGER, + null, + ['unsigned' => true, 'nullable' => false, 'primary' => true, 'default' => '0'], + 'Product ID' + ) + ->addColumn( + 'position', + \Magento\Framework\DB\Ddl\Table::TYPE_INTEGER, + null, + ['nullable' => false, 'default' => '0'], + 'Position' + ) + ->addIndex($setup->getIdxName($tableName, ['product_id']), ['product_id']) + ->addForeignKey( + $setup->getFkName($tableName, 'query_id', 'search_query', 'query_id'), + 'query_id', + $setup->getTable('search_query'), + 'query_id', + \Magento\Framework\DB\Ddl\Table::ACTION_CASCADE + ) + ->addForeignKey( + $setup->getFkName($tableName, 'product_id', 'catalog_product_entity', 'entity_id'), + 'product_id', + $setup->getTable('catalog_product_entity'), + 'entity_id', + \Magento\Framework\DB\Ddl\Table::ACTION_CASCADE + ) + ->setComment('Catalog product position in search queries'); + + $setup->getConnection()->createTable($table); + } + /** * Update attribute value for an entity with a default value. * All existing values are erased by the new value. diff --git a/src/module-elasticsuite-catalog/Setup/InstallSchema.php b/src/module-elasticsuite-catalog/Setup/InstallSchema.php index 85b59298d..c0c394319 100644 --- a/src/module-elasticsuite-catalog/Setup/InstallSchema.php +++ b/src/module-elasticsuite-catalog/Setup/InstallSchema.php @@ -65,6 +65,9 @@ public function install(SchemaSetupInterface $setup, ModuleContextInterface $con // Introduced in version 1.3.0. $this->catalogSetup->createCategoryFacetConfigurationTable($setup); + // Introduced in version 1.4.0. + $this->catalogSetup->createSearchPositionTable($setup); + $setup->endSetup(); } } diff --git a/src/module-elasticsuite-catalog/Setup/UpgradeSchema.php b/src/module-elasticsuite-catalog/Setup/UpgradeSchema.php index 6c23f0bc8..3c24f78c6 100644 --- a/src/module-elasticsuite-catalog/Setup/UpgradeSchema.php +++ b/src/module-elasticsuite-catalog/Setup/UpgradeSchema.php @@ -66,6 +66,10 @@ public function upgrade( $this->catalogSetup->createCategoryFacetConfigurationTable($setup); } + if (version_compare($context->getVersion(), '1.4.0', '<')) { + $this->catalogSetup->createSearchPositionTable($setup); + } + $setup->endSetup(); } } diff --git a/src/module-elasticsuite-catalog/Ui/Component/Search/Term/Preview/DataProvider.php b/src/module-elasticsuite-catalog/Ui/Component/Search/Term/Preview/DataProvider.php new file mode 100644 index 000000000..270758dc7 --- /dev/null +++ b/src/module-elasticsuite-catalog/Ui/Component/Search/Term/Preview/DataProvider.php @@ -0,0 +1,112 @@ + + * @copyright 2018 Smile + * @license Open Software License ("OSL") v. 3.0 + */ +namespace Smile\ElasticsuiteCatalog\Ui\Component\Search\Term\Preview; + +use Magento\Ui\DataProvider\AbstractDataProvider; + +/** + * Search merchandiser form dataprovider. + * + * @category Smile + * @package Smile\ElasticsuiteCatalog + * @author Aurelien FOUCRET + */ +class DataProvider extends AbstractDataProvider +{ + /** + * @var \Magento\Framework\Locale\FormatInterface + */ + private $localeFormat; + + /** + * @var \Magento\Backend\Model\UrlInterface + */ + private $urlBuilder; + + /** + * @var \Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Search\Position + */ + private $resourceModel; + + /** + * Constructor. + * + * @param string $name Component Name + * @param string $primaryFieldName Primary Field Name + * @param string $requestFieldName Request Field Name. + * @param \Magento\Search\Model\ResourceModel\Query\CollectionFactory $collectionFactory Query collection factory. + * @param \Magento\Framework\Locale\FormatInterface $localeFormat Locale formatter. + * @param \Magento\Backend\Model\UrlInterface $urlBuilder URL builder. + * @param \Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Search\Position $resourceModel Resource model for search position. + * @param array $meta Init meta. + * @param array $data Init data. + */ + public function __construct( + $name, + $primaryFieldName, + $requestFieldName, + \Magento\Search\Model\ResourceModel\Query\CollectionFactory $collectionFactory, + \Magento\Framework\Locale\FormatInterface $localeFormat, + \Magento\Backend\Model\UrlInterface $urlBuilder, + \Smile\ElasticsuiteCatalog\Model\ResourceModel\Product\Search\Position $resourceModel, + array $meta = [], + array $data = [] + ) { + $this->collection = $collectionFactory->create(); + $this->localeFormat = $localeFormat; + $this->urlBuilder = $urlBuilder; + $this->resourceModel = $resourceModel; + parent::__construct($name, $primaryFieldName, $requestFieldName, $meta, $data); + } + + /** + * {@inheritDoc} + */ + public function getData() + { + $data = parent::getData(); + $items = []; + + foreach ($data['items'] as $currentItem) { + $queryId = $currentItem[$this->primaryFieldName]; + $currentItem['sorted_products'] = json_encode($this->getSortedProducts($queryId), JSON_FORCE_OBJECT); + $currentItem['price_format'] = $this->localeFormat->getPriceFormat(); + $currentItem['product_sorter_load_url'] = $this->getProductSorterLoadUrl(); + $items[$queryId] = $currentItem; + } + + return $items; + } + + /** + * Return list of sorted products for the query. + * + * @param int $queryId Query id. + * + * @return array + */ + private function getSortedProducts($queryId) + { + return $this->resourceModel->getProductPositionsByQuery($queryId); + } + + /** + * Return product sorter load URL. + * + * @return string + */ + private function getProductSorterLoadUrl() + { + return $this->urlBuilder->getUrl('search/term_merchandiser/load', ['ajax' => true]); + } +} diff --git a/src/module-elasticsuite-catalog/etc/adminhtml/routes.xml b/src/module-elasticsuite-catalog/etc/adminhtml/routes.xml new file mode 100644 index 000000000..7b6302180 --- /dev/null +++ b/src/module-elasticsuite-catalog/etc/adminhtml/routes.xml @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/src/module-elasticsuite-catalog/etc/elasticsuite_indices.xml b/src/module-elasticsuite-catalog/etc/elasticsuite_indices.xml index b9f504b6e..11f8afadb 100644 --- a/src/module-elasticsuite-catalog/etc/elasticsuite_indices.xml +++ b/src/module-elasticsuite-catalog/etc/elasticsuite_indices.xml @@ -26,6 +26,7 @@ Smile\ElasticsuiteCatalog\Model\Product\Indexer\Fulltext\Datasource\CategoryData Smile\ElasticsuiteCatalog\Model\Product\Indexer\Fulltext\Datasource\AttributeData Smile\ElasticsuiteCatalog\Model\Product\Indexer\Fulltext\Datasource\InventoryData + Smile\ElasticsuiteCatalog\Model\Product\Indexer\Fulltext\Datasource\SearchPositionData @@ -66,6 +67,10 @@ 0 + + + + diff --git a/src/module-elasticsuite-catalog/etc/module.xml b/src/module-elasticsuite-catalog/etc/module.xml index 21325b5a1..846a7d93c 100644 --- a/src/module-elasticsuite-catalog/etc/module.xml +++ b/src/module-elasticsuite-catalog/etc/module.xml @@ -17,7 +17,7 @@ */ --> - + diff --git a/src/module-elasticsuite-catalog/etc/view.xml b/src/module-elasticsuite-catalog/etc/view.xml index f02da5323..9266e2704 100644 --- a/src/module-elasticsuite-catalog/etc/view.xml +++ b/src/module-elasticsuite-catalog/etc/view.xml @@ -23,6 +23,10 @@ 45 45 + + 150 + 150 + diff --git a/src/module-elasticsuite-catalog/view/adminhtml/layout/search_term_edit.xml b/src/module-elasticsuite-catalog/view/adminhtml/layout/search_term_edit.xml new file mode 100644 index 000000000..48944311f --- /dev/null +++ b/src/module-elasticsuite-catalog/view/adminhtml/layout/search_term_edit.xml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/src/module-elasticsuite-catalog/view/adminhtml/layout/search_term_merchandiser_edit.xml b/src/module-elasticsuite-catalog/view/adminhtml/layout/search_term_merchandiser_edit.xml new file mode 100644 index 000000000..b2188138f --- /dev/null +++ b/src/module-elasticsuite-catalog/view/adminhtml/layout/search_term_merchandiser_edit.xml @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/src/module-elasticsuite-catalog/view/adminhtml/layout/search_term_report.xml b/src/module-elasticsuite-catalog/view/adminhtml/layout/search_term_report.xml index f5f27c329..3276267ed 100644 --- a/src/module-elasticsuite-catalog/view/adminhtml/layout/search_term_report.xml +++ b/src/module-elasticsuite-catalog/view/adminhtml/layout/search_term_report.xml @@ -1,5 +1,19 @@ - + diff --git a/src/module-elasticsuite-catalog/view/adminhtml/ui_component/search_term_preview.xml b/src/module-elasticsuite-catalog/view/adminhtml/ui_component/search_term_preview.xml new file mode 100644 index 000000000..c1032e5ac --- /dev/null +++ b/src/module-elasticsuite-catalog/view/adminhtml/ui_component/search_term_preview.xml @@ -0,0 +1,95 @@ + + +
+ + + + search_term_preview.search_term_preview_data_source + + + templates/form/collapsible + + + + +