Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

IBX-6878: Customize BO search suggestions #2222

Merged
merged 36 commits into from
Apr 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
34ccffa
Start BO search suggestions
Nov 15, 2023
5d80b54
Start BO search suggestions
Nov 15, 2023
d2c066d
PHP CS Fixes
adriendupuis Nov 15, 2023
5260c99
Start BO search suggestions
Nov 15, 2023
86470df
Start BO search suggestions
Nov 15, 2023
dd3ec1f
PHP CS Fixes
adriendupuis Nov 15, 2023
6e5bf63
Start BO search suggestions
Nov 15, 2023
bb442f3
Merge remote-tracking branch 'origin/suggestion_php' into suggestion_php
Nov 15, 2023
9a56de0
suggestions.md: Replace the default suggestion source
Nov 16, 2023
68087e8
suggestions.md: fix grammar
Nov 17, 2023
21233ac
suggestions.md: About parameters being dynamic
Nov 17, 2023
458b75b
Move from back_office/suggestion to back_office/search for future add…
Nov 17, 2023
f36f2bf
Move from back_office/suggestion to back_office/search for future add…
Nov 17, 2023
fd415a8
Move from suggestions.md to search_suggestions.md for future additions
Nov 17, 2023
cd80bbf
Move from suggestions.md to search_suggestion.md for future additions
Nov 17, 2023
1dca846
search_suggestion.md: Use singular
Nov 17, 2023
94fde80
MySuggestionEventSubscriber.php: Fix duplicates, logger, parentLocations
Nov 20, 2023
777edbe
PHP CS Fixes
adriendupuis Nov 20, 2023
fbae6ad
search_suggestion.md: detail suggestion collection sorting and trunca…
Nov 20, 2023
49e10ac
Register MySuggestionEventSubscriber as a service for logger
Nov 21, 2023
833d692
Add ProductSuggestion, its normalizer and its template
Nov 21, 2023
a2505b2
PHP CS Fixes
adriendupuis Nov 21, 2023
9e44165
search_suggestion.md: Describe normalizer and renderer
Nov 21, 2023
27f514c
PHP CS Fixes
adriendupuis Nov 21, 2023
fbc6c0e
search_suggestion.md: Move suggestion item template to Twig
Nov 21, 2023
e0676d5
search_suggestion.md: reword a bit.
Nov 21, 2023
5913530
search_suggestion.md: Style
Nov 27, 2023
60aafab
Apply suggestions from code review
adriendupuis Feb 14, 2024
a028d43
custom suggestion source: Update services.yaml
Feb 14, 2024
b3f8747
Apply suggestions from code review
adriendupuis Mar 25, 2024
137f0c5
search_suggestion.md → customize_search_suggestion.md
adriendupuis Apr 4, 2024
984825c
customize_search_suggestion.md: reword
adriendupuis Apr 4, 2024
113fb7b
customize_search_suggestion.md: reword
adriendupuis Apr 4, 2024
2752aaa
customize_search_suggestion.md: reword
adriendupuis Apr 4, 2024
83c8511
customize_search_suggestion.md: typo
adriendupuis Apr 4, 2024
3d9e332
Apply suggestions from code review
adriendupuis Apr 17, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions code_samples/back_office/search/append_to_webpack.config.js
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'), ],
});
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 code_samples/back_office/search/config/append_to_services.yaml
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 }
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;
}
}
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;
}
}
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;
}
}
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>
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 docs/administration/back_office/customize_search_suggestion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
---

Check warning on line 1 in docs/administration/back_office/customize_search_suggestion.md

View workflow job for this annotation

GitHub Actions / vale

[vale] docs/administration/back_office/customize_search_suggestion.md#L1

[Ibexa.ReadingLevel] The grade level is 9.09. Aim for 8th grade or lower by using shorter sentences and words.
Raw output
{"message": "[Ibexa.ReadingLevel] The grade level is 9.09. Aim for 8th grade or lower by using shorter sentences and words.", "location": {"path": "docs/administration/back_office/customize_search_suggestion.md", "range": {"start": {"line": 1, "column": 1}}}, "severity": "WARNING"}
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it recommended to set the SiteAccess parameters directly?

I always thought that the ibexa.system.<scope> configuration is preferred (in this case: https://github.com/ibexa/search/blob/main/src/bundle/DependencyInjection/Configuration/Parser/SiteAccessAware/SuggestionParser.php#L20-L29)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modifications made above and below in 3d9e332.
@mnocon Is it solving your concern?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side note:

We may need to add something to https://doc.ibexa.co/en/latest/administration/configuration/dynamic_configuration/

The example starts with something that could lead to bad practice:

parameters:
    # Internal configuration
    ibexa.site_access.config.default.content.default_ttl: 60
    ibexa.site_access.config.site_group.content.default_ttl: 3600

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think for our configuration you can always use the SiteAccess-aware configuration - and settings the parameters directly is not recommended (might be needed by partners for something custom, if they don't want to provide the configuration 🤔 )

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`.

Check warning on line 116 in docs/administration/back_office/customize_search_suggestion.md

View workflow job for this annotation

GitHub Actions / vale

[vale] docs/administration/back_office/customize_search_suggestion.md#L116

[Ibexa.EOLWhitespace] Remove whitespace characters from the end of the line.
Raw output
{"message": "[Ibexa.EOLWhitespace] Remove whitespace characters from the end of the line.", "location": {"path": "docs/administration/back_office/customize_search_suggestion.md", "range": {"start": {"line": 116, "column": 109}}}, "severity": "WARNING"}
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
```