-
Notifications
You must be signed in to change notification settings - Fork 85
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
--------- Co-authored-by: adriendupuis <[email protected]> Co-authored-by: Tomasz Dąbrowski <[email protected]> Co-authored-by: Marek Nocoń <[email protected]>
- Loading branch information
1 parent
7384530
commit 454b71e
Showing
10 changed files
with
392 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
|
||
const ibexaConfigManager = require('./ibexa.webpack.config.manager.js'); | ||
|
||
ibexaConfigManager.add({ | ||
ibexaConfig, | ||
entryName: 'ibexa-admin-ui-layout-js', | ||
newItems: [ path.resolve(__dirname, './assets/js/admin.search.autocomplete.product.js'), ], | ||
}); |
19 changes: 19 additions & 0 deletions
19
code_samples/back_office/search/assets/js/admin.search.autocomplete.product.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
(function (global, doc, ibexa, Routing) { | ||
const renderItem = (result, searchText) => { | ||
const globalSearch = doc.querySelector('.ibexa-global-search'); | ||
const {highlightText} = ibexa.helpers.highlight; | ||
const autocompleteHighlightTemplate = globalSearch.querySelector('.ibexa-global-search__autocomplete-list').dataset.templateHighlight; | ||
const {getContentTypeIconUrl, getContentTypeName} = ibexa.helpers.contentType; | ||
|
||
const autocompleteItemTemplate = globalSearch.querySelector('.ibexa-global-search__autocomplete-product-template').dataset.templateItem; | ||
|
||
return autocompleteItemTemplate | ||
.replace('{{ productHref }}', Routing.generate('ibexa.product_catalog.product.view', {productCode: result.productCode})) | ||
.replace('{{ productName }}', highlightText(searchText, result.name, autocompleteHighlightTemplate)) | ||
.replace('{{ productCode }}', result.productCode) | ||
.replace('{{ productTypeIconHref }}', getContentTypeIconUrl(result.productTypeIdentifier)) | ||
.replace('{{ productTypeName }}', result.productTypeName); | ||
}; | ||
|
||
ibexa.addConfig('autocomplete.renderers.product', renderItem, true); | ||
})(window, document, window.ibexa, window.Routing); |
28 changes: 28 additions & 0 deletions
28
code_samples/back_office/search/config/append_to_services.yaml
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
services: | ||
|
||
App\EventSubscriber\MySuggestionEventSubscriber: ~ | ||
|
||
App\Search\Serializer\Normalizer\Suggestion\ProductSuggestionNormalizer: | ||
autoconfigure: false | ||
|
||
app.search.suggestion.serializer: | ||
decorates: ibexa.search.suggestion.serializer | ||
class: Symfony\Component\Serializer\Serializer | ||
autoconfigure: false | ||
arguments: | ||
$normalizers: | ||
- '@App\Search\Serializer\Normalizer\Suggestion\ProductSuggestionNormalizer' | ||
- '@Ibexa\Search\Serializer\Normalizer\Suggestion\ContentSuggestionNormalizer' | ||
- '@Ibexa\Search\Serializer\Normalizer\Suggestion\LocationNormalizer' | ||
- '@Ibexa\Search\Serializer\Normalizer\Suggestion\ParentLocationCollectionNormalizer' | ||
- '@Ibexa\Search\Serializer\Normalizer\Suggestion\SuggestionCollectionNormalizer' | ||
$encoders: | ||
- '@serializer.encoder.json' | ||
|
||
ibexa.search.autocomplete.product_template: | ||
parent: Ibexa\AdminUi\Component\TabsComponent | ||
arguments: | ||
$template: '@@ibexadesign/ui/global_search_autocomplete_product_template.html.twig' | ||
$groupIdentifier: 'global-search-autocomplete-product' | ||
tags: | ||
- { name: ibexa.admin_ui.component, group: global-search-autocomplete-templates } |
83 changes: 83 additions & 0 deletions
83
code_samples/back_office/search/src/EventSubscriber/MySuggestionEventSubscriber.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
<?php declare(strict_types=1); | ||
|
||
namespace App\EventSubscriber; | ||
|
||
use App\Search\Model\Suggestion\ProductSuggestion; | ||
use Ibexa\Contracts\ProductCatalog\ProductServiceInterface; | ||
use Ibexa\Contracts\ProductCatalog\Values\Product\ProductQuery; | ||
use Ibexa\Contracts\ProductCatalog\Values\Product\Query\Criterion; | ||
use Ibexa\Contracts\Search\Event\BuildSuggestionCollectionEvent; | ||
use Ibexa\Contracts\Search\Mapper\SearchHitToContentSuggestionMapperInterface; | ||
use Psr\Log\LoggerAwareInterface; | ||
use Psr\Log\LoggerAwareTrait; | ||
use Symfony\Component\EventDispatcher\EventSubscriberInterface; | ||
|
||
class MySuggestionEventSubscriber implements EventSubscriberInterface, LoggerAwareInterface | ||
{ | ||
use LoggerAwareTrait; | ||
|
||
private ProductServiceInterface $productService; | ||
|
||
private SearchHitToContentSuggestionMapperInterface $contentSuggestionMapper; | ||
|
||
public function __construct( | ||
ProductServiceInterface $productService, | ||
SearchHitToContentSuggestionMapperInterface $contentSuggestionMapper | ||
) { | ||
$this->productService = $productService; | ||
$this->contentSuggestionMapper = $contentSuggestionMapper; | ||
} | ||
|
||
public static function getSubscribedEvents(): array | ||
{ | ||
return [ | ||
BuildSuggestionCollectionEvent::class => ['onBuildSuggestionCollectionEvent', -1], | ||
]; | ||
} | ||
|
||
public function onBuildSuggestionCollectionEvent(BuildSuggestionCollectionEvent $event): BuildSuggestionCollectionEvent | ||
{ | ||
$suggestionQuery = $event->getQuery(); | ||
$suggestionCollection = $event->getSuggestionCollection(); | ||
|
||
$text = $suggestionQuery->getQuery(); | ||
$words = explode(' ', preg_replace('/\s+/', ' ', $text)); | ||
$limit = $suggestionQuery->getLimit(); | ||
|
||
try { | ||
$productQuery = new ProductQuery(null, new Criterion\LogicalOr([ | ||
new Criterion\ProductName(implode(' ', array_map(static function (string $word) { | ||
return "$word*"; | ||
}, $words))), | ||
new Criterion\ProductCode($words), | ||
new Criterion\ProductType($words), | ||
]), [], 0, $limit); | ||
$searchResult = $this->productService->findProducts($productQuery); | ||
|
||
if ($searchResult->getTotalCount()) { | ||
$maxScore = 0.0; | ||
$suggestionsByContentIds = []; | ||
/** @var \Ibexa\Contracts\Search\Model\Suggestion\ContentSuggestion $suggestion */ | ||
foreach ($suggestionCollection as $suggestion) { | ||
$maxScore = max($suggestion->getScore(), $maxScore); | ||
$suggestionsByContentIds[$suggestion->getContent()->id] = $suggestion; | ||
} | ||
|
||
/** @var \Ibexa\ProductCatalog\Local\Repository\Values\Product $product */ | ||
foreach ($searchResult as $product) { | ||
$contentId = $product->getContent()->id; | ||
if (array_key_exists($contentId, $suggestionsByContentIds)) { | ||
$suggestionCollection->remove($suggestionsByContentIds[$contentId]); | ||
} | ||
|
||
$productSuggestion = new ProductSuggestion($maxScore + 1, $product); | ||
$suggestionCollection->append($productSuggestion); | ||
} | ||
} | ||
} catch (\Throwable $throwable) { | ||
$this->logger->error($throwable); | ||
} | ||
|
||
return $event; | ||
} | ||
} |
24 changes: 24 additions & 0 deletions
24
code_samples/back_office/search/src/Search/Model/Suggestion/ProductSuggestion.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
<?php declare(strict_types=1); | ||
|
||
namespace App\Search\Model\Suggestion; | ||
|
||
use Ibexa\Contracts\Search\Model\Suggestion\Suggestion; | ||
use Ibexa\ProductCatalog\Local\Repository\Values\Product; | ||
|
||
class ProductSuggestion extends Suggestion | ||
{ | ||
private Product $product; | ||
|
||
public function __construct( | ||
float $score, | ||
Product $product | ||
) { | ||
parent::__construct($score, $product->getName()); | ||
$this->product = $product; | ||
} | ||
|
||
public function getProduct() | ||
{ | ||
return $this->product; | ||
} | ||
} |
39 changes: 39 additions & 0 deletions
39
...office/search/src/Search/Serializer/Normalizer/Suggestion/ProductSuggestionNormalizer.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
<?php declare(strict_types=1); | ||
|
||
namespace App\Search\Serializer\Normalizer\Suggestion; | ||
|
||
use App\Search\Model\Suggestion\ProductSuggestion; | ||
use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; | ||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; | ||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; | ||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface; | ||
|
||
class ProductSuggestionNormalizer implements | ||
NormalizerInterface, | ||
NormalizerAwareInterface, | ||
CacheableSupportsMethodInterface | ||
{ | ||
use NormalizerAwareTrait; | ||
|
||
public function normalize($object, string $format = null, array $context = []) | ||
{ | ||
/** @var \App\Search\Model\Suggestion\ProductSuggestion $object */ | ||
return [ | ||
'type' => 'product', | ||
'name' => $object->getName(), | ||
'productCode' => $object->getProduct()->getCode(), | ||
'productTypeIdentifier' => $object->getProduct()->getProductType()->getIdentifier(), | ||
'productTypeName' => $object->getProduct()->getProductType()->getName(), | ||
]; | ||
} | ||
|
||
public function supportsNormalization($data, string $format = null) | ||
{ | ||
return $data instanceof ProductSuggestion; | ||
} | ||
|
||
public function hasCacheableSupportsMethod(): bool | ||
{ | ||
return true; | ||
} | ||
} |
20 changes: 20 additions & 0 deletions
20
...office/search/templates/themes/admin/ui/global_search_autocomplete_product_item.html.twig
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
<li class="ibexa-global-search__autocomplete-item"> | ||
<a class="ibexa-global-search__autocomplete-item-link ibexa-link" href="{{ product_href }}"> | ||
<div class="ibexa-global-search__autocomplete-item-name"> | ||
{{ product_name }} | ||
<div class="ibexa-badge"> | ||
{{ product_code }} | ||
</div> | ||
</div> | ||
<div class="ibexa-global-search__autocomplete-item-info"> | ||
<div class="ibexa-global-search__autocomplete-item-content-type-wrapper"> | ||
<svg class="ibexa-icon ibexa-icon--tiny-small"> | ||
<use xlink:href="{{ product_type_icon_href }}"></use> | ||
</svg> | ||
<span class="ibexa-global-search__autocomplete-item-content-type"> | ||
{{ product_type_name }} | ||
</span> | ||
</div> | ||
</div> | ||
</a> | ||
</li> |
10 changes: 10 additions & 0 deletions
10
...ce/search/templates/themes/admin/ui/global_search_autocomplete_product_template.html.twig
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
<div | ||
class="ibexa-global-search__autocomplete-product-template" | ||
data-template-item="{{ include('@ibexadesign/ui/global_search_autocomplete_product_item.html.twig', { | ||
product_href: "{{ productHref }}", | ||
product_name: "{{ productName }}", | ||
product_code: "{{ productCode }}", | ||
product_type_icon_href: "{{ productTypeIconHref }}", | ||
product_type_name: "{{ productTypeName }}" | ||
})|e('html_attr') }}"> | ||
</div> |
160 changes: 160 additions & 0 deletions
160
docs/administration/back_office/customize_search_suggestion.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,160 @@ | ||
--- | ||
description: Customize search suggestion configuration and sources. | ||
--- | ||
|
||
# Customize search suggestion | ||
|
||
In the Back Office, when you start typing in the search field on the top bar, suggestions about what you could be looking for show up directly under the field. | ||
For more information about using this feature to search for content, see [user documentation]([[= user_doc =]]/search/search_for_content). | ||
|
||
## Configuration | ||
|
||
By default, suggestions start showing up after the user types in at least 3 characters, and 5 suggestions are presented. | ||
This can be changed with the following [scoped](multisite_configuration.md#scope) configuration: | ||
|
||
```yaml | ||
ibexa: | ||
system: | ||
<scope>: | ||
search: | ||
min_query_length: 3 | ||
result_limit: 5 | ||
``` | ||
## Add custom suggestion source | ||
You can add a suggestion source by listening or subscribing to `Ibexa\Contracts\Search\Event\BuildSuggestionCollectionEvent`. | ||
During this event, you can add, remove, or replace suggestions by updating its `SuggestionCollection`. | ||
After this event, the suggestion collection is sorted by score and truncated to a number of items set in [`result_limit`](#configuration). | ||
|
||
!!! tip | ||
|
||
You can list listeners and subscribers with the following command: | ||
``` shell | ||
php bin/console debug:event BuildSuggestionCollectionEvent | ||
``` | ||
|
||
The following example is boosting Product suggestions. | ||
It's a subscriber that passes after the default one (because priority is set to zero), adds matching products at a score above the earlier Content suggestions, and avoids duplicates. | ||
|
||
- If the suggestion source finds a number of matching products that is equal or greater than the `result_limit`, only those products end up in the suggestion. | ||
- If it finds less than `result_limit` products, those products are on top of the suggestion, followed by items from another suggestion source until the limit is met. | ||
- If it doesn't find any matching products, only items from the default suggestion source are shown. | ||
|
||
This example event subscriber is implemented in the `src/EventSubscriber/MySuggestionEventSubscriber.php` file. | ||
It uses [`ProductService::findProducts`](product_api.md#products), and returns the received event after having manipulated the `SuggestionCollection`: | ||
|
||
``` php | ||
[[= include_file('code_samples/back_office/search/src/EventSubscriber/MySuggestionEventSubscriber.php') =]] | ||
``` | ||
|
||
To have the logger injected thanks to the `LoggerAwareTrait`, this subscriber must be registered as a service: | ||
|
||
``` yaml | ||
services: | ||
#… | ||
[[= include_file('code_samples/back_office/search/config/append_to_services.yaml', 2, 3) =]] | ||
``` | ||
|
||
To represent the product suggestion data, a `ProductSuggestion` class is created in `src/Search/Model/Suggestion/ProductSuggestion.php`: | ||
|
||
``` php | ||
[[= include_file('code_samples/back_office/search/src/Search/Model/Suggestion/ProductSuggestion.php') =]] | ||
``` | ||
|
||
This representation needs a normalizer to be transformed into a JSON. | ||
`ProductSuggestionNormalizer::supportsNormalization` returns that this normalizer supports `ProductSuggestion`. | ||
`ProductSuggestionNormalizer::normalize` returns an array of scalar values which can be transformed into a JSON object. | ||
Alongside data about the product, this array must have a `type` key, whose value is used later for rendering as an identifier. | ||
In `src/Search/Serializer/Normalizer/Suggestion/ProductSuggestionNormalizer.php`: | ||
|
||
``` php | ||
[[= include_file('code_samples/back_office/search/src/Search/Serializer/Normalizer/Suggestion/ProductSuggestionNormalizer.php') =]] | ||
``` | ||
|
||
This normalizer is added to suggestion normalizers by decorating `ibexa.search.suggestion.serializer` and redefining its list of normalizers: | ||
|
||
``` yaml | ||
services: | ||
#… | ||
[[= include_file('code_samples/back_office/search/config/append_to_services.yaml', 4, 20) =]] | ||
``` | ||
|
||
!!! tip | ||
|
||
At this point, it's possible to test the suggestion JSON. | ||
The route is `/suggestion` with a GET parameter `query` for the searched text. | ||
|
||
For example, log in to the Back Office to have a session cookie, then access the route through the Back Office SiteAccess, such as `<yourdomain>/admin/suggestion?query=platform`. | ||
If you have a product with "platform" in its name, it is returned as the first suggestion. | ||
|
||
A JavaScript renderer displays the normalized product suggestion. | ||
This renderer is wrapped in an immediately executed function. | ||
This wrapping function must define a rendering function and register it as a renderer. | ||
It's registered as `autocomplete.renderers.<type>` by using the type identifier defined in the normalizer. | ||
|
||
```javascript | ||
(function (global, doc, ibexa, Routing) { | ||
const renderItem = (result, searchText) => { | ||
// Compute suggestion item's HTML | ||
return html; | ||
} | ||
ibexa.addConfig('autocomplete.renderers.<type>', renderItem, true); | ||
})(window, document, window.ibexa, window.Routing); | ||
``` | ||
|
||
To fit into the Back Office design, you can take HTML structure and CSS class names from an existing suggestion template `vendor/ibexa/admin-ui/src/bundle/Resources/views/themes/admin/ui/global_search_autocomplete_content_item.html.twig`. | ||
|
||
To allow template override and ease HTML writing, the example is also loading a template to render the HTML. | ||
|
||
Here is a complete `assets/js/admin.search.autocomplete.product.js` from the product suggestion example: | ||
|
||
``` js hl_lines="8" | ||
[[= include_file('code_samples/back_office/search/assets/js/admin.search.autocomplete.product.js') =]] | ||
``` | ||
|
||
To be loaded in the Back Office layout, this file must be added to Webpack entry `ibexa-admin-ui-layout-js`. | ||
At the end of `webpack.config.js`, add it by using `ibexaConfigManager`: | ||
|
||
``` javascript | ||
//… | ||
[[= include_file('code_samples/back_office/search/append_to_webpack.config.js') =]] | ||
``` | ||
|
||
The renderer, `renderItem` function from `admin.search.autocomplete.product.js`, loads an HTML template from a wrapping DOM node [dataset](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset). | ||
This wrapping node exists only once and the renderer loads the template several times. | ||
|
||
The example template for this wrapping node is stored in `templates/themes/admin/ui/global_search_autocomplete_product_template.html.twig` (notice the CSS class name used by the renderer to reach it): | ||
|
||
``` html+twig hl_lines="2 3 9" | ||
[[= include_file('code_samples/back_office/search/templates/themes/admin/ui/global_search_autocomplete_product_template.html.twig') =]] | ||
``` | ||
|
||
- At HTML level, it wraps the product item template in its dataset attribute `data-template-item`. | ||
- At Twig level, it includes the item template, replaces Twig variables with the strings used by the JS renderer, | ||
and passes it to the [`escape` filter](https://twig.symfony.com/doc/3.x/filters/escape.html) with the HTML attribute strategy. | ||
|
||
To be present, this wrapping node template must be added to the `global-search-autocomplete-templates` group of tabs components: | ||
|
||
``` yaml | ||
services: | ||
#… | ||
[[= include_file('code_samples/back_office/search/config/append_to_services.yaml', 22, 28) =]] | ||
``` | ||
|
||
The template for the product suggestion item follows, named `templates/themes/admin/ui/global_search_autocomplete_product_item.html.twig`: | ||
|
||
``` html+twig | ||
[[= include_file('code_samples/back_office/search/templates/themes/admin/ui/global_search_autocomplete_product_item.html.twig') =]] | ||
``` | ||
|
||
## Replace default suggestion source | ||
|
||
To replace the default suggestion source, [decorate]([[= symfony_doc =]]/service_container/service_decoration.html) the built-in `BuildSuggestionCollectionEvent` subscriber with your own: | ||
|
||
```yaml | ||
services: | ||
#… | ||
App\EventSubscriber\MySuggestionEventSubscriber: | ||
decorates: Ibexa\Search\EventDispatcher\EventListener\ContentSuggestionSubscriber | ||
``` |
Oops, something went wrong.