diff --git a/.circleci/config.yml b/.circleci/config.yml index f96c2887e..8f33d1cb4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -33,10 +33,19 @@ shared: &shared command: | mv ~/build_directory/algoliasearch-magento-2/dev/tests/install-config-mysql.php ~/magento_directory/dev/tests/integration/etc/install-config-mysql.php + - run: + name: Before setup + command: | + wget https://alg.li/algolia-keys && chmod +x algolia-keys + - run: name: Run tests command: | cd ~/magento_directory/dev/tests/integration + export CI_BUILD_NUM=$CIRCLE_BUILD_NUM + export CI_PROJ_USERNAME=$CIRCLE_PROJECT_USERNAME + export CI_PROJ_REPONAME=$CIRCLE_PROJECT_REPONAME + eval $(~/build_directory/algoliasearch-magento-2/algolia-keys export) php -dmemory_limit=-1 ../../../vendor/bin/phpunit ../../../vendor/algolia/algoliasearch-magento-2/Test jobs: @@ -55,10 +64,33 @@ jobs: docker: - image: algolia/magento2-circleci:2.3.0 + "phpcompatibility": + docker: # run the steps with Docker with PHP 7.2 + - image: circleci/php:7.2 + steps: + - checkout + - run: sudo composer self-update + - run: composer config http-basic.repo.magento.com ${MAGENTO_AUTH_USERNAME} ${MAGENTO_AUTH_PASSWORD} + - restore_cache: + keys: + - composer-v1-{{ checksum "composer.lock" }} + - composer-v1- + - run: composer install -n --prefer-dist --ignore-platform-reqs --no-progress + - save_cache: + key: composer-v1-{{ checksum "composer.lock" }} + paths: + - vendor + - run: + name: PHPCS PHPCompatibility + command: | + ./vendor/bin/phpcs --config-set installed_paths vendor/phpcompatibility/php-compatibility/PHPCompatibility/ + # vendor/ is large and seems to break phpcs, ls+grep+xargs hack is used to send folders to phpcs + ls -d */ | grep -vE 'dev|Test|vendor' | xargs ./vendor/bin/phpcs -p --standard=PHPCompatibility --runtime-set testVersion 7.1- --runtime-set ignore_warnings_on_exit true + workflows: version: 2 build: jobs: - - "magento-2.1" - "magento-2.2" - "magento-2.3" + - "phpcompatibility" diff --git a/.gitignore b/.gitignore index 02e8b44fc..6731d97ec 100755 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ dev/frontend/node_modules/ dev/bin/auth.json composer.lock vendor/ +.php_cs.cache diff --git a/Adapter/Aggregation/Builder.php b/Adapter/Aggregation/Builder.php new file mode 100644 index 000000000..b2de9d7e1 --- /dev/null +++ b/Adapter/Aggregation/Builder.php @@ -0,0 +1,127 @@ +dataProviderContainer = $dataProviderContainer; + $this->aggregationContainer = $aggregationContainer; + $this->resource = $resource; + $this->aggregationResolver = $aggregationResolver; + $this->productFactory = $productFactory; + } + + public function build(RequestInterface $request, Table $documentsTable, array $documents, array $facets) + { + return $this->processAggregations($request, $documentsTable, $documents, $facets); + } + + private function processAggregations(RequestInterface $request, Table $documentsTable, $documents, $facets) + { + $aggregations = []; + $documentIds = $documents ? $this->extractDocumentIds($documents) : $this->getDocumentIds($documentsTable); + $buckets = $this->aggregationResolver->resolve($request, $documentIds); + $dataProvider = $this->dataProviderContainer->get($request->getIndex()); + + foreach ($buckets as $bucket) { + if (isset($facets[$bucket->getField()])) { + $aggregations[$bucket->getName()] = + $this->formatAggregation($bucket->getField(), $facets[$bucket->getField()]); + } else { + $aggregationBuilder = $this->aggregationContainer->get($bucket->getType()); + $aggregations[$bucket->getName()] = $aggregationBuilder->build( + $dataProvider, + $request->getDimensions(), + $bucket, + $documentsTable + ); + } + } + + return $aggregations; + } + + private function formatAggregation($attribute, $facetData) + { + $aggregation = []; + + foreach ($facetData as $value => $count) { + $optionId = $this->getOptionIdByLabel($attribute, $value); + $aggregation[$optionId] = [ + 'value' => (string) $optionId, + 'count' => (string) $count, + ]; + } + + return $aggregation; + } + + private function getOptionIdByLabel($attributeCode, $optionLabel) + { + $product = $this->productFactory->create(); + $isAttributeExist = $product->getResource()->getAttribute($attributeCode); + $optionId = ''; + if ($isAttributeExist && $isAttributeExist->usesSource()) { + $optionId = $isAttributeExist->getSource()->getOptionId($optionLabel); + } + + return $optionId; + } + + private function extractDocumentIds(array $documents) + { + return $documents ? array_keys($documents) : []; + } + + private function getDocumentIds(Table $documentsTable) + { + $select = $this->getConnection() + ->select() + ->from($documentsTable->getName(), TemporaryStorage::FIELD_ENTITY_ID); + + return $this->getConnection()->fetchCol($select); + } + + private function getConnection() + { + return $this->resource->getConnection(); + } +} diff --git a/Adapter/Algolia.php b/Adapter/Algolia.php index bf6856d58..0af2dd2fd 100755 --- a/Adapter/Algolia.php +++ b/Adapter/Algolia.php @@ -2,6 +2,7 @@ namespace Algolia\AlgoliaSearch\Adapter; +use Algolia\AlgoliaSearch\Adapter\Aggregation\Builder as AlgoliaAggregationBuilder; use Algolia\AlgoliaSearch\Helper\AdapterHelper; use AlgoliaSearch\AlgoliaConnectionException; use Magento\Framework\App\ResourceConnection; @@ -38,6 +39,9 @@ class Algolia implements AdapterInterface /** @var AdapterHelper */ private $adapterHelper; + /** @var AlgoliaAggregationBuilder */ + private $algoliaAggregationBuilder; + /** @var DocumentFactory */ private $documentFactory; @@ -53,6 +57,7 @@ class Algolia implements AdapterInterface * @param AggregationBuilder $aggregationBuilder * @param TemporaryStorageFactory $temporaryStorageFactory * @param AdapterHelper $adapterHelper + * @param AlgoliaAggregationBuilder $algoliaAggregationBuilder * @param DocumentFactory $documentFactory */ public function __construct( @@ -62,6 +67,7 @@ public function __construct( AggregationBuilder $aggregationBuilder, TemporaryStorageFactory $temporaryStorageFactory, AdapterHelper $adapterHelper, + AlgoliaAggregationBuilder $algoliaAggregationBuilder, DocumentFactory $documentFactory ) { $this->mapper = $mapper; @@ -70,6 +76,7 @@ public function __construct( $this->aggregationBuilder = $aggregationBuilder; $this->temporaryStorageFactory = $temporaryStorageFactory; $this->adapterHelper = $adapterHelper; + $this->algoliaAggregationBuilder = $algoliaAggregationBuilder; $this->documentFactory = $documentFactory; } @@ -93,11 +100,12 @@ public function query(RequestInterface $request) $documents = []; $totalHits = 0; $table = null; + $facetsFromAlgolia = null; try { // If instant search is on, do not make a search query unless SEO request is set to 'Yes' if (!$this->adapterHelper->isInstantEnabled() || $this->adapterHelper->makeSeoRequest()) { - list($documents, $totalHits) = $this->adapterHelper->getDocumentsFromAlgolia(); + list($documents, $totalHits, $facetsFromAlgolia) = $this->adapterHelper->getDocumentsFromAlgolia(); } $apiDocuments = array_map([$this, 'getApiDocument'], $documents); @@ -106,7 +114,8 @@ public function query(RequestInterface $request) return $this->nativeQuery($request); } - $aggregations = $this->aggregationBuilder->build($request, $table, $documents); + $aggregations = $this->algoliaAggregationBuilder->build($request, $table, $documents, $facetsFromAlgolia); + $response = [ 'documents' => $documents, 'aggregations' => $aggregations, @@ -167,6 +176,7 @@ private function getConnection() * Get rows size * * @param Select $query + * * @return int */ private function getSize(Select $query) @@ -185,6 +195,7 @@ private function getSize(Select $query) * Reset limit and offset * * @param Select $query + * * @return Select */ private function getSelectCountSql(Select $query) diff --git a/Block/Navigation.php b/Block/Navigation.php new file mode 100755 index 000000000..e2becd278 --- /dev/null +++ b/Block/Navigation.php @@ -0,0 +1,38 @@ +configHelper = $configHelper; + + if ($this->configHelper->isBackendRenderingEnabled()) { + $this->getLayout()->unsetElement('catalog.compare.sidebar'); + $this->getLayout()->unsetElement('wishlist_sidebar'); + } + } +} diff --git a/Block/Navigation/Renderer/CategoryRenderer.php b/Block/Navigation/Renderer/CategoryRenderer.php new file mode 100644 index 000000000..9d96d069e --- /dev/null +++ b/Block/Navigation/Renderer/CategoryRenderer.php @@ -0,0 +1,40 @@ +filter = $filter; + + if ($this->canRenderFilter()) { + $this->assign('filterItems', $filter->getItems()); + $html = $this->_toHtml(); + $this->assign('filterItems', []); + } + + return $html; + } + + /** + * {@inheritdoc} + */ + protected function canRenderFilter() + { + return true; + } +} diff --git a/Block/Navigation/Renderer/DefaultRenderer.php b/Block/Navigation/Renderer/DefaultRenderer.php new file mode 100644 index 000000000..f3c78c7ce --- /dev/null +++ b/Block/Navigation/Renderer/DefaultRenderer.php @@ -0,0 +1,143 @@ +configHelper = $configHelper; + } + + /** + * Returns true if checkox have to be enabled. + * + * @return bool + */ + public function isMultipleSelectEnabled() + { + return true; + } + + public function setIsSearchable($value) + { + $this->isSearchable = $value; + + return $this; + } + + public function getIsSearchable() + { + return $this->isSearchable; + } + + /** + * {@inheritdoc} + */ + public function render(FilterInterface $filter) + { + $html = ''; + $this->filter = $filter; + + if ($this->canRenderFilter()) { + $this->assign('filterItems', $filter->getItems()); + $html = $this->_toHtml(); + $this->assign('filterItems', []); + } + + return $html; + } + + /** + * {@inheritdoc} + */ + public function getJsLayout() + { + $filterItems = $this->getFilter()->getItems(); + $maxValuesPerFacet = (int) $this->configHelper->getMaxValuesPerFacet(); + + $jsLayoutConfig = [ + 'component' => self::JS_COMPONENT, + 'maxSize' => $maxValuesPerFacet, + 'displayProductCount' => true, + 'hasMoreItems' => (bool) $filterItems > $maxValuesPerFacet, + 'ajaxLoadUrl' => $this->getAjaxLoadUrl(), + 'displaySearch' => $this->getIsSearchable(), + ]; + + foreach ($filterItems as $item) { + $jsLayoutConfig['items'][] = [ + 'label' => $item->getLabel(), + 'count' => $item->getCount(), + 'url' => $item->getUrl(), + 'is_selected' => $item->getData('is_selected'), + ]; + } + + return json_encode($jsLayoutConfig); + } + + /** + * {@inheritdoc} + */ + protected function canRenderFilter() + { + return true; + } + + /** + * @return FilterInterface + */ + public function getFilter() + { + return $this->filter; + } + + /** + * Get the AJAX load URL (used by the show more and the search features). + * + * @return string + */ + private function getAjaxLoadUrl() + { + $qsParams = ['filterName' => $this->getFilter()->getRequestVar()]; + + $currentCategory = $this->getFilter()->getLayer()->getCurrentCategory(); + + if ($currentCategory && $currentCategory->getId() && $currentCategory->getLevel() > 1) { + $qsParams['cat'] = $currentCategory->getId(); + } + + $urlParams = ['_current' => true, '_query' => $qsParams]; + + return $this->_urlBuilder->getUrl('catalog/navigation_filter/ajax', $urlParams); + } +} diff --git a/Block/Navigation/Renderer/PriceRenderer.php b/Block/Navigation/Renderer/PriceRenderer.php new file mode 100644 index 000000000..5967fae24 --- /dev/null +++ b/Block/Navigation/Renderer/PriceRenderer.php @@ -0,0 +1,83 @@ +localeFormat->getPriceFormat(); + } + + /** + * {@inheritdoc} + */ + protected function getConfig() + { + $config = parent::getConfig(); + + if ($this->isManualCalculation() && ($this->getStepValue() > 0)) { + $config['step'] = $this->getStepValue(); + } + + if ($this->getFilter()->getCurrencyRate()) { + $config['rate'] = $this->getFilter()->getCurrencyRate(); + } + + return $config; + } + + /** @return int */ + protected function getMinValue() + { + $minValue = $this->getFilter()->getMinValue(); + + if ($this->isManualCalculation() && ($this->getStepValue() > 0)) { + $stepValue = $this->getStepValue(); + $minValue = floor($minValue / $stepValue) * $stepValue; + } + + return $minValue; + } + + /* @return int */ + protected function getMaxValue() + { + $maxValue = $this->getFilter()->getMaxValue(); + + if ($this->isManualCalculation() && ($this->getStepValue() > 0)) { + $stepValue = $this->getStepValue(); + $maxValue = ceil($maxValue / $stepValue) * $stepValue; + } + + return $maxValue; + } + + /* @return bool */ + private function isManualCalculation() + { + $calculation = $this->_scopeConfig->getValue(PriceDataProvider::XML_PATH_RANGE_CALCULATION, ScopeInterface::SCOPE_STORE); + if ($calculation === PriceDataProvider::RANGE_CALCULATION_MANUAL) { + return true; + } + + return false; + } + + /* @return int */ + private function getStepValue() + { + $value = $this->_scopeConfig->getValue(PriceDataProvider::XML_PATH_RANGE_STEP, ScopeInterface::SCOPE_STORE); + + return (int) $value; + } +} diff --git a/Block/Navigation/Renderer/SliderRenderer.php b/Block/Navigation/Renderer/SliderRenderer.php new file mode 100644 index 000000000..7781d4022 --- /dev/null +++ b/Block/Navigation/Renderer/SliderRenderer.php @@ -0,0 +1,160 @@ +jsonEncoder = $jsonEncoder; + $this->localeFormat = $localeFormat; + } + + public function render(FilterInterface $filter) + { + $html = ''; + $this->filter = $filter; + + if ($this->canRenderFilter()) { + $this->assign('filterItems', $filter->getItems()); + $html = $this->_toHtml(); + $this->assign('filterItems', []); + } + + return $html; + } + + public function getFilter() + { + return $this->filter; + } + + /** @return string */ + public function getJsonConfig() + { + $config = $this->getConfig(); + + return $this->jsonEncoder->encode($config); + } + + /** @return string */ + public function getDataRole() + { + $filter = $this->getFilter(); + + return $this->dataRole . '-' . $filter->getRequestVar(); + } + + /** + * {@inheritdoc} + */ + protected function canRenderFilter() + { + return true; + } + + /** @return array */ + protected function getFieldFormat() + { + $format = $this->localeFormat->getPriceFormat(); + + $attribute = $this->getFilter()->getAttributeModel(); + + $format['pattern'] = (string) $attribute->getDisplayPattern(); + $format['precision'] = (int) $attribute->getDisplayPrecision(); + $format['requiredPrecision'] = (int) $attribute->getDisplayPrecision(); + $format['integerRequired'] = (int) $attribute->getDisplayPrecision() > 0; + + return $format; + } + + /** @return array */ + protected function getConfig() + { + $config = [ + 'minValue' => $this->getMinValue(), + 'maxValue' => $this->getMaxValue(), + 'currentValue' => $this->getCurrentValue(), + 'fieldFormat' => $this->getFieldFormat(), + 'urlTemplate' => $this->getUrlTemplate(), + ]; + + return $config; + } + + /** @return int */ + protected function getMinValue() + { + return $this->getFilter()->getMinValue(); + } + + /** @return int */ + protected function getMaxValue() + { + return $this->getFilter()->getMaxValue(); + } + + /** @return array */ + private function getCurrentValue() + { + $currentValue = $this->getFilter()->getCurrentValue(); + + if (!is_array($currentValue)) { + $currentValue = []; + } + + if (!isset($currentValue['from']) || $currentValue['from'] === '') { + $currentValue['from'] = $this->getMinValue(); + } + + if (!isset($currentValue['to']) || $currentValue['to'] === '') { + $currentValue['to'] = $this->getMaxValue(); + } + + return $currentValue; + } + + /** @return string */ + private function getUrlTemplate() + { + $filter = $this->getFilter(); + $item = current($this->getFilter()->getItems()); + + $regexp = "/({$filter->getRequestVar()})=(-?[0-9A-Z\-\%]+)/"; + $replacement = '${1}=<%- from %>-<%- to %>'; + + return preg_replace($regexp, $replacement, $item->getUrl()); + } +} diff --git a/Controller/Adminhtml/System/Config/Save.php b/Controller/Adminhtml/System/Config/Save.php index e35d44af8..51196d6a1 100644 --- a/Controller/Adminhtml/System/Config/Save.php +++ b/Controller/Adminhtml/System/Config/Save.php @@ -16,15 +16,6 @@ protected function _getGroupsForSave() private function handleDeactivatedSerializedArrays($groups) { - if (isset($groups['instant']['fields']['is_instant_enabled']['value']) - && $groups['instant']['fields']['is_instant_enabled']['value'] == '0') { - foreach ($this->instantSerializedValues as $field) { - if (isset($groups['instant']['fields'][$field])) { - unset($groups['instant']['fields'][$field]); - } - } - } - if (isset($groups['autocomplete']['fields']['is_popup_enabled']['value']) && $groups['autocomplete']['fields']['is_popup_enabled']['value'] == '0') { foreach ($this->autocompleteSerializedValues as $field) { diff --git a/Factory/CatalogPermissionsFactory.php b/Factory/CatalogPermissionsFactory.php index afd045d54..4e0e89382 100644 --- a/Factory/CatalogPermissionsFactory.php +++ b/Factory/CatalogPermissionsFactory.php @@ -8,8 +8,6 @@ class CatalogPermissionsFactory { - const CATALOG_PERMISSIONS_ENABLED_CONFIG_PATH = 'catalog/magento_catalogpermissions/enabled'; - private $scopeConfig; private $moduleManager; private $objectManager; @@ -29,13 +27,8 @@ public function __construct( public function isCatalogPermissionsEnabled($storeId) { - $isEnabled = $this->scopeConfig->isSetFlag( - self::CATALOG_PERMISSIONS_ENABLED_CONFIG_PATH, - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - $storeId - ); - - return $isEnabled && $this->isCatalogPermissionsModuleEnabled(); + return $this->isCatalogPermissionsModuleEnabled() + && $this->getCatalogPermissionsConfig()->isEnabled($storeId); } private function isCatalogPermissionsModuleEnabled() @@ -53,6 +46,11 @@ public function getCatalogPermissionsHelper() return $this->objectManager->create('\Magento\CatalogPermissions\Helper\Data'); } + public function getCatalogPermissionsConfig() + { + return $this->objectManager->create('\Magento\CatalogPermissions\App\Config'); + } + public function getCategoryPermissionsCollection() { if (!$this->categoryPermissionsCollection) { diff --git a/Factory/SharedCatalogFactory.php b/Factory/SharedCatalogFactory.php index 16ba811e8..73eefe485 100644 --- a/Factory/SharedCatalogFactory.php +++ b/Factory/SharedCatalogFactory.php @@ -8,8 +8,6 @@ class SharedCatalogFactory { - const SHARED_CATALOG_ENABLED_CONFIG_PATH = 'btob/website_configuration/sharedcatalog_active'; - private $scopeConfig; private $moduleManager; private $objectManager; @@ -29,13 +27,11 @@ public function __construct( public function isSharedCatalogEnabled($storeId) { - $isEnabled = $this->scopeConfig->isSetFlag( - self::SHARED_CATALOG_ENABLED_CONFIG_PATH, - \Magento\Store\Model\ScopeInterface::SCOPE_STORE, - $storeId - ); - - return $isEnabled && $this->isSharedCatalogModuleEnabled(); + return $this->isSharedCatalogModuleEnabled() + && $this->getSharedCatalogConfig()->isActive( + \Magento\Store\Model\ScopeInterface::SCOPE_STORE, + $storeId + ); } private function isSharedCatalogModuleEnabled() @@ -58,6 +54,11 @@ public function getSharedCatalogResource() return $this->objectManager->create('\Magento\SharedCatalog\Model\ResourceModel\SharedCatalog'); } + public function getSharedCatalogConfig() + { + return $this->objectManager->create('\Magento\SharedCatalog\Model\Config'); + } + public function getSharedCategoryCollection() { if (!$this->sharedCategoryCollection) { diff --git a/Helper/Adapter/FiltersHelper.php b/Helper/Adapter/FiltersHelper.php index d85d2f609..986fdf5e5 100644 --- a/Helper/Adapter/FiltersHelper.php +++ b/Helper/Adapter/FiltersHelper.php @@ -70,14 +70,26 @@ public function getPaginationFilters() /** * Get the category filters from the context * + * @param int $storeId + * * @return array */ - public function getCategoryFilters() + public function getCategoryFilters($storeId) { $categoryFilter = []; + $categoryId = null; $category = $this->registry->registry('current_category'); + if ($category) { - $categoryFilter['facetFilters'][] = 'categoryIds:' . $category->getEntityId(); + $categoryId = $category->getEntityId(); + } + + if (!is_null($this->request->getParam('cat')) && $this->config->isBackendRenderingEnabled($storeId)) { + $categoryId = $this->request->getParam('cat'); + } + + if (!is_null($categoryId)) { + $categoryFilter['facetFilters'][] = 'categoryIds:' . $categoryId; } return $categoryFilter; @@ -134,7 +146,7 @@ public function getFacetFilters($storeId, $parameters = null) { $facetFilters = []; // If the parameters variable is null, fetch them from the request - if (is_null($parameters)) { + if ($parameters === null) { $parameters = $this->request->getParams(); } @@ -184,6 +196,31 @@ public function getFacetFilters($storeId, $parameters = null) return $facetFilters; } + /** + * Get the disjunctive facets + * + * @param int $storeId + * @param string[] $parameters + * + * @return array + */ + public function getDisjunctiveFacets($storeId, $parameters = null) + { + $disjunctiveFacets = []; + // If the parameters variable is null, fetch them from the request + if ($parameters === null) { + $parameters = $this->request->getParams(); + } + + foreach ($this->config->getFacets($storeId) as $facet) { + if (isset($parameters[$facet['attribute']]) && $facet['type'] == 'disjunctive') { + $disjunctiveFacets['disjunctiveFacets'][] = $facet['attribute']; + } + } + + return $disjunctiveFacets; + } + /** * Get the price filters from the url * diff --git a/Helper/AdapterHelper.php b/Helper/AdapterHelper.php index aba398158..e6fcc0332 100644 --- a/Helper/AdapterHelper.php +++ b/Helper/AdapterHelper.php @@ -8,6 +8,9 @@ class AdapterHelper { + const INSTANTSEARCH_ORDER_PARAM = 'sortBy'; + const BACKEND_ORDER_PARAM = 'product_list_order'; + /** @var CatalogSearchDataHelper */ private $catalogSearchHelper; @@ -50,7 +53,6 @@ public function getDocumentsFromAlgolia() $algoliaQuery = $query !== '__empty__' ? $query : ''; $searchParams = []; $targetedIndex = null; - if ($this->isReplaceCategory() || $this->isSearch() || $this->isLandingPage()) { $searchParams = $this->getSearchParams($storeId); @@ -63,14 +65,30 @@ public function getDocumentsFromAlgolia() $algoliaQuery = $this->filtersHelper->getLandingPageQuery(); } + $orderParam = $this->getOrderParam($storeId); if ($this->filtersHelper->getRequest()->getParam('sortBy') !== null) { - $targetedIndex = $this->filtersHelper->getRequest()->getParam('sortBy'); + $targetedIndex = $this->filtersHelper->getRequest()->getParam($orderParam); } } return $this->algoliaHelper->getSearchResult($algoliaQuery, $storeId, $searchParams, $targetedIndex); } + /** + * Get the sort order parameter + * + * @param int $storeId + * + * @return string + */ + private function getOrderParam($storeId) + { + return !$this->configHelper->isInstantEnabled($storeId) + && $this->configHelper->isBackendRenderingEnabled($storeId) ? + self::BACKEND_ORDER_PARAM : + self::INSTANTSEARCH_ORDER_PARAM; + } + /** * Get the search params from the url * @@ -92,7 +110,7 @@ private function getSearchParams($storeId) // Handle category context $searchParams = array_merge( $searchParams, - $this->filtersHelper->getCategoryFilters() + $this->filtersHelper->getCategoryFilters($storeId) ); // Handle facet filtering @@ -101,6 +119,12 @@ private function getSearchParams($storeId) $this->filtersHelper->getFacetFilters($storeId) ); + // Handle disjunctive facets + $searchParams = array_merge( + $searchParams, + $this->filtersHelper->getDisjunctiveFacets($storeId) + ); + // Handle price filtering $searchParams = array_merge( $searchParams, @@ -154,7 +178,8 @@ public function isReplaceCategory() return $this->filtersHelper->getRequest()->getControllerName() === 'category' && $this->configHelper->replaceCategories($storeId) === true - && $this->configHelper->isInstantEnabled($storeId) === true; + && ($this->configHelper->isInstantEnabled($storeId) === true + || $this->configHelper->isBackendRenderingEnabled($storeId) === true); } /** diff --git a/Helper/ConfigHelper.php b/Helper/ConfigHelper.php index df312a7a4..71efdadf4 100755 --- a/Helper/ConfigHelper.php +++ b/Helper/ConfigHelper.php @@ -31,6 +31,7 @@ class ConfigHelper const SHOW_SUGGESTIONS_NO_RESULTS = 'algoliasearch_instant/instant/show_suggestions_on_no_result_page'; const XML_ADD_TO_CART_ENABLE = 'algoliasearch_instant/instant/add_to_cart_enable'; const INFINITE_SCROLL_ENABLE = 'algoliasearch_instant/instant/infinite_scroll_enable'; + const BACKEND_RENDERING_ENABLE = 'algoliasearch_instant/instant/backend_rendering_enable'; const IS_POPUP_ENABLED = 'algoliasearch_autocomplete/autocomplete/is_popup_enabled'; const NB_OF_PRODUCTS_SUGGESTIONS = 'algoliasearch_autocomplete/autocomplete/nb_of_products_suggestions'; @@ -360,6 +361,11 @@ public function isInfiniteScrollEnabled($storeId = null) && $this->configInterface->isSetFlag(self::INFINITE_SCROLL_ENABLE, ScopeInterface::SCOPE_STORE, $storeId); } + public function isBackendRenderingEnabled($storeId = null) + { + return $this->configInterface->isSetFlag(self::BACKEND_RENDERING_ENABLE, ScopeInterface::SCOPE_STORE, $storeId); + } + public function isRemoveBranding($storeId = null) { return $this->configInterface->isSetFlag(self::REMOVE_BRANDING, ScopeInterface::SCOPE_STORE, $storeId); diff --git a/Helper/Data.php b/Helper/Data.php index c83b24292..4d1ba0e9c 100755 --- a/Helper/Data.php +++ b/Helper/Data.php @@ -105,6 +105,11 @@ public function getSearchResult($query, $storeId, $searchParams = null, $targete $numberOfResults = min($this->configHelper->getNumberOfProductResults($storeId), 1000); } + $facetsToRetrieve = []; + foreach ($this->configHelper->getFacets($storeId) as $facet) { + $facetsToRetrieve[] = $facet['attribute']; + } + $params = [ 'hitsPerPage' => $numberOfResults, // retrieve all the hits (hard limit is 1000) 'attributesToRetrieve' => 'objectID', @@ -113,6 +118,8 @@ public function getSearchResult($query, $storeId, $searchParams = null, $targete 'numericFilters' => 'visibility_search=1', 'removeWordsIfNoResults' => $this->configHelper->getRemoveWordsIfNoResult($storeId), 'analyticsTags' => 'backend-search', + 'facets' => $facetsToRetrieve, + 'maxValuesPerFacet' => 100, ]; if (is_array($searchParams)) { @@ -134,7 +141,7 @@ public function getSearchResult($query, $storeId, $searchParams = null, $targete } } - return [$data, $answer['nbHits']]; + return [$data, $answer['nbHits'], $answer['facets']]; } public function rebuildStoreAdditionalSectionsIndex($storeId) @@ -343,8 +350,12 @@ public function rebuildCategoryIndex($storeId, $page, $pageSize) return; } + $this->startEmulation($storeId); + $collection = $this->categoryHelper->getCategoryCollectionQuery($storeId, null); $this->rebuildStoreCategoryIndexPage($storeId, $collection, $page, $pageSize); + + $this->stopEmulation(); } public function rebuildStoreSuggestionIndexPage($storeId, $collectionDefault, $page, $pageSize) diff --git a/Helper/Entity/ProductHelper.php b/Helper/Entity/ProductHelper.php index 7234ccacc..5ed37b064 100755 --- a/Helper/Entity/ProductHelper.php +++ b/Helper/Entity/ProductHelper.php @@ -41,7 +41,7 @@ class ProductHelper private $eventManager; private $visibility; private $stockHelper; - private $stockRegistry; + protected $stockRegistry; private $objectManager; private $currencyManager; private $categoryHelper; @@ -217,18 +217,11 @@ public function getProductCollectionQuery( ->addAttributeToFilter('visibility', ['in' => $this->visibility->getVisibleInSiteIds()]); } - if ($this->configHelper->getShowOutOfStock($storeId) === false) { - $this->stockHelper->addInStockFilterToCollection($products); - } + $this->addStockFilter($products, $storeId); } /* @noinspection PhpUnnecessaryFullyQualifiedNameInspection */ - $products = $products->addFinalPrice() - ->addAttributeToSelect('special_price') - ->addAttributeToSelect('special_from_date') - ->addAttributeToSelect('special_to_date') - ->addAttributeToSelect('visibility') - ->addAttributeToSelect('status'); + $this->addMandatoryAttributes($products); $additionalAttr = $this->getAdditionalAttributes($storeId); @@ -263,6 +256,24 @@ public function getProductCollectionQuery( return $products; } + protected function addStockFilter($products, $storeId) + { + if ($this->configHelper->getShowOutOfStock($storeId) === false) { + $this->stockHelper->addInStockFilterToCollection($products); + } + } + + protected function addMandatoryAttributes($products) + { + /** @var \Magento\Catalog\Model\ResourceModel\Product\Collection $products */ + $products->addFinalPrice() + ->addAttributeToSelect('special_price') + ->addAttributeToSelect('special_from_date') + ->addAttributeToSelect('special_to_date') + ->addAttributeToSelect('visibility') + ->addAttributeToSelect('status'); + } + public function getAdditionalAttributes($storeId = null) { return $this->configHelper->getProductAdditionalAttributes($storeId); @@ -728,7 +739,7 @@ private function addImageData(array $customData, Product $product, $additionalAt return $customData; } - private function addInStock($defaultData, $customData, Product $product) + protected function addInStock($defaultData, $customData, Product $product) { if (isset($defaultData['in_stock']) === false) { $stockItem = $this->stockRegistry->getStockItem($product->getId()); @@ -1125,15 +1136,24 @@ public function canProductBeReindexed($product, $storeId, $isChildProduct = fals ->withStoreId($storeId); } + $isInStock = true; if (!$this->configHelper->getShowOutOfStock($storeId)) { - $stockItem = $this->stockRegistry->getStockItem($product->getId()); - if (! $stockItem->getIsInStock()) { - throw (new ProductOutOfStockException()) - ->withProduct($product) - ->withStoreId($storeId); - } + $isInStock = $this->productIsInStock($product, $storeId); + } + + if (!$isInStock) { + throw (new ProductOutOfStockException()) + ->withProduct($product) + ->withStoreId($storeId); } return true; } + + public function productIsInStock($product, $storeId) + { + $stockItem = $this->stockRegistry->getStockItem($product->getId()); + + return $stockItem->getIsInStock(); + } } diff --git a/Helper/Image.php b/Helper/Image.php index 20a7a2589..af6aa5280 100755 --- a/Helper/Image.php +++ b/Helper/Image.php @@ -3,6 +3,7 @@ namespace Algolia\AlgoliaSearch\Helper; use Magento\Catalog\Model\Product\ImageFactory; +use Magento\ConfigurableProduct\Model\Product\Type\Configurable as ProductTypeConfigurable; use Magento\Framework\App\Helper\Context; use Magento\Framework\View\Asset\Repository; use Magento\Framework\View\ConfigInterface; @@ -61,6 +62,51 @@ public function getUrl() return $url; } + protected function initBaseFile() + { + $model = $this->_getModel(); + $baseFile = $model->getBaseFile(); + if (!$baseFile) { + if ($this->getImageFile()) { + $model->setBaseFile($this->getImageFile()); + } else { + $model->setBaseFile($this->getProductImage()); + } + } + + return $this; + } + + /** + * Configurable::setImageFromChildProduct() only pulls 'image' type + * and not the type set by the imageHelper + * + * @return string + */ + private function getProductImage() + { + $imageUrl = $this->getProduct()->getImage(); + if (!$this->getImageFile() && $this->getType() !== 'image' + && $this->getProduct()->getTypeId() == ProductTypeConfigurable::TYPE_CODE) { + $imageUrl = $this->getConfigurableProductImage() ?: $imageUrl; + } + + return $imageUrl; + } + + private function getConfigurableProductImage() + { + $childProducts = $this->getProduct()->getTypeInstance()->getUsedProducts($this->getProduct()); + foreach ($childProducts as $childProduct) { + $childImageUrl = $childProduct->getData($this->getType()); + if ($childImageUrl && $childImageUrl !== 'no_selection') { + return $childImageUrl; + } + } + + return null; + } + public function removeProtocol($url) { return str_replace(['https://', 'http://'], '//', $url); diff --git a/Model/Backend/Facets.php b/Model/Backend/Facets.php new file mode 100755 index 000000000..9cb8972e1 --- /dev/null +++ b/Model/Backend/Facets.php @@ -0,0 +1,30 @@ +getValue(); + if (is_array($values)) { + unset($values['__empty']); + } + + // Adding query rule config (set to "no") in case the select doesn't appear in the form + foreach ($values as &$facet) { + if (!isset($facet['create_rule'])) { + $facet['create_rule'] = '2'; + } + } + + $this->setValue($values); + + return parent::beforeSave(); + } +} diff --git a/Model/Indexer/CategoryObserver.php b/Model/Indexer/CategoryObserver.php index 8f9adc1a0..e2ba4621e 100755 --- a/Model/Indexer/CategoryObserver.php +++ b/Model/Indexer/CategoryObserver.php @@ -7,61 +7,94 @@ use Magento\Catalog\Model\Category as CategoryModel; use Magento\Catalog\Model\ResourceModel\Category as CategoryResourceModel; use Magento\Catalog\Model\ResourceModel\Product\Collection as ProductCollection; +use Magento\Framework\App\ResourceConnection; use Magento\Framework\Indexer\IndexerRegistry; class CategoryObserver { + /** @var IndexerRegistry */ + private $indexerRegistry; + /** @var CategoryIndexer */ private $indexer; /** @var ConfigHelper */ private $configHelper; + /** @var ResourceConnection */ + protected $resource; + /** + * CategoryObserver constructor. * @param IndexerRegistry $indexerRegistry * @param ConfigHelper $configHelper + * @param ResourceConnection $resource */ - public function __construct(IndexerRegistry $indexerRegistry, ConfigHelper $configHelper) - { + public function __construct( + IndexerRegistry $indexerRegistry, + ConfigHelper $configHelper, + ResourceConnection $resource + ) { + $this->indexerRegistry = $indexerRegistry; $this->indexer = $indexerRegistry->get('algolia_categories'); $this->configHelper = $configHelper; + $this->resource = $resource; } /** - * Using "before" method here instead of "after", because M2.1 doesn't pass "$product" argument - * to "after" methods. When M2.1 support will be removed, this method can be rewriten to: - * afterSave(CategoryResourceModel $categoryResource, CategoryResourceModel $result, CategoryModel $category) - * * @param CategoryResourceModel $categoryResource + * @param CategoryResourceModel $result * @param CategoryModel $category * - * @return CategoryModel[] + * @return CategoryResourceModel */ - public function beforeSave(CategoryResourceModel $categoryResource, CategoryModel $category) - { + public function afterSave( + CategoryResourceModel $categoryResource, + CategoryResourceModel $result, + CategoryModel $category + ) { $categoryResource->addCommitCallback(function() use ($category) { - if (!$this->indexer->isScheduled() || $this->configHelper->isQueueActive()) { + $collectionIds = []; + // To reduce the indexing operation for products, only update if these values have changed + if ($category->getOrigData('name') !== $category->getData('name') + || $category->getOrigData('include_in_menu') !== $category->getData('include_in_menu') + || $category->getOrigData('is_active') !== $category->getData('is_active') + || $category->getOrigData('path') !== $category->getData('path')) { /** @var ProductCollection $productCollection */ $productCollection = $category->getProductCollection(); - CategoryIndexer::$affectedProductIds = (array) $productCollection->getColumnValues('entity_id'); + $collectionIds = (array) $productCollection->getColumnValues('entity_id'); + } + $changedProductIds = ($category->getChangedProductIds() !== null ? (array) $category->getChangedProductIds() : []); + if (!$this->indexer->isScheduled()) { + CategoryIndexer::$affectedProductIds = array_unique(array_merge($changedProductIds, $collectionIds)); $this->indexer->reindexRow($category->getId()); + } else { + // missing logic, if scheduled, when category is saved w/out product, products need to be added to _cl + if (count($changedProductIds) === 0 && count($collectionIds) > 0) { + $this->updateCategoryProducts($collectionIds); + } } }); - return [$category]; + return $result; } /** * @param CategoryResourceModel $categoryResource + * @param CategoryResourceModel $result * @param CategoryModel $category * - * @return CategoryModel[] + * @return CategoryResourceModel */ - public function beforeDelete(CategoryResourceModel $categoryResource, CategoryModel $category) - { + public function afterDelete( + CategoryResourceModel $categoryResource, + CategoryResourceModel $result, + CategoryModel $category + ) { $categoryResource->addCommitCallback(function() use ($category) { - if (!$this->indexer->isScheduled() || $this->configHelper->isQueueActive()) { + // mview should be able to handle the changes for catalog_category_product relationship + if (!$this->indexer->isScheduled()) { /* we are using products position because getProductCollection() doesn't use correct store */ $productCollection = $category->getProductsPosition(); CategoryIndexer::$affectedProductIds = array_keys($productCollection); @@ -70,6 +103,29 @@ public function beforeDelete(CategoryResourceModel $categoryResource, CategoryMo } }); - return [$category]; + return $result; + } + + /** + * @param array $productIds + */ + private function updateCategoryProducts(array $productIds) + { + $productIndexer = $this->indexerRegistry->get('algolia_products'); + if (!$productIndexer->isScheduled()) { + // if the product index is not schedule, it should still index these products + $productIndexer->reindexList($productIds); + } else { + $view = $productIndexer->getView(); + $changelogTableName = $this->resource->getTableName($view->getChangelog()->getName()); + $connection = $this->resource->getConnection(); + if ($connection->isTableExists($changelogTableName)) { + $data = []; + foreach ($productIds as $productId) { + $data[] = ['entity_id' => $productId]; + } + $connection->insertMultiple($changelogTableName, $data); + } + } } } diff --git a/Model/Indexer/ProductObserver.php b/Model/Indexer/ProductObserver.php index b9cadb1ab..3a2180cb2 100755 --- a/Model/Indexer/ProductObserver.php +++ b/Model/Indexer/ProductObserver.php @@ -3,8 +3,8 @@ namespace Algolia\AlgoliaSearch\Model\Indexer; use Algolia\AlgoliaSearch\Helper\ConfigHelper; -use Magento\Catalog\Model\Product as ProductModel; use Magento\Catalog\Model\Product\Action; +use Magento\Catalog\Model\Product as ProductModel; use Magento\Catalog\Model\ResourceModel\Product as ProductResource; use Magento\Framework\Indexer\IndexerRegistry; @@ -27,45 +27,39 @@ public function __construct(IndexerRegistry $indexerRegistry, ConfigHelper $conf } /** - * Using "before" method here instead of "after", because M2.1 doesn't pass "$product" argument - * to "after" methods. When M2.1 support will be removed, this method can be rewriten to: - * afterSave(ProductResource $productResource, ProductResource $result, ProductModel $product) - * * @param ProductResource $productResource + * @param ProductResource $result * @param ProductModel $product * * @return ProductModel[] */ - public function beforeSave(ProductResource $productResource, ProductModel $product) + public function afterSave(ProductResource $productResource, ProductResource $result, ProductModel $product) { $productResource->addCommitCallback(function () use ($product) { - if (!$this->indexer->isScheduled() || $this->configHelper->isQueueActive()) { + if (!$this->indexer->isScheduled()) { $this->indexer->reindexRow($product->getId()); } }); - return [$product]; + return $result; } /** - * Using "before" method here instead of "after", because M2.1 doesn't pass "$product" argument - * to "after" methods. When M2.1 support will be removed, this method can be rewriten to: - * public function afterDelete(ProductResource $productResource, ProductResource $result, ProductModel $product) - * * @param ProductResource $productResource + * @param ProductResource $result * @param ProductModel $product * * @return ProductModel[] */ - public function beforeDelete(ProductResource $productResource, ProductModel $product) + public function afterDelete(ProductResource $productResource, ProductResource $result, ProductModel $product) { $productResource->addCommitCallback(function () use ($product) { - if (!$this->indexer->isScheduled() || $this->configHelper->isQueueActive()) { + if (!$this->indexer->isScheduled()) { $this->indexer->reindexRow($product->getId()); } }); - return [$product]; + return $result; } /** @@ -77,7 +71,7 @@ public function beforeDelete(ProductResource $productResource, ProductModel $pro */ public function afterUpdateAttributes(Action $subject, Action $result = null, $productIds) { - if (!$this->indexer->isScheduled() || $this->configHelper->isQueueActive()) { + if (!$this->indexer->isScheduled()) { $this->indexer->reindexList(array_unique($productIds)); } @@ -93,7 +87,7 @@ public function afterUpdateAttributes(Action $subject, Action $result = null, $p */ public function afterUpdateWebsites(Action $subject, Action $result = null, array $productIds) { - if (!$this->indexer->isScheduled() || $this->configHelper->isQueueActive()) { + if (!$this->indexer->isScheduled()) { $this->indexer->reindexList(array_unique($productIds)); } diff --git a/Model/Indexer/StockItemObserver.php b/Model/Indexer/StockItemObserver.php index 7b4451b57..2833c4e4d 100644 --- a/Model/Indexer/StockItemObserver.php +++ b/Model/Indexer/StockItemObserver.php @@ -13,9 +13,9 @@ public function __construct(IndexerRegistry $indexerRegistry) $this->indexer = $indexerRegistry->get('algolia_products'); } - public function aroundSave( + public function afterSave( \Magento\CatalogInventory\Model\ResourceModel\Stock\Item $stockItemModel, - \Closure $proceed, + \Magento\CatalogInventory\Model\ResourceModel\Stock\Item $result, \Magento\CatalogInventory\Api\Data\StockItemInterface $stockItem ) { $stockItemModel->addCommitCallback(function () use ($stockItem) { @@ -24,12 +24,12 @@ public function aroundSave( } }); - return $proceed($stockItem); + return $result; } - public function aroundDelete( + public function afterDelete( \Magento\CatalogInventory\Model\ResourceModel\Stock\Item $stockItemResource, - \Closure $proceed, + \Magento\CatalogInventory\Model\ResourceModel\Stock\Item $result, \Magento\CatalogInventory\Api\Data\StockItemInterface $stockItem ) { $stockItemResource->addCommitCallback(function () use ($stockItem) { @@ -38,6 +38,6 @@ public function aroundDelete( } }); - return $proceed($stockItem); + return $result; } } diff --git a/Model/Layer/Category/FilterableAttributeList.php b/Model/Layer/Category/FilterableAttributeList.php new file mode 100644 index 000000000..00921c60f --- /dev/null +++ b/Model/Layer/Category/FilterableAttributeList.php @@ -0,0 +1,46 @@ +configHelper = $configHelper; + } + + protected function _prepareAttributeCollection($collection) + { + $collection->addIsFilterableFilter(); + + if ($this->configHelper->isBackendRenderingEnabled()) { + $facets = $this->configHelper->getFacets($this->storeManager->getStore()->getId()); + $filterAttributes = []; + foreach ($facets as $facet) { + $filterAttributes[] = $facet['attribute']; + } + + $collection->addFieldToFilter('attribute_code', ['in' => $filterAttributes]); + $collection->setOrder('attribute_id', 'ASC'); + } + + return $collection; + } +} diff --git a/Model/Layer/Filter/Attribute.php b/Model/Layer/Filter/Attribute.php new file mode 100644 index 000000000..856e7ae05 --- /dev/null +++ b/Model/Layer/Filter/Attribute.php @@ -0,0 +1,130 @@ +tagFilter = $tagFilter; + $this->escaper = $escaper; + $this->configHelper = $configHelper; + } + + /** + * {@inheritdoc} + */ + public function apply(\Magento\Framework\App\RequestInterface $request) + { + $storeId = $this->configHelper->getStoreId(); + if (!$this->configHelper->isBackendRenderingEnabled($storeId)) { + return parent::apply($request); + } + + $attribute = $this->getAttributeModel(); + $attributeValue = $request->getParam($this->_requestVar); + if (!is_null($attributeValue)) { + $attributeValue = explode('~', $request->getParam($this->_requestVar)); + } + + if (empty($attributeValue)) { + return $this; + } + + if (!is_array($attributeValue)) { + $attributeValue = [$attributeValue]; + } + + $this->currentFilterValue = array_values($attributeValue); + + /** @var \Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection $productCollection */ + $productCollection = $this->getLayer()->getProductCollection(); + $productCollection->addFieldToFilter($attribute->getAttributeCode(), ['in' => $this->currentFilterValue]); + $layerState = $this->getLayer()->getState(); + + foreach ($this->currentFilterValue as $currentFilter) { + $filter = $this->_createItem( + $this->escaper->escapeHtml($this->getOptionText($currentFilter)), + $this->currentFilterValue + ); + $layerState->addFilter($filter); + } + + return $this; + } + + protected function _initItems() + { + parent::_initItems(); + + foreach ($this->_items as $item) { + $applyValue = $item->getValue(); + if (($valuePos = array_search($applyValue, $this->currentFilterValue)) !== false) { + $item->setIsSelected(true); + } + } + + return $this; + } + + protected function _getItemsData() + { + return $this->sortItemsData(parent::_getItemsData()); + } + + private function sortItemsData(array $items) + { + usort($items, function ($item1, $item2) { + if (!isset($item1['count']) or !isset($item2['count'])) { + return 0; + } + + return $item1['count'] > $item2['count'] ? -1 : 1; + }); + + return $items; + } +} diff --git a/Model/Layer/Filter/Category.php b/Model/Layer/Filter/Category.php new file mode 100644 index 000000000..f90a999ce --- /dev/null +++ b/Model/Layer/Filter/Category.php @@ -0,0 +1,80 @@ +escaper = $escaper; + $this->_requestVar = 'cat'; + $this->dataProvider = $categoryDataProviderFactory->create(['layer' => $this->getLayer()]); + $this->configHelper = $configHelper; + } + + public function apply(\Magento\Framework\App\RequestInterface $request) + { + $categoryId = $request->getParam($this->_requestVar) ?: $request->getParam('id'); + if (empty($categoryId)) { + return $this; + } + + $storeId = $this->configHelper->getStoreId(); + if (!$this->configHelper->isBackendRenderingEnabled($storeId)) { + return parent::apply($request); + } + + $this->dataProvider->setCategoryId($categoryId); + + $category = $this->dataProvider->getCategory(); + + if ($request->getParam('id') != $category->getId() && $this->dataProvider->isValid()) { + $this->getLayer()->getState()->addFilter($this->_createItem($category->getName(), $categoryId)); + } + + return $this; + } +} diff --git a/Model/Layer/Filter/Decimal.php b/Model/Layer/Filter/Decimal.php new file mode 100644 index 000000000..b2117f5f2 --- /dev/null +++ b/Model/Layer/Filter/Decimal.php @@ -0,0 +1,139 @@ +localeResolver = $localeResolver; + $this->configHelper = $configHelper; + $this->dataProvider = $dataProviderFactory->create(['layer' => $this->getLayer()]); + } + + protected function _getItemsData() + { + $storeId = $this->configHelper->getStoreId(); + if (!$this->configHelper->isBackendRenderingEnabled($storeId)) { + return parent::_getItemsData(); + } + + $attribute = $this->getAttributeModel(); + $this->_requestVar = $attribute->getAttributeCode(); + + /** @var \Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection $productCollection */ + $productCollection = $this->getLayer()->getProductCollection(); + $facets = $productCollection->getFacetedData($attribute->getAttributeCode()); + $this->setMinValue($productCollection->getMinPrice()); + $this->setMaxValue($productCollection->getMaxPrice()); + + $data = []; + if (count($facets) > 0) { + foreach ($facets as $key => $aggregation) { + $count = $aggregation['count']; + if (mb_strpos($key, '_') === false) { + continue; + } + $data[] = $this->prepareData($key, $count, $data); + } + } + + return $data; + } + + private function prepareData($key, $count) + { + list($from, $to) = explode('_', $key); + if ($from == '*') { + $from = $this->getFrom($to); + } + if ($to == '*') { + $to = $this->getTo($to); + } + $label = $this->_renderRangeLabel($from, $to); + $value = $from . '-' . $to . $this->dataProvider->getAdditionalRequestData(); + + $data = [ + 'label' => $label, + 'value' => $value, + 'count' => $count, + 'from' => $from, + 'to' => $to, + ]; + + return $data; + } + + protected function _renderRangeLabel($fromValue, $toValue) + { + $label = $this->formatValue($fromValue); + + if ($toValue === '') { + $label = __('%1 and above', $label); + } elseif ($fromValue != $toValue) { + $label = __('%1 - %2', $label, $this->formatValue($toValue)); + } + + return $label; + } + + private function formatValue($value) + { + $attribute = $this->getAttributeModel(); + + if ((int) $attribute->getDisplayPrecision() > 0) { + $locale = $this->localeResolver->getLocale(); + $options = ['locale' => $locale, 'precision' => (int) $attribute->getDisplayPrecision()]; + $valueFormatter = new \Zend_Filter_NormalizedToLocalized($options); + $value = $valueFormatter->filter($value); + } + + if ((string) $attribute->getDisplayPattern() != '') { + $value = sprintf((string) $attribute->getDisplayPattern(), $value); + } + + return $value; + } +} diff --git a/Model/Layer/Filter/Item/Attribute.php b/Model/Layer/Filter/Item/Attribute.php new file mode 100644 index 000000000..c090a0c18 --- /dev/null +++ b/Model/Layer/Filter/Item/Attribute.php @@ -0,0 +1,130 @@ +getFilter()->getRequestVar() => $this->getApplyValue(), + $this->_htmlPagerBlock->getPageVarName() => null, + ]; + + $qsParams = $this->getAdditionalParams($qsParams); + + return $this->_url->getUrl( + '*/*/*', + [ + '_current' => true, + '_use_rewrite' => true, + '_escape' => false, + '_query' => $qsParams, + ] + ); + } + + public function getRemoveUrl() + { + $query = [$this->getFilter()->getRequestVar() => $this->getFilter()->getResetValue()]; + + if (is_array($this->getApplyValue())) { + $idToRemove = null; + + foreach ($this->getFilter()->getAttributeModel()->getOptions() as $option) { + if ($option->getLabel() == $this->getLabel()) { + $idToRemove = $option->getValue(); + break; + } + } + + if (!is_null($idToRemove)) { + $resetValue = array_diff($this->getApplyValue(), [$idToRemove]); + } + $query = [$this->getFilter()->getRequestVar() => implode('~', $resetValue)]; + } + + $params = [ + '_current' => true, + '_use_rewrite' => true, + '_query' => $query, + '_escape' => true, + ]; + + return $this->_url->getUrl('*/*/*', $params); + } + + public function toArray(array $keys = []) + { + $data = parent::toArray($keys); + + if (in_array('url', $keys) || empty($keys)) { + $data['url'] = $this->getUrl(); + } + + if (in_array('is_selected', $keys) || empty($keys)) { + $data['is_selected'] = (bool) $this->getIsSelected(); + } + + return $data; + } + + private function getApplyValue() + { + $value = $this->getValue(); + + if (is_array($this->getApplyFilterValue())) { + $value = $this->getApplyFilterValue(); + } + + if (is_array($value) && count($value) == 1) { + $value = current($value); + } + + return $value; + } + + private function getAdditionalParams($qsParams) + { + $baseUrlParts = explode('?', htmlspecialchars_decode($this->_url->getCurrentUrl())); + + $qsParser = new \Zend\Stdlib\Parameters(); + $qsParser->fromArray($qsParams); + + $paramsToAdd = $qsParser->toArray(); + + if (count($baseUrlParts) > 1) { + $qsParser->fromString($baseUrlParts[1]); + $existingParams = $qsParser->toArray(); + + foreach ($paramsToAdd as $key => $value) { + if (isset($existingParams[$key])) { + $existingParamsArray = explode('~', $existingParams[$key]); + // check if the value is already in the applied filter, if it is, this means we have to remove it + if (in_array($paramsToAdd[$key], $existingParamsArray) && $this->getIsSelected()) { + foreach ($existingParamsArray as $arrayParamKey => $arrayParam) { + if ($arrayParam == $paramsToAdd[$key]) { + unset($existingParamsArray[$arrayParamKey]); + } + } + $paramsToAdd[$key] = !empty($existingParamsArray) ? + implode('~', $existingParamsArray) : + null; + } else { + $paramsToAdd[$key] = $existingParams[$key] . '~' . $paramsToAdd[$key]; + } + } + } + + $qsParams = array_merge($existingParams, $paramsToAdd); + $qsParser->fromArray($qsParams); + } + + $baseUrlParts[1] = $qsParser->toString(); + + return $qsParser->toArray(); + } +} diff --git a/Model/Layer/Filter/Price.php b/Model/Layer/Filter/Price.php new file mode 100644 index 000000000..01ee9b261 --- /dev/null +++ b/Model/Layer/Filter/Price.php @@ -0,0 +1,158 @@ +dataProvider = $dataProviderFactory->create(['layer' => $this->getLayer()]); + $this->configHelper = $configHelper; + $this->priceCurrency = $priceCurrency; + } + + public function apply(\Magento\Framework\App\RequestInterface $request) + { + $storeId = $this->configHelper->getStoreId(); + if (!$this->configHelper->isBackendRenderingEnabled($storeId)) { + return parent::apply($request); + } + + $filter = $request->getParam($this->getRequestVar()); + + if ($filter && !is_array($filter)) { + $this->dataProvider->setInterval($filter); + + list($fromValue, $toValue) = explode('-', $filter); + $this->setCurrentValue(['from' => $fromValue, 'to' => $toValue]); + + $this->getLayer()->getState()->addFilter( + $this->_createItem($this->_renderRangeLabel(empty($fromValue) ? 0 : $fromValue, $toValue), $filter) + ); + } + + return $this; + } + + protected function _getItemsData() + { + $storeId = $this->configHelper->getStoreId(); + if (!$this->configHelper->isBackendRenderingEnabled($storeId)) { + return parent::_getItemsData(); + } + + $attribute = $this->getAttributeModel(); + $this->_requestVar = $attribute->getAttributeCode(); + + /** @var \Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection $productCollection */ + $productCollection = $this->getLayer()->getProductCollection(); + $facets = $productCollection->getFacetedData($attribute->getAttributeCode()); + $this->setMinValue($productCollection->getMinPrice()); + $this->setMaxValue($productCollection->getMaxPrice()); + + $data = []; + if (count($facets) > 0) { + foreach ($facets as $key => $aggregation) { + $count = $aggregation['count']; + if (mb_strpos($key, '_') === false) { + continue; + } + $data[] = $this->prepareData($key, $count, $data); + } + } + + return $data; + } + + private function prepareData($key, $count) + { + list($from, $to) = explode('_', $key); + if ($from == '*') { + $from = $this->getFrom($to); + } + if ($to == '*') { + $to = $this->getTo($to); + } + $label = $this->_renderRangeLabel($from, $to); + $value = $from . '-' . $to . $this->dataProvider->getAdditionalRequestData(); + + $data = [ + 'label' => $label, + 'value' => $value, + 'count' => $count, + 'from' => $from, + 'to' => $to, + ]; + + return $data; + } + + protected function _renderRangeLabel($fromPrice, $toPrice) + { + $fromPrice = empty($fromPrice) ? 0 : $fromPrice * $this->getCurrencyRate(); + $toPrice = empty($toPrice) ? $toPrice : $toPrice * $this->getCurrencyRate(); + + $formattedFromPrice = $this->priceCurrency->format($fromPrice); + if ($toPrice === '') { + return __('%1 and above', $formattedFromPrice); + } elseif ($fromPrice == $toPrice && $this->dataProvider->getOnePriceIntervalValue()) { + return $formattedFromPrice; + } + + return __('%1 - %2', $formattedFromPrice, $this->priceCurrency->format($toPrice)); + } +} diff --git a/Model/Layer/Search/FilterableAttributeList.php b/Model/Layer/Search/FilterableAttributeList.php new file mode 100644 index 000000000..60942a534 --- /dev/null +++ b/Model/Layer/Search/FilterableAttributeList.php @@ -0,0 +1,46 @@ +configHelper = $configHelper; + } + + protected function _prepareAttributeCollection($collection) + { + $collection = parent::_prepareAttributeCollection($collection); + + if ($this->configHelper->isBackendRenderingEnabled()) { + $facets = $this->configHelper->getFacets($this->storeManager->getStore()->getId()); + $filterAttributes = []; + foreach ($facets as $facet) { + $filterAttributes[] = $facet['attribute']; + } + + $collection->addFieldToFilter('attribute_code', ['in' => $filterAttributes]); + $collection->setOrder('attribute_id', 'ASC'); + } + + return $collection; + } +} diff --git a/Model/Observer.php b/Model/Observer.php index c327b9793..78830cac9 100755 --- a/Model/Observer.php +++ b/Model/Observer.php @@ -6,6 +6,7 @@ use Magento\Framework\Event\ObserverInterface; use Magento\Framework\Registry; use Magento\Framework\View\Layout; +use Magento\Framework\View\Page\Config as PageConfig; use Magento\Store\Model\StoreManagerInterface; /** @@ -16,12 +17,18 @@ class Observer implements ObserverInterface private $config; private $registry; private $storeManager; - - public function __construct(ConfigHelper $configHelper, Registry $registry, StoreManagerInterface $storeManager) - { + private $pageConfig; + + public function __construct( + ConfigHelper $configHelper, + Registry $registry, + StoreManagerInterface $storeManager, + PageConfig $pageConfig + ) { $this->config = $configHelper; $this->registry = $registry; $this->storeManager = $storeManager; + $this->pageConfig = $pageConfig; } public function execute(\Magento\Framework\Event\Observer $observer) @@ -43,6 +50,8 @@ public function execute(\Magento\Framework\Event\Observer $observer) $this->loadPreventBackendRenderingHandle($layout); $this->loadAnalyticsHandle($layout); + + $this->addBodyCss(); } } } @@ -82,4 +91,12 @@ private function loadAnalyticsHandle(Layout $layout) $layout->getUpdate()->addHandle('algolia_search_handle_click_conversion_analytics'); } + + private function addBodyCss() + { + $storeId = $this->storeManager->getStore()->getId(); + if ($this->config->isBackendRenderingEnabled($storeId)) { + $this->pageConfig->addBodyClass('algolia-rendering'); + } + } } diff --git a/Model/Observer/CatalogPermissions/ApplyProductPermissionsFilter.php b/Model/Observer/CatalogPermissions/ApplyProductPermissionsFilter.php index e265c8d57..a86b2b025 100644 --- a/Model/Observer/CatalogPermissions/ApplyProductPermissionsFilter.php +++ b/Model/Observer/CatalogPermissions/ApplyProductPermissionsFilter.php @@ -3,27 +3,44 @@ namespace Algolia\AlgoliaSearch\Model\Observer\CatalogPermissions; use Algolia\AlgoliaSearch\Factory\CatalogPermissionsFactory; +use Algolia\AlgoliaSearch\Factory\SharedCatalogFactory; use Magento\Framework\Event\Observer; use Magento\Framework\Event\ObserverInterface; use Magento\Store\Model\StoreManager; class ApplyProductPermissionsFilter implements ObserverInterface { + /** @var CatalogPermissionsFactory */ private $permissionsFactory; + + /** @var SharedCatalogFactory */ + private $sharedCatalogFactory; + + /** @var StoreManager */ private $storeManager; + /** + * @param CatalogPermissionsFactory $permissionsFactory + * @param SharedCatalogFactory $sharedCatalogFactory + * @param StoreManager $storeManager + */ public function __construct( CatalogPermissionsFactory $permissionsFactory, + SharedCatalogFactory $sharedCatalogFactory, StoreManager $storeManager ) { $this->permissionsFactory = $permissionsFactory; + $this->sharedCatalogFactory = $sharedCatalogFactory; $this->storeManager = $storeManager; } public function execute(Observer $observer) { $storeId = $this->storeManager->getStore()->getId(); - if (!$this->permissionsFactory->isCatalogPermissionsEnabled($storeId)) { + if (!$this->permissionsFactory->isCatalogPermissionsEnabled($storeId) + || ($this->permissionsFactory->getCatalogPermissionsHelper()->isAllowedCategoryView($storeId) + && !$this->sharedCatalogFactory->isSharedCatalogEnabled($storeId)) + ) { return $this; } diff --git a/Model/Observer/CatalogPermissions/CategoryCollectionAddPermissions.php b/Model/Observer/CatalogPermissions/CategoryCollectionAddPermissions.php index 909ec0932..2461d363b 100644 --- a/Model/Observer/CatalogPermissions/CategoryCollectionAddPermissions.php +++ b/Model/Observer/CatalogPermissions/CategoryCollectionAddPermissions.php @@ -54,7 +54,11 @@ protected function addCatalogPermissionsData($collection, $categoryIds) foreach ($permissionsCollection as $categoryId => $permissions) { $permissions = explode(',', $permissions); foreach ($permissions as $permission) { - list($customerGroupId, $level) = explode('_', $permission); + $permission = explode('_', $permission); + if (count($permission) < 2) { // prevent undefined + continue; + } + list($customerGroupId, $level) = $permission; if ($category = $collection->getItemById($categoryId)) { $category->setData('customer_group_permission_' . $customerGroupId, (($level == -2 || $level != -1 && !$catalogPermissionsHelper->isAllowedCategoryView()) ? 0 : 1)); @@ -79,7 +83,11 @@ protected function addSharedCatalogData($collection, $categoryIds, $storeId) foreach ($sharedCollection as $categoryId => $permissions) { $permissions = explode(',', $permissions); foreach ($permissions as $permission) { - list($customerGroupId, $level) = explode('_', $permission); + $permission = explode('_', $permission); + if (count($permission) < 2) { // prevent undefined + continue; + } + list($customerGroupId, $level) = $permission; if ($category = $collection->getItemById($categoryId)) { $category->setData('shared_catalog_permission_' . $customerGroupId, $level == -1 ? 1 : 0); } diff --git a/Model/Observer/CatalogPermissions/ProductCollectionAddPermissions.php b/Model/Observer/CatalogPermissions/ProductCollectionAddPermissions.php index fbc33bee0..c83bf5838 100644 --- a/Model/Observer/CatalogPermissions/ProductCollectionAddPermissions.php +++ b/Model/Observer/CatalogPermissions/ProductCollectionAddPermissions.php @@ -63,9 +63,12 @@ protected function addProductPermissionsData($additionalData, $productIds, $stor foreach ($permissionsCollection as $productId => $permissions) { $permissions = explode(',', $permissions); foreach ($permissions as $permission) { - list($permissionStoreId, $customerGroupId, $level) = explode('_', $permission); + $permission = explode('_', $permission); + if (count($permission) < 3) { // prevent undefined + continue; + } + list($permissionStoreId, $customerGroupId, $level) = $permission; if ($permissionStoreId == $storeId) { - $additionalData->addProductData($productId, [ 'customer_group_permission_' . $customerGroupId => (($level == -2 || $level != -1 && !$catalogPermissionsHelper->isAllowedCategoryView()) ? 0 : 1), @@ -90,7 +93,11 @@ protected function addSharedCatalogData($additionalData, $productIds) foreach ($sharedCollection as $productId => $permissions) { $permissions = explode(',', $permissions); foreach ($permissions as $permission) { - list($customerGroupId, $level) = explode('_', $permission); + $permission = explode('_', $permission); + if (count($permission) < 2) { // prevent undefined + continue; + } + list($customerGroupId, $level) = $permission; $additionalData->addProductData($productId, [ 'shared_catalog_permission_' . $customerGroupId => $level, ]); diff --git a/Model/Observer/CategoryMoveAfter.php b/Model/Observer/CategoryMoveAfter.php new file mode 100644 index 000000000..acbe401df --- /dev/null +++ b/Model/Observer/CategoryMoveAfter.php @@ -0,0 +1,75 @@ +indexerRegistry = $indexerRegistry; + $this->configHelper = $configHelper; + $this->resource = $resource; + } + + /** + * Category::move() does not run save so the plugin observing the save method + * is not able to process the products that need updating. + * + * @param Observer $observer + * @return bool|void + */ + public function execute(Observer $observer) + { + /** @var Category $category */ + $category = $observer->getEvent()->getCategory(); + if (!$this->configHelper->indexProductOnCategoryProductsUpdate($category->getStoreId())) { + return false; + } + + $productIndexer = $this->indexerRegistry->get('algolia_products'); + if ($category->getOrigData('path') !== $category->getData('path')) { + $productIds = array_keys($category->getProductsPosition()); + + if (!$productIndexer->isScheduled()) { + // if the product index is not schedule, it should still index these products + $productIndexer->reindexList($productIds); + } else { + $view = $productIndexer->getView(); + $changelogTableName = $this->resource->getTableName($view->getChangelog()->getName()); + $connection = $this->resource->getConnection(); + if ($connection->isTableExists($changelogTableName)) { + $data = []; + foreach ($productIds as $productId) { + $data[] = ['entity_id' => $productId]; + } + $connection->insertMultiple($changelogTableName, $data); + } + } + } + } +} diff --git a/Model/Source/Facets.php b/Model/Source/Facets.php index ed51c3181..1dfcc49d0 100755 --- a/Model/Source/Facets.php +++ b/Model/Source/Facets.php @@ -2,13 +2,34 @@ namespace Algolia\AlgoliaSearch\Model\Source; +use Algolia\AlgoliaSearch\Helper\ConfigHelper; +use Algolia\AlgoliaSearch\Helper\Entity\CategoryHelper; +use Algolia\AlgoliaSearch\Helper\Entity\ProductHelper; +use Algolia\AlgoliaSearch\Helper\ProxyHelper; +use Magento\Backend\Block\Template\Context; + class Facets extends AbstractTable { + protected $proxyHelper; + + public function __construct( + Context $context, + ProductHelper $producthelper, + CategoryHelper $categoryHelper, + ConfigHelper $configHelper, + ProxyHelper $proxyHelper, + array $data = [] + ) { + $this->proxyHelper = $proxyHelper; + + parent::__construct($context, $producthelper, $categoryHelper, $configHelper, $data); + } + protected function getTableData() { $productHelper = $this->productHelper; - return [ + $config = [ 'attribute' => [ 'label' => 'Attribute', 'values' => function () use ($productHelper) { @@ -39,10 +60,28 @@ protected function getTableData() 'label' => 'Searchable?', 'values' => ['1' => 'Yes', '2' => 'No'], ], - 'create_rule' => [ + ]; + + if ($this->isQueryRulesEnabled()) { + $config['create_rule'] = [ 'label' => 'Create Query rule?', 'values' => ['1' => 'Yes', '2' => 'No'], - ], - ]; + ]; + } + + return $config; + } + + private function isQueryRulesEnabled() + { + $info = $this->proxyHelper->getInfo(\Algolia\AlgoliaSearch\Helper\ProxyHelper::INFO_TYPE_QUERY_RULES); + + // In case the call to API proxy fails, + // be "nice" and return true + if ($info && array_key_exists('query_rules', $info)) { + return $info['query_rules']; + } + + return true; } } diff --git a/Plugin/BackendFilterRendererPlugin.php b/Plugin/BackendFilterRendererPlugin.php new file mode 100755 index 000000000..dea707e57 --- /dev/null +++ b/Plugin/BackendFilterRendererPlugin.php @@ -0,0 +1,90 @@ +layout = $layout; + $this->configHelper = $configHelper; + $this->storeManager = $storeManager; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @param \Magento\LayeredNavigation\Block\Navigation\FilterRenderer $subject + * @param \Closure $proceed + * @param \Magento\Catalog\Model\Layer\Filter\FilterInterface $filter + * + * @throws \Magento\Framework\Exception\LocalizedException + * + * @return mixed + */ + public function aroundRender( + \Magento\LayeredNavigation\Block\Navigation\FilterRenderer $subject, + \Closure $proceed, + \Magento\Catalog\Model\Layer\Filter\FilterInterface $filter + ) { + if ($this->configHelper->isBackendRenderingEnabled()) { + if ($filter instanceof \Magento\CatalogSearch\Model\Layer\Filter\Category) { + return $this->layout + ->createBlock($this->categoryBlock) + ->render($filter); + } + + $attributeCode = $filter->getAttributeModel()->getAttributeCode(); + $facets = $this->configHelper->getFacets($this->storeManager->getStore()->getId()); + + foreach ($facets as $facet) { + if ($facet['attribute'] == $attributeCode) { + $block = $this->defaultBlock; + if ($facet['type'] == 'slider') { + $block = $this->sliderBlock; + } + if ($facet['attribute'] == 'price') { + $block = $this->priceBlock; + } + + return $this->layout + ->createBlock($block) + ->setIsSearchable($facet['searchable'] == '1') + ->render($filter); + } + } + } + + return $proceed($filter); + } +} diff --git a/Plugin/LayerPlugin.php b/Plugin/LayerPlugin.php new file mode 100755 index 000000000..1d433922d --- /dev/null +++ b/Plugin/LayerPlugin.php @@ -0,0 +1,46 @@ +configHelper = $configHelper; + $this->storeManager = $storeManager; + } + + /** + * Adding relevance ordering on product collection to make sure it's compatible with replica index targeting + * + * @param Layer $subject + * @param ProductCollection $result + */ + public function afterGetProductCollection(Layer $subject, $result) + { + $storeId = $this->storeManager->getStore()->getId(); + + if ($this->configHelper->isBackendRenderingEnabled($storeId)) { + $result->setOrder('relevance'); + } + + return $result; + } +} diff --git a/Plugin/ToolbarPlugin.php b/Plugin/ToolbarPlugin.php new file mode 100644 index 000000000..f41c7ed5c --- /dev/null +++ b/Plugin/ToolbarPlugin.php @@ -0,0 +1,54 @@ +configHelper = $configHelper; + $this->coreHelper = $coreHelper; + $this->productHelper = $productHelper; + $this->storeManager = $storeManager; + $this->httpContext = $httpContext; + } + + // Override the available orders when backend rendering is activated (get the sorting indices) + public function afterGetAvailableOrders(Toolbar $subject, $result) + { + $storeId = $this->storeManager->getStore()->getId(); + + if ($this->configHelper->isBackendRenderingEnabled($storeId)) { + $customerGroupId = $this->httpContext->getValue(CustomerContext::CONTEXT_GROUP); + $indexName = $this->coreHelper->getIndexName($this->productHelper->getIndexNameSuffix()); + $sortingIndices = $this->configHelper->getSortingIndices($indexName, $storeId, $customerGroupId); + $availableOrders = []; + $availableOrders[$indexName] = __('Relevance'); + foreach ($sortingIndices as $sortingIndice) { + $availableOrders[$sortingIndice['name']] = $sortingIndice['label']; + } + $result = $availableOrders; + } + + return $result; + } +} diff --git a/Test/Integration/SearchTest.php b/Test/Integration/SearchTest.php index c95b94b23..e9bc0d954 100644 --- a/Test/Integration/SearchTest.php +++ b/Test/Integration/SearchTest.php @@ -17,7 +17,7 @@ public function testSearch() /** @var Data $helper */ $helper = $this->getObjectManager()->create('Algolia\AlgoliaSearch\Helper\Data'); - list($results, $totalHits) = $helper->getSearchResult('', 1); + list($results, $totalHits, $facetsFromAlgolia) = $helper->getSearchResult('', 1); $this->assertNotEmpty($results); } diff --git a/ViewModel/Adminhtml/Common.php b/ViewModel/Adminhtml/Common.php index ab0aedfae..19dd2c1ea 100644 --- a/ViewModel/Adminhtml/Common.php +++ b/ViewModel/Adminhtml/Common.php @@ -42,13 +42,13 @@ class Common ], 'algoliasearch_synonyms' => [ 'title' => 'Notable features', - 'url' => 'https://www.youtube.com/watch?v=qzaLrHz67U4', - 'thumbnail' => 'https://img.youtube.com/vi/qzaLrHz67U4/mqdefault.jpg', + 'url' => 'https://www.youtube.com/watch?v=45NKJbrs1Z4', + 'thumbnail' => 'https://img.youtube.com/vi/45NKJbrs1Z4/mqdefault.jpg', ], 'algoliasearch_cc_analytics' => [ 'title' => 'Notable features', - 'url' => 'https://www.youtube.com/watch?v=qzaLrHz67U4', - 'thumbnail' => 'https://img.youtube.com/vi/qzaLrHz67U4/mqdefault.jpg', + 'url' => 'https://www.youtube.com/watch?v=45NKJbrs1Z4', + 'thumbnail' => 'https://img.youtube.com/vi/45NKJbrs1Z4/mqdefault.jpg', ], ]; @@ -211,6 +211,12 @@ public function isClickAnalyticsEnabled() return true; } + /** @return bool */ + public function isClickAnalyticsTurnedOnInAdmin() + { + return $this->configHelper->isClickConversionAnalyticsEnabled(); + } + /** @return array|void */ public function getVideoConfig($section) { diff --git a/ViewModel/Adminhtml/Support/Contact.php b/ViewModel/Adminhtml/Support/Contact.php index 0a390a316..20cc7e502 100644 --- a/ViewModel/Adminhtml/Support/Contact.php +++ b/ViewModel/Adminhtml/Support/Contact.php @@ -4,7 +4,6 @@ use Algolia\AlgoliaSearch\Helper\SupportHelper; use Algolia\AlgoliaSearch\ViewModel\Adminhtml\BackendView; -use Magento\Backend\Block\Template; use Magento\Backend\Model\Auth\Session; use Magento\Framework\Module\ModuleListInterface; use Magento\User\Model\User; @@ -79,20 +78,6 @@ public function getTooltipHtml($message) return $this->backendView->getTooltipHtml($message); } - /** - * @return string - */ - public function getLegacyVersionHtml() - { - /** @var Template $block */ - $block = $this->backendView->getLayout()->createBlock(Template::class); - - $block->setTemplate('Algolia_AlgoliaSearch::support/components/legacy-version.phtml'); - $block->setData('extension_version', $this->getExtensionVersion()); - - return $block->toHtml(); - } - /** @return User|null */ private function getCurrenctAdmin() { diff --git a/ViewModel/Adminhtml/Support/Overview.php b/ViewModel/Adminhtml/Support/Overview.php index 9dc7c4538..318a4ddce 100644 --- a/ViewModel/Adminhtml/Support/Overview.php +++ b/ViewModel/Adminhtml/Support/Overview.php @@ -2,6 +2,7 @@ namespace Algolia\AlgoliaSearch\ViewModel\Adminhtml\Support; +use Algolia\AlgoliaSearch\Block\Adminhtml\BaseAdminTemplate; use Algolia\AlgoliaSearch\Helper\SupportHelper; use Algolia\AlgoliaSearch\ViewModel\Adminhtml\BackendView; use Magento\Backend\Block\Template; @@ -49,4 +50,13 @@ public function getLegacyVersionHtml() return $block->toHtml(); } + + public function getContactHtml() + { + /** @var BaseAdminTemplate $block */ + $block = $this->backendView->getLayout()->createBlock(BaseAdminTemplate::class); + $block->setTemplate('Algolia_AlgoliaSearch::support/contact.phtml'); + + return $block->toHtml(); + } } diff --git a/composer.json b/composer.json index ae8886aa6..e5e119338 100755 --- a/composer.json +++ b/composer.json @@ -13,6 +13,15 @@ "ext-mbstring": "*", "ext-dom": "*" }, + "repositories": [ + { + "type": "composer", + "url": "https://repo.magento.com/" + } + ], + "require-dev": { + "phpcompatibility/php-compatibility": "^9.2" + }, "autoload": { "files": [ "registration.php" ], "psr-4": { diff --git a/dev/cloud/setup.sh b/dev/cloud/setup.sh new file mode 100755 index 000000000..d4217fccb --- /dev/null +++ b/dev/cloud/setup.sh @@ -0,0 +1,18 @@ +#! /usr/bin/env bash + +echo 'Saving APPID...' +php bin/magento config:set algoliasearch_credentials/credentials/application_id "${ALGOLIA_APPLICATION_ID}" +echo 'Saving Search Key...' +php bin/magento config:set algoliasearch_credentials/credentials/search_only_api_key "${ALGOLIA_SEARCH_API_KEY}" +echo 'Saving API key...' +php bin/magento config:set algoliasearch_credentials/credentials/api_key "${ALGOLIA_API_KEY}" +echo 'Saving prefix...' +php bin/magento config:set algoliasearch_credentials/credentials/index_prefix "${MAGENTO_CLOUD_ENVIRONMENT}_" +echo 'Saving enable instant...' +php bin/magento config:set algoliasearch_instant/instant/is_instant_enabled "1" + +php bin/magento indexer:reindex algolia_products +php bin/magento indexer:reindex algolia_categories +php bin/magento indexer:reindex algolia_pages +php bin/magento indexer:reindex algolia_suggestions +php bin/magento indexer:reindex algolia_additional_sections \ No newline at end of file diff --git a/dev/cloud/teardown.php b/dev/cloud/teardown.php new file mode 100644 index 000000000..f86225a57 --- /dev/null +++ b/dev/cloud/teardown.php @@ -0,0 +1,43 @@ +getObjectManager(); + +$algoliaHelper = $objectManager->get('\Algolia\AlgoliaSearch\Helper\AlgoliaHelper'); +$indexNamePrefix = getenv('MAGENTO_CLOUD_ENVIRONMENT'); + +/** + * @param $algoliaHelper Algolia\AlgoliaSearch\Helper\AlgoliaHelper + * @param array $indices + */ +function deleteIndexes($algoliaHelper, array $indices, $indexNamePrefix) +{ + foreach ($indices['items'] as $index) { + $name = $index['name']; + + if (mb_strpos($name, $indexNamePrefix) === 0) { + try { + $algoliaHelper->deleteIndex($name); + echo 'Index "' . $name . '" has been deleted.'; + echo "\n"; + } catch (Exception $e) { + // Might be a replica + } + } + } +} + +if ($algoliaHelper) { + $indices = $algoliaHelper->listIndexes(); + if (count($indices) > 0) { + deleteIndexes($algoliaHelper, $indices, $indexNamePrefix); + } + + $replicas = $algoliaHelper->listIndexes(); + if (count($replicas) > 0) { + deleteIndexes($algoliaHelper, $replicas, $indexNamePrefix); + } +} diff --git a/etc/acl.xml b/etc/acl.xml index 85a4f23d8..2192aaf94 100644 --- a/etc/acl.xml +++ b/etc/acl.xml @@ -4,7 +4,14 @@ - + + + + + + + + diff --git a/etc/adminhtml/events.xml b/etc/adminhtml/events.xml index 91fa720f0..6c5c0350d 100755 --- a/etc/adminhtml/events.xml +++ b/etc/adminhtml/events.xml @@ -41,6 +41,10 @@ + + + + diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml index 680e6f993..dab6c64f2 100755 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -248,6 +248,20 @@ ]]> + + + Magento\Config\Model\Config\Source\Yesno + + If you turn on this feature, please make sure that every attribute you choose in the facet listing below is configured as "Use in Layered Navigation" for categories and "Use in Search Result Layered Navigation" for Catalog Search in Stores > Attributes > Product > "Storefront Properties" panel of attribute configuration. + ]]> + + + 0 + + @@ -267,14 +281,11 @@ The number of products displayed on the instant search results page. Default value is 9. ]]> - - 1 - Algolia\AlgoliaSearch\Model\Source\Facets - Magento\Config\Model\Config\Backend\Serialized\ArraySerialized + Algolia\AlgoliaSearch\Model\Backend\Facets @@ -295,9 +306,6 @@ ]]> - - 1 - validate-digits @@ -307,9 +315,6 @@ The number of values each facet can display. Default value is 10. ]]> - - 1 - @@ -322,9 +327,6 @@ Read more in documentation. ]]> - - 1 - diff --git a/etc/di.xml b/etc/di.xml index 6529f80c8..56cc975f0 100755 --- a/etc/di.xml +++ b/etc/di.xml @@ -7,7 +7,7 @@ - + diff --git a/etc/frontend/di.xml b/etc/frontend/di.xml index f25e6abdf..3b67f060a 100644 --- a/etc/frontend/di.xml +++ b/etc/frontend/di.xml @@ -16,4 +16,71 @@ + + + + + Algolia\AlgoliaSearch\Model\Layer\Filter\Item\Attribute + + + + + + Algolia\AlgoliaSearch\Model\Layer\Filter\Item\AttributeFactory + + + + + + Algolia\AlgoliaSearch\Model\Layer\Category\FilterableAttributeList + + Algolia\AlgoliaSearch\Model\Layer\Filter\Attribute + Algolia\AlgoliaSearch\Model\Layer\Filter\Price + Algolia\AlgoliaSearch\Model\Layer\Filter\Decimal + Algolia\AlgoliaSearch\Model\Layer\Filter\Category + + + + + + + Algolia\AlgoliaSearch\Model\Layer\Search\FilterableAttributeList + + Algolia\AlgoliaSearch\Model\Layer\Filter\Attribute + Algolia\AlgoliaSearch\Model\Layer\Filter\Price + Algolia\AlgoliaSearch\Model\Layer\Filter\Decimal + Algolia\AlgoliaSearch\Model\Layer\Filter\Category + + + + + + + categoryFilterList + + + + + + searchFilterList + + + + + + + + + + + + + + + + + + + + diff --git a/etc/module.xml b/etc/module.xml index 15db6cbba..97a40cefd 100755 --- a/etc/module.xml +++ b/etc/module.xml @@ -3,6 +3,12 @@ + + + + + + \ No newline at end of file diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 000000000..9c3d9e4b2 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,8 @@ +parameters: + level: 0 + excludes_analyse: + - %currentWorkingDirectory%/vendor/* + - %currentWorkingDirectory%/Test/* + ignoreErrors: + - '#(class|type) Magento\\\S*Factory#i' + - '#(class|type) Algolia\\\S*Factory#i' diff --git a/view/adminhtml/layout/adminhtml_system_config_edit.xml b/view/adminhtml/layout/adminhtml_system_config_edit.xml new file mode 100644 index 000000000..7254a904f --- /dev/null +++ b/view/adminhtml/layout/adminhtml_system_config_edit.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/view/adminhtml/templates/common.phtml b/view/adminhtml/templates/common.phtml index 626bb11bd..a80754eb8 100755 --- a/view/adminhtml/templates/common.phtml +++ b/view/adminhtml/templates/common.phtml @@ -6,12 +6,14 @@ $viewModel = $this->getViewModel(); $isClickAnalyticsEnabled = true; +$isClickAnalyticsTurnedOnInAdmin = true; $isQueryRulesEnabled = true; $section = $this->getRequest()->getParam('section'); if ($section === 'algoliasearch_cc_analytics') { $isClickAnalyticsEnabled = $viewModel->isClickAnalyticsEnabled(); + $isClickAnalyticsTurnedOnInAdmin = $viewModel->isClickAnalyticsTurnedOnInAdmin(); } if ($section === 'algoliasearch_instant') { @@ -38,6 +40,7 @@ $linksConfig = $viewModel->getLinksConfig($section); - -
- +
+
-
-
-
-
- Documentation -
+
-
- The official Algolia for Magento documentation -
+
+ + + +
+
-
+
+
+
+
+ Documentation +
- +
+ The official Algolia for Magento documentation +
+
- -
+
-
-
-
- Community Forum -
+ -
- Questions and answers from the Algolia community +
-
- -
- +
+
+
+ Community Forum +
- -
-
+
+ Questions and answers from the Algolia community +
+
-
- +
+
+
+ Documentation - - - - - Browse more forum topics ... - -
-
-
- Video Tutorials +
+
+ Community Forum -
- Video Tutorials -
+
+ Community Forum +
-
- An introduction to the Magento extension in video -
-
-
- -
- Discover the extension +
+ Questions and answers from the Algolia community +
-
- Discover the extension + - - -
- Understand Indexing queue -
-
- Understand Indexing Queue -
-
- - + +
+
+ Video Tutorials + +
+ Video Tutorials +
+ +
+ An introduction to the Magento extension in video +
-
+
- - View more tutorials ... - +
+ getContactHtml(); ?>
-
-
- isExtensionSupportEnabled()): ?> - Didn't find what you were looking for? - Contact us. - - Consider updating your plan - to get direct and fast email help by Algolia experts. -
diff --git a/view/adminhtml/web/css/common.css b/view/adminhtml/web/css/common.css index 4b415db79..9f83a4311 100644 --- a/view/adminhtml/web/css/common.css +++ b/view/adminhtml/web/css/common.css @@ -418,3 +418,18 @@ .algolia-clearfix { clear: both; } + +/* Warning blocks on configuration */ +.algolia_dashboard_warning { + background-image: url("../images/icon-magento-settings.svg"); + background-repeat: no-repeat; + background-position: 25px 20px; + background-color : #FDFAF5; + border : 1px solid #F8E5C4; + padding : 20px 20px 10px 90px; + margin-bottom : 20px; +} + +.algolia_dashboard_warning_page { + margin-bottom : 0; +} diff --git a/view/adminhtml/web/css/merchandising.css b/view/adminhtml/web/css/merchandising.css index 0a0fa2559..75af9b543 100644 --- a/view/adminhtml/web/css/merchandising.css +++ b/view/adminhtml/web/css/merchandising.css @@ -47,7 +47,7 @@ .algoliasearch-merchandising-options { max-width: 1400px; - margin: 70px auto 75px; + margin: 40px auto 75px; } } diff --git a/view/adminhtml/web/css/support.css b/view/adminhtml/web/css/support.css index 7dd20b058..811a894fa 100644 --- a/view/adminhtml/web/css/support.css +++ b/view/adminhtml/web/css/support.css @@ -319,3 +319,98 @@ a.footer { .contact_results .ais-hits__empty { font-size: 12px; } + +/* Tab display */ +#algolia_support_tab ul { + margin: 0; + padding: 0; +} + +#algolia_support_tab li { + display: inline-block; + float: none; + background: #e3e3e3; + border: 0.1rem solid #adadad; + border-bottom-color: #e3e3e3; + letter-spacing: .0183em; + list-style: none; + margin-right: .4rem; +} +#algolia_support_tab li.tab-active { + background: #ffffff; + border-bottom: 0; + font-weight: 600; + letter-spacing: normal; + margin-bottom: -.1rem; +} + +#algolia_support_tab li a { + color: #41362f; + display: block; + padding: 1.5rem 1.8rem 1.3rem; + text-decoration: none; +} + +#algolia_support_tab li.tab-active a { + border-bottom: 0.2rem solid #ffffff; + border-top: 0.4rem solid #eb5202; + padding-top: 1.1rem; +} + +#algolia_support_tab_content { + border-top: 1px solid #adadad; + padding-top: 20px; +} + +#algolia-contact-panel { + display: none; +} + +/* access denied */ +.algolia-suggestions-header { + width : 100%; + background-color : #F5FAFD; + border : 1px solid #E2F0FA; + padding : 20px; + margin : 5px 0 20px; + position:relative; +} + +.algolia-suggestions-header .book{ + float : left; + margin-right : 20px; + margin-top: 5px; +} + +.algolia-suggestions-header p{ + margin-left : 70px; +} + +.algolia-denied-page { + width : 50%; + margin: 50px auto; + text-align: center; +} + +.algolia-denied-page img{ + margin : 40px 0 40px; +} + +.algolia-denied-page .algolia-suggestions-header{ + text-align: left; + padding : 25px; +} + +.algolia-suggestions-header-wbg{ + background: none; + width : auto; +} + +.algolia-suggestions-header-wbg p{ + font-size : 1.6rem; +} + +.algolia-suggestions-header-wbg .stars{ + float: left; + margin: 0 20px 0 0; +} diff --git a/view/adminhtml/web/images/icon-magento-settings.svg b/view/adminhtml/web/images/icon-magento-settings.svg new file mode 100644 index 000000000..5cdcff287 --- /dev/null +++ b/view/adminhtml/web/images/icon-magento-settings.svg @@ -0,0 +1,19 @@ + + + + Group 3 + Created with Sketch. + + + + + + + + + + + + + + \ No newline at end of file diff --git a/view/adminhtml/web/images/illu-help.svg b/view/adminhtml/web/images/illu-help.svg new file mode 100644 index 000000000..e61469439 --- /dev/null +++ b/view/adminhtml/web/images/illu-help.svg @@ -0,0 +1,23 @@ + + + + illu - help + Created with Sketch. + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/view/adminhtml/web/js/config.js b/view/adminhtml/web/js/config.js new file mode 100644 index 000000000..2ef66b1b5 --- /dev/null +++ b/view/adminhtml/web/js/config.js @@ -0,0 +1,88 @@ +require( + [ + 'jquery', + 'mage/translate', + ], + function ($) { + + addDashboardWarnings(); + + function addDashboardWarnings() { + // rows + var rowIds = [ + '#row_algoliasearch_instant_instant_facets', + '#row_algoliasearch_instant_instant_max_values_per_facet' + ]; + + var rowWarning = '
'; + rowWarning += '

This setting is also available in the Algolia Dashboard. We advise you to manage it from this page, because saving Magento settings will override the Algolia settings.

'; + rowWarning += '
'; + + for (var i=0; i < rowIds.length; i++) { + var element = $(rowIds[i]); + if (element.length > 0) { + element.find('.value').prepend(rowWarning); + } + } + + // pages + var pageIds = [ + '#algoliasearch_products_products', + '#algoliasearch_categories_categories', + '#algoliasearch_synonyms_synonyms_group', + '#algoliasearch_extra_settings_extra_settings' + ]; + + var pageWarning = '
'; + pageWarning += '

These settings are also available in the Algolia Dashboard. We advise you to manage it from this page, because saving Magento settings will override the Algolia settings.

'; + pageWarning += '
'; + + for (var i=0; i < pageIds.length; i++) { + var element = $(pageIds[i]); + if (element.length > 0) { + element.find('.comment').append(pageWarning); + } + } + } + + if ($('#algoliasearch_instant_instant_facets').length > 0) { + var addButton = $('#algoliasearch_instant_instant_facets tfoot .action-add'); + addButton.on('click', function(){ + handleFacetQueryRules(); + }); + + handleFacetQueryRules(); + } + + function handleFacetQueryRules() { + var facets = $('#algoliasearch_instant_instant_facets tbody tr'); + + for (var i=0; i < facets.length; i++) { + var rowId = $(facets[i]).attr('id'); + var searchableSelect = $('select[name="groups[instant][fields][facets][value][' + rowId + '][searchable]"]'); + + searchableSelect.on('change', function(){ + configQrFromSearchableSelect($(this)); + }); + + configQrFromSearchableSelect(searchableSelect); + } + } + + function configQrFromSearchableSelect(searchableSelect) { + var rowId = searchableSelect.parent().parent().attr('id'); + var qrSelect = $('select[name="groups[instant][fields][facets][value][' + rowId + '][create_rule]"]'); + if (qrSelect.length > 0) { + if (searchableSelect.val() == "2") { + qrSelect.val('2'); + qrSelect.attr('disabled','disabled'); + } else { + qrSelect.removeAttr('disabled'); + } + } else { + $('#row_algoliasearch_instant_instant_facets .algolia_block').hide(); + } + } + + } +); diff --git a/view/adminhtml/web/js/support.js b/view/adminhtml/web/js/support.js index ec12d2f3c..f002b09cb 100644 --- a/view/adminhtml/web/js/support.js +++ b/view/adminhtml/web/js/support.js @@ -10,7 +10,11 @@ requirejs(['algoliaAdminBundle'], function(algoliaBundle) { if ($('.algolia_contact_form #subject').length > 0) { initContactDocSearch(); } - + + if ($('#algolia_support_tab').length > 0) { + handleSupportTabs(); + } + function initDocSearch() { const documentationSearch = algoliaBundle.instantsearch({ appId: 'BH4D9OD16A', @@ -223,6 +227,30 @@ requirejs(['algoliaAdminBundle'], function(algoliaBundle) { $results.show(); $landing.hide(); } + + function handleSupportTabs() { + const supportTabs = $('#algolia_support_tab li'); + const supportPanels = $('.algolia_support_panel'); + + supportTabs.each(function(index){ + $(this).on('click', function(){ + resetTabs(supportTabs, supportPanels); + $(this).addClass('tab-active'); + var divToShow = '#' + $(this).attr('aria-controls'); + $(divToShow).show(); + }); + }); + } + + function resetTabs(supportTabs, supportPanels) { + supportTabs.each(function(index){ + $(this).removeClass('tab-active'); + }); + + supportPanels.each(function(index){ + $(this).hide(); + }); + } }); function handleLatestVersion($) { diff --git a/view/frontend/layout/catalog_category_view_type_layered.xml b/view/frontend/layout/catalog_category_view_type_layered.xml new file mode 100644 index 000000000..7af68be36 --- /dev/null +++ b/view/frontend/layout/catalog_category_view_type_layered.xml @@ -0,0 +1,10 @@ + + + + + + Algolia_AlgoliaSearch::layer/view.phtml + + + + diff --git a/view/frontend/layout/catalogsearch_result_index.xml b/view/frontend/layout/catalogsearch_result_index.xml new file mode 100644 index 000000000..4236dd49c --- /dev/null +++ b/view/frontend/layout/catalogsearch_result_index.xml @@ -0,0 +1,10 @@ + + + + + + Algolia_AlgoliaSearch::layer/view.phtml + + + + diff --git a/view/frontend/requirejs-config.js b/view/frontend/requirejs-config.js index 8efb037bd..aadd5ede6 100644 --- a/view/frontend/requirejs-config.js +++ b/view/frontend/requirejs-config.js @@ -1,6 +1,7 @@ var config = { 'paths': { 'algoliaBundle': 'Algolia_AlgoliaSearch/internals/algoliaBundle.min', - 'algoliaAnalytics': 'Algolia_AlgoliaSearch/internals/search-insights' + 'algoliaAnalytics': 'Algolia_AlgoliaSearch/internals/search-insights', + 'rangeSlider': 'Algolia_AlgoliaSearch/navigation/range-slider-widget' } }; diff --git a/view/frontend/templates/layer/filter/category.phtml b/view/frontend/templates/layer/filter/category.phtml new file mode 100644 index 000000000..dfc28b3a3 --- /dev/null +++ b/view/frontend/templates/layer/filter/category.phtml @@ -0,0 +1,21 @@ +
    + +
  1. + getCount() > 0): ?> + + getLabel() ?> + helper('\Magento\Catalog\Helper\Data')->shouldDisplayProductCountOnLayer()): ?> + getCount() ?> + getCount() == 1):?> + + + + getLabel() ?> + helper('\Magento\Catalog\Helper\Data')->shouldDisplayProductCountOnLayer()): ?> + getCount() ?> + getCount() == 1):?> + + +
  2. + +
diff --git a/view/frontend/templates/layer/filter/default.phtml b/view/frontend/templates/layer/filter/default.phtml new file mode 100644 index 000000000..dfc28b3a3 --- /dev/null +++ b/view/frontend/templates/layer/filter/default.phtml @@ -0,0 +1,21 @@ +
    + +
  1. + getCount() > 0): ?> + + getLabel() ?> + helper('\Magento\Catalog\Helper\Data')->shouldDisplayProductCountOnLayer()): ?> + getCount() ?> + getCount() == 1):?> + + + + getLabel() ?> + helper('\Magento\Catalog\Helper\Data')->shouldDisplayProductCountOnLayer()): ?> + getCount() ?> + getCount() == 1):?> + + +
  2. + +
diff --git a/view/frontend/templates/layer/filter/js-default.phtml b/view/frontend/templates/layer/filter/js-default.phtml new file mode 100644 index 000000000..015214517 --- /dev/null +++ b/view/frontend/templates/layer/filter/js-default.phtml @@ -0,0 +1,13 @@ +getFilter()->getRequestVar() . 'Filter'; +?> + +
+ +
+ + + + \ No newline at end of file diff --git a/view/frontend/templates/layer/filter/slider.phtml b/view/frontend/templates/layer/filter/slider.phtml new file mode 100644 index 000000000..f17f9461e --- /dev/null +++ b/view/frontend/templates/layer/filter/slider.phtml @@ -0,0 +1,16 @@ +
+
+
+
+
+
+ + + +
+
+
+ + diff --git a/view/frontend/templates/layer/view.phtml b/view/frontend/templates/layer/view.phtml new file mode 100644 index 000000000..0649850d0 --- /dev/null +++ b/view/frontend/templates/layer/view.phtml @@ -0,0 +1,43 @@ +canShowBlock()) : ?> +
+ getLayer()->getState()->getFilters()) ?> +
+ +
+ +
+ getLayer()->getState()->getFilters()) : ?> +
+ +
+ + getChildHtml('state') ?> + + + getFilters()))); ?> + getFilters() as $filter) : ?> + getItemsCount()) : ?> + +
+ + +
+
getName()) ?>
+
getChildBlock('renderer')->render($filter) ?>
+
+ + + +
+ + + +
+
+ \ No newline at end of file diff --git a/view/frontend/web/instantsearch.js b/view/frontend/web/instantsearch.js index 27d96d00d..98234abe2 100644 --- a/view/frontend/web/instantsearch.js +++ b/view/frontend/web/instantsearch.js @@ -70,7 +70,7 @@ requirejs(['algoliaBundle','Magento_Catalog/js/price-utils'], function(algoliaBu * Docs: https://community.algolia.com/instantsearch.js/ **/ - var ruleContexts = ['']; // Empty context to keep BC for already create rules in dashboard + var ruleContexts = ['magento_filters', '']; // Empty context to keep BC for already create rules in dashboard if (algoliaConfig.request.categoryId.length > 0) { ruleContexts.push('magento-category-' + algoliaConfig.request.categoryId); } @@ -175,7 +175,7 @@ requirejs(['algoliaBundle','Magento_Catalog/js/price-utils'], function(algoliaBu }, render: function (data) { if (!algoliaConfig.isSearchPage) { - if (data.results.query.length === 0) { + if (data.results.query.length === 0 && data.results.nbHits === 0) { $('.algolia-instant-replaced-content').show(); $('.algolia-instant-selector-results').hide(); } diff --git a/view/frontend/web/internals/algoliasearch.css b/view/frontend/web/internals/algoliasearch.css index bf5d5b2d7..a1e4673b6 100755 --- a/view/frontend/web/internals/algoliasearch.css +++ b/view/frontend/web/internals/algoliasearch.css @@ -1288,4 +1288,162 @@ a.ais-current-refined-values--link:hover { #algolia-banner img { padding-top: 15px; +} + +/** BACKEND RENDERING CSS */ + +.algolia-filter-list .filter-actions { + margin-bottom : 0; + float: right; + margin-top: 8px; +} + +.algolia-filter-list .filter-current { + border: solid 1px #efefef; + margin-bottom: 15px; +} + +.algolia-filter-list .filter-current .item { + display: inline-block; + background-color: #f4f4f4; + padding: 5px 7px 5px 35px; + margin: 5px 0; + border: solid 1px #DDDDDD; + border-radius: 2px; + color: #636363; +} + +.algolia-filter-list .filter-current .block-subtitle { + color: #757575; + font-weight: 500; + text-transform: uppercase; + background-color: #f4f4f4; +} + +.algolia-filter-list .filter-current .action.remove { + background-color: #DDDDDD; +} + +.algolia-filter-list .filter-current .action.remove:hover { + background-color: #DDDDDD; +} + +.algolia-filter-list .filter-current .action.remove::before { + font-size: 18px; + margin: 8px 5px; + font-weight: 600; +} + +.algolia-filter-list .filter-options-content{ + padding: 5px; +} + +.algolia-filter-list .filter-options-title{ + background-color: #f4f4f4; + color: #757575; + font-weight: 500; + padding: 4px 40px 4px 10px; +} + +.algolia-filter-list .filter-options-item{ + border: solid 1px #efefef; + margin-bottom: 15px; + padding-bottom: 0; +} + +.algolia-filter-list .filter-options-title::after { + top: 5px; +} + +.algolia-filter-list .items .item { + position: relative; +} + +.algolia-filter-list .items .item .filter-label, +.algolia-filter-list .items .item .filter-value { + color: #006bb4; +} + +.algolia-filter-list .items .item a label { + cursor: pointer; +} + +.algolia-filter-list .items .item a:hover { + background-color: white; +} + +.algolia-filter-list .items .item a:hover label span:first-child { + text-decoration: underline; +} + +.algolia-filter-list .items .item input { + top : 0; +} + +.algolia-filter-list .items .item a .count { + position : absolute; + right: 0; + font-weight: 500; +} + +.algolia-filter-list .items .item a .count::before, +.algolia-filter-list .items .item a .count::after { + content : ''; +} + +/* SLIDER */ +.algolia-filter-list .algolia-range-slider [data-role=from-label] { + display: block; + float: left; + padding: 5px 0 10px; + font-size: .8em; + min-width: 20px; + font-weight: 400; + text-align: center; +} + +.algolia-filter-list .algolia-range-slider [data-role=to-label] { + display: block; + float: right; + padding: 5px 0 10px; + font-size: .8em; + min-width: 20px; + font-weight: 400; + text-align: center; +} + +.algolia-filter-list .algolia-range-slider .actions-toolbar { + margin: 17px 8px 5px; + display: block; +} + +.algolia-filter-list .algolia-range-slider .actions-primary { + float: right; +} + +.algolia-filter-list .algolia-range-slider .actions-primary .primary { + padding: 5px; +} + +.algolia-filter-list .algolia-range-slider .ui-slider { + height: 2px; + margin: 10px; + clear: both; + background-color: #F3F4F7; + border: 1px solid #DDD; +} + +.algolia-filter-list .algolia-range-slider .ui-slider-handle { + background-color: grey; + padding: 0; + margin: -8px 0 0 -10px; + border-radius: 50%; + width: 18px; + height: 18px; + background-color: #FFFFFF; + border: 1px solid #c8c8c8; +} + +.algolia-rendering .sorter-action { + display: none; } \ No newline at end of file diff --git a/view/frontend/web/navigation/attribute-filter.js b/view/frontend/web/navigation/attribute-filter.js new file mode 100644 index 000000000..90b336a2c --- /dev/null +++ b/view/frontend/web/navigation/attribute-filter.js @@ -0,0 +1,138 @@ +define([ + 'jquery', + 'uiComponent', + 'underscore', + 'mage/translate' +], function ($, Component, _) { + "use strict"; + + return Component.extend({ + defaults: { + template: "Algolia_AlgoliaSearch/attribute-filter", + noResultLabel : $.mage.__("No results.") + }, + + /* Initialization */ + initialize: function () { + this._super(); + this.expanded = false; + this.items = this.items.map(this.addItemId.bind(this)); + this.observe(['fulltextSearch', 'expanded']); + + var lastSelectedIndex = Math.max.apply(null, (this.items.map( + function (v, k) {return v['is_selected'] ? k : 0;})) + ); + this.maxSize = Math.max(this.maxSize, lastSelectedIndex + 1); + + this.initPlaceholder(); + this.onShowLess(); + this.searchActive = this.items.length > this.maxSize; + }, + + /* Placeholder initialization */ + initPlaceholder: function () { + this.searchPlaceholder = $('
').html($.mage.__('Search for other ...')).text(); + }, + + /* Behaviour while typing in the search input */ + onSearchChange: function (component, ev) { + var text = ev.target.value; + if (text.trim() === "") { + component.fulltextSearch(null); + component.onShowLess(); + } else { + component.fulltextSearch(text); + component.onShowMore(); + } + return true; + }, + + /* Reset value on focus if search is considered as empty */ + onSearchFocusOut: function(component, ev) { + var text = ev.target.value; + if (text.trim() === "") { + component.fulltextSearch(null); + ev.target.value = ""; + } + }, + + /* Get additional results */ + loadAdditionalItems: function (callback) { + $.get(this.ajaxLoadUrl, function (data) { + this.items = data.map(this.addItemId.bind(this)); + this.hasMoreItems = false; + + if (callback) { + return callback(); + } + }.bind(this)); + }, + + /* Get items list */ + getDisplayedItems: function () { + var items = this.items; + + if (this.expanded() === false) { + items = this.items.slice(0, this.maxSize); + } + + if (this.fulltextSearch()) { + var searchTokens = this.slugify(this.fulltextSearch()).split('-'); + var lastSearchToken = searchTokens.splice(-1, 1)[0]; + + items = items.filter(function(item) { + var isValidItem = true; + var itemTokens = this.slugify(item.label).split('-'); + searchTokens.forEach(function(currentToken) { + if (itemTokens.indexOf(currentToken) === -1) { + isValidItem = false; + } + }) + if (isValidItem && lastSearchToken) { + var ngrams = itemTokens.map(function(token) {return token.substring(0, lastSearchToken.length)}); + isValidItem = ngrams.indexOf(lastSearchToken) !== -1; + } + return isValidItem; + }.bind(this)) + } + + return items; + }, + + /* Check if search has results */ + hasSearchResult: function () { + return this.getDisplayedItems().length > 0 + }, + + /* Get No Search result message */ + getNoResultMessage : function() { + return this.noResultLabel; + }, + + /* Callback when list is being populated */ + onShowMore: function () { + if (this.hasMoreItems) { + this.loadAdditionalItems(this.onShowMore.bind(this)); + } else { + this.expanded(true); + } + }, + + /* Callback when list is refined */ + onShowLess: function () { + this.expanded(false); + }, + + /* Slugify search */ + slugify: function(text) { + return text.toString().toLowerCase().replace(/\s+/g, '-').replace(/[^\w\-]+/g, '').replace(/\-\-+/g, '-').replace(/^-+/, '') + }, + + /* Add id to item list. */ + addItemId: function (item) { + item.id = _.uniqueId(this.index + "_option_"); + item.displayProductCount = this.displayProductCount && (item.count >= 1) + return item; + }, + }); +}); diff --git a/view/frontend/web/navigation/range-slider-widget.js b/view/frontend/web/navigation/range-slider-widget.js new file mode 100644 index 000000000..4bbd39340 --- /dev/null +++ b/view/frontend/web/navigation/range-slider-widget.js @@ -0,0 +1,84 @@ +define([ + "jquery", + 'Magento_Catalog/js/price-utils', + 'mage/template', + "jquery/ui" +], function ($, priceUtil, mageTemplate) { + + "use strict"; + + $.widget('algolia.rangeSlider', { + + options: { + fromLabel : '[data-role=from-label]', + toLabel : '[data-role=to-label]', + sliderBar : '[data-role=slider-bar]', + applyButton : '[data-role=apply-range]', + rate : 1.0000 + }, + + _create: function () { + this._initSliderValues(); + this._createSlider(); + this._refreshDisplay(); + this.element.find(this.options.applyButton).bind('click', this._applyRange.bind(this)); + }, + + _initSliderValues: function() { + this.rate = parseFloat(this.options.rate); + this.from = Math.floor(this.options.currentValue.from * this.rate); + this.to = Math.round(this.options.currentValue.to * this.rate); + this.minValue = Math.floor(this.options.minValue * this.rate); + this.maxValue = Math.round(this.options.maxValue * this.rate); + }, + + _createSlider: function() { + this.element.find(this.options.sliderBar).slider({ + range: true, + min: this.minValue, + max: this.maxValue, + values: [ this.from, this.to ], + slide: this._onSliderChange.bind(this), + step: this.options.step + }); + }, + + _onSliderChange : function (ev, ui) { + this.from = ui.values[0]; + this.to = ui.values[1]; + this._refreshDisplay(); + }, + + _refreshDisplay: function() { + + if (this.element.find('[data-role=from-label]')) { + this.element.find('[data-role=from-label]').html(this._formatLabel(this.from)); + } + + if (this.element.find('[data-role=to-label]')) { + this.element.find('[data-role=to-label]').html(this._formatLabel(this.to)); + } + }, + + _applyRange : function () { + var range = { + from : this.from * (1 / this.rate), + to : this.to * (1 / this.rate) + }; + var url = mageTemplate(this.options.urlTemplate)(range); + this.element.find(this.options.applyButton).attr('href', url); + }, + + _formatLabel : function(value) { + var formattedValue = value; + + if (this.options.fieldFormat) { + formattedValue = priceUtil.formatPrice(value, this.options.fieldFormat); + } + + return formattedValue; + } + }); + + return $.algolia.rangeSlider; +}); diff --git a/view/frontend/web/template/attribute-filter.html b/view/frontend/web/template/attribute-filter.html new file mode 100644 index 000000000..931354a96 --- /dev/null +++ b/view/frontend/web/template/attribute-filter.html @@ -0,0 +1,31 @@ + + +
    +
  1. + + + + + +
    + + +
    +
  2. +
+ +
+

+