Skip to content

Commit

Permalink
Merge pull request #10 from develodesign/feature/searchable-attribute…
Browse files Browse the repository at this point in the history
…s-and-autocomplete

Fix autocomplete and searchable attributes
  • Loading branch information
collymore authored May 5, 2023
2 parents 39f9c6d + f0fc00f commit 399c7df
Show file tree
Hide file tree
Showing 8 changed files with 279 additions and 40 deletions.
2 changes: 1 addition & 1 deletion Adapter/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public function deleteIndex(string $indexName): array
*/
public function addData($indexName, $data)
{
$facets = [];
$facets = $this->getFacets();
foreach ($data as &$item) {
$item['id'] = (string)$item['objectID'];
$item['objectID'] = (string)$item['objectID'];
Expand Down
74 changes: 36 additions & 38 deletions Helper/ConfigChangeHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Develo\Typesense\Helper;

use Algolia\AlgoliaSearch\Helper\ConfigHelper;
use Algolia\AlgoliaSearch\Helper\Data as AlgoliaHelper;
use Algolia\AlgoliaSearch\Helper\ConfigHelper as AlgoliaConfigHelper;
use Develo\Typesense\Adapter\Client;
Expand Down Expand Up @@ -136,7 +137,6 @@ public function setCollectionConfig()
unset($existingCollections[$indexName]);
}


$this->typeSenseCollecitons->create(
[
'name' => $indexName,
Expand Down Expand Up @@ -189,7 +189,20 @@ public function getFields(array $facets, array $sortingAttributes, string $index
['name' => 'objectID', 'type' => 'string', 'facet' => true],
['name' => 'categories', 'type' => 'object', 'facet' => true],
['name' => 'visibility_search', 'type' => 'int64'],
['name' => 'visibility_catalog', 'type' => 'int64', 'facet' => true]
['name' => 'visibility_catalog', 'type' => 'int64', 'facet' => true],
[
'name' => 'price_default',
'type' => 'float',
'sort' => true,
'facet' => true
],

[
'name' => 'sku',
'type' => 'string[]',
'facet' => in_array('sku', $facets),
'sort' => in_array('sku', $sortingAttributes)
]
];

// The hierarchal menu widget expects 10 levels of category.
Expand Down Expand Up @@ -222,7 +235,7 @@ public function getFields(array $facets, array $sortingAttributes, string $index

$attributeCodes = [];
foreach ($attributes as $attribute) {
if ($attribute['searchable'] === '1' || in_array($attribute['attribute'], $facets)) {
if ($attribute['searchable'] === '1') {
$attributeCodes[] = $attribute['attribute'];
}
}
Expand All @@ -234,44 +247,19 @@ public function getFields(array $facets, array $sortingAttributes, string $index
$attributeCollection = $this->attributeRepository->getList($entityTypeCode, $searchCriteria->create());

$fields = [];
foreach ($attributeCollection->getItems() as $attribute) {
if ($attribute->getAttributeCode() === 'price') {
$fields[] = [
'name' => $attribute->getAttributeCode(),
'type' => 'object'
];

$fields[] = [
'name' => 'price_default',
'type' => 'float',
'sort' => true
];

continue;
}

if ($attribute->getAttributeCode() === 'sku') {
$fields[] = [
'name' => $attribute->getAttributeCode(),
'type' => 'string[]',
'facet' => in_array($attribute->getAttributeCode(), $facets),
'sort' => in_array($attribute->getAttributeCode(), $sortingAttributes),
];

foreach ($attributeCollection->getItems() as $attribute) {
if (in_array($attribute->getAttributeCode(), ['price', 'sku'])) {
continue;
}

$isFacet = in_array($attribute->getAttributeCode(), $facets);

if (!$isFacet) {
continue;
}

$fields[] = [
'name' => $attribute->getAttributeCode(),
'type' => 'string[]',
'type' => $isFacet ? 'string[]' : 'string',
'facet' => $isFacet,
'sort' => false,
'sort' => in_array($attribute->getAttributeCode(), $sortingAttributes),
'optional' => !$attribute->getIsRequired()
];
}
Expand All @@ -286,14 +274,24 @@ public function getFields(array $facets, array $sortingAttributes, string $index
public function getSearchableAttributes(string $index = self::INDEX_PRODUCTS): string
{
$attributes = [];
foreach ($this->getFields([], [], $index) as $field) {
if (!in_array($field['type'], ['string', 'string[]'])) {
continue;
}
switch ($index) {
case 'products':
$attributes = $this->algoliaConfigHelper->getProductAdditionalAttributes();
break;
case 'categories':
$attributes = $this->algoliaConfigHelper->getCategoryAdditionalAttributes();
break;
case 'pages':
return 'name,slug';
}

$attributes[] = $field['name'];
$searchableAttributes = [];
foreach ($attributes as $attribute) {
if ($attribute['searchable'] === '1') {
$searchableAttributes[] = $attribute['attribute'];
}
}

return implode(',', $attributes);
return implode(',', $searchableAttributes);
}
}
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ For more information on customizing the Algolia module, please refer to the foll
- [Customizing Instant Search Page](https://www.algolia.com/doc/integration/magento-2/customize/instant-search-page/)
- [Customizing Custom Front-end Events](https://www.algolia.com/doc/integration/magento-2/customize/custom-front-end-events/)

When migrating from Algolia, you will need to remove "Price" from the facets and review the Product and Category searchable attributes. Typesense is much more strict when querying so if an attribute does not exist it will throw an error.

Review the following config and set searchable to "No" when applicable:

Settings > Algolia > Products > Attributes

## Debugging config

You may get errors such as:

`pesense-adapter.js:1 Uncaught (in promise) Error: 404 - Could not find a field named "path" in the schema.`

This is because you either have a searchable attribute for products which does not exist, or perhaps a facet attribute which does not exist. You should remove the attribute from these areas and try again.

## Documentation

For more information about Typesense, check out their [official documentation](https://typesense.org/docs/).
Expand Down
6 changes: 6 additions & 0 deletions view/frontend/layout/default.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,10 @@
<head>
<script src="Develo_Typesense::js/magento-adapter.js"/>
</head>

<referenceBlock name="algolia.autocomplete.page">
<action method="setTemplate">
<argument name="template" xsi:type="string">Develo_Typesense::autocomplete/page.phtml</argument>
</action>
</referenceBlock>
</page>
7 changes: 7 additions & 0 deletions view/frontend/requirejs-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
var config = {
map: {
'*': {
'autocomplete': 'Develo_Typesense/js/autocomplete'
}
}
};
212 changes: 212 additions & 0 deletions view/frontend/web/js/autocomplete.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
requirejs([
'algoliaBundle',
'Develo_Typesense/js/typesense.min',
'domReady!'
], function (algoliaBundle, Typesense) {
algoliaBundle.$(function ($) {

const {autocomplete} = algoliaBundle;
const {resultURL} = algoliaConfig;

if (algoliaConfig.autocomplete.nbOfProductsSuggestions > 0) {
algoliaConfig.autocomplete.sections.unshift({
hitsPerPage: algoliaConfig.autocomplete.nbOfProductsSuggestions,
label: algoliaConfig.translations.products,
name: "products"
});
}

if (algoliaConfig.autocomplete.nbOfCategoriesSuggestions > 0) {
algoliaConfig.autocomplete.sections.unshift({
hitsPerPage: algoliaConfig.autocomplete.nbOfCategoriesSuggestions,
label: algoliaConfig.translations.categories,
name: "categories"
});
}

if (algoliaConfig.autocomplete.nbOfQueriesSuggestions > 0) {
algoliaConfig.autocomplete.sections.unshift({
hitsPerPage: algoliaConfig.autocomplete.nbOfQueriesSuggestions,
label: '',
name: "suggestions"
});
}

algoliaConfig.autocomplete.templates = {
products: algoliaBundle.Hogan.compile($('#autocomplete_products_template').html()),
categories: algoliaBundle.Hogan.compile($('#autocomplete_categories_template').html()),
pages: algoliaBundle.Hogan.compile($('#autocomplete_pages_template').html())
};

const getQueryBy = function (name) {
if (
typeof algoliaConfig.typesense_searchable !== 'undefined' &&
typeof algoliaConfig.typesense_searchable[name] !== 'undefined'
) {
return algoliaConfig.typesense_searchable[name];
}

return 'name'
}

// taken from common.js (autocomplete v0) and adopted to autocomplete v1
const getAutocompleteSource = function ({section, setContext}) {
if (section.hitsPerPage <= 0)
return null;

var options = {
hitsPerPage: section.hitsPerPage,
analyticsTags: 'autocomplete',
clickAnalytics: true
};

var source = {};

var templates = {};

switch (section.name) {
case 'products':
options.numericFilters = 'visibility_search=1';
options.ruleContexts = ['magento_filters', '']; // Empty context to keep BC for already create rules in dashboard
break;
case 'categories':
if (algoliaConfig.showCatsNotIncludedInNavigation === false) {
options.numericFilters = 'include_in_menu=1';
}
break;
}

templates = {
header({html}) {
return html`<h3>${section.label}</h3>`;
},
item({item, html}) {
const innerHtml = algoliaConfig.autocomplete.templates[section.name].render(item);

return html`<div dangerouslySetInnerHTML=${{ __html: innerHtml }}></div>`
}
}

source = {
...options,
indexName: algoliaConfig.indexName + "_" + section.name,
name: section.name,
templates
};

return source;
};

const plugins = []

if (window.initExtraAlgoliaConfiguration) {
const {plugins: extraPlugins} = initExtraAlgoliaConfiguration(algoliaConfig)
plugins.push(...extraPlugins)
}

autocomplete({
container: '#algoliaAutocomplete',
placeholder: algoliaConfig.translations.placeholder,
debug: algoliaConfig.autocomplete.isDebugEnabled,
plugins,
detachedMediaQuery: 'none',
onSubmit: (params) => {
window.location.href = `${resultURL}?q=${params.state.query}`
},
classNames: {
list: 'w-full flex flex-wrap py-4 px-2',
item: 'w-full lg:w-1/2 p-2 hover:bg-gray-200',
sourceHeader: 'px-2 py-4 uppercase tracking-widest text-blue-500',
source: 'flex flex-col',
panel: 'mx-4 absolute w-full bg-white z-50 border border-gray-300',
input: 'w-full p-2 text-base lg:text-lg leading-7 tracking-wider border border-gray-300',
form: 'w-full relative flex items-center',
inputWrapper: 'flex-grow px-4',
inputWrapperPrefix: 'hidden',
inputWrapperSuffix: 'hidden',
label: 'm-0 leading-none',
submitButton: 'leading-none'
},
async getSources({query, setContext}) {
/** Setup autocomplete data sources **/
var sources = [];
for (let i = 0; i < algoliaConfig.autocomplete.sections.length; i++) {

let section = algoliaConfig.autocomplete.sections[i];

var source = getAutocompleteSource({section, setContext});

// autocomplete v1 adapter
if (source) {

let typesenseClient = new Typesense.Client(algoliaConfig.typesense.config)

const results = await typesenseClient.collections(source.indexName).documents().search({
q: query,
query_by: getQueryBy(source.name),
per_page: source.hitsPerPage
})

sources.push({
sourceId: source.name,
query,
getItems() {
return results.hits.map(hit => (
hit.document
));
},
templates: source.templates
});
}
}

return sources;
},

render({elements, render, html}, root) {
const {categories, pages, products} = elements;

render(
html`<div class="relative w-full flex flex-col lg:flex-row">
<div class="w-full lg:order-1 lg:border-l-2">${products}</div>
<div class="w-full px-1 pb-10 md:px-2">${categories} ${pages}</div>
</div>`,
root
);
},

renderNoResults({state, render, html}, root) {
const suggestions = [];

if (algoliaConfig.showSuggestionsOnNoResultsPage && algoliaConfig.popularQueries.length > 0) {
algoliaConfig.popularQueries
.slice(0, Math.min(3, algoliaConfig.popularQueries.length))
.forEach(function (query) {
suggestions.push({
url: algoliaConfig.baseUrl + '/catalogsearch/result/?q=' + encodeURIComponent(query),
query
});
});
}

render(html`
<div class="p-4 lg:p-6">
<div class="lx:mb-2">
<span class="pr-1 text-base xl:text-lg font-bold tracking-wide">${algoliaConfig.translations.noProducts}</span>
<span class="text-base font-bold tracking-wide">"${state.query}"</span>
</div>
<div class="see-all">
${(algoliaConfig.showSuggestionsOnNoResultsPage && suggestions.length > 0 ?
html`<div class="py-4 lg:py-6">
<span class="pr-1 text-sm xl:text-base font-bold tracking-wider">${algoliaConfig.translations.popularQueries}</span>
${suggestions.map(({url, query}) => html`<a class="text-sm xl:text-base text-gray-600 tracking-wide font-semibold hover:underline" href="${url}">${query}</a>`)}
</div>` : '')
}
<a class="py-2 text-sm xl:text-base text-gray-600 tracking-wide font-bold hover:underline" href="${algoliaConfig.baseUrl}/catalogsearch/result/?q=__empty__">${algoliaConfig.translations.seeAll}</a>
</div>
</div>`, root);
},
}
);
})
});
2 changes: 1 addition & 1 deletion view/frontend/web/js/internals/autocompleteConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ const initAutoComplete = () => {

autocomplete({
container: '#algolia-autocomplete-container',
placeholder: algoliaConfig.placeholder,
placeholder: algoliaConfig.translations.placeholder,
debug: algoliaConfig.autocomplete.isDebugEnabled,
plugins,
detachedMediaQuery: 'none',
Expand Down
2 changes: 2 additions & 0 deletions view/frontend/web/js/typesense.min.js

Large diffs are not rendered by default.

0 comments on commit 399c7df

Please sign in to comment.