diff --git a/code_samples/back_office/search/append_to_webpack.config.js b/code_samples/back_office/search/append_to_webpack.config.js new file mode 100644 index 0000000000..3fa0b8d917 --- /dev/null +++ b/code_samples/back_office/search/append_to_webpack.config.js @@ -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'), ], +}); diff --git a/code_samples/back_office/search/assets/js/admin.search.autocomplete.product.js b/code_samples/back_office/search/assets/js/admin.search.autocomplete.product.js new file mode 100644 index 0000000000..c9a80f0b16 --- /dev/null +++ b/code_samples/back_office/search/assets/js/admin.search.autocomplete.product.js @@ -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); diff --git a/code_samples/back_office/search/config/append_to_services.yaml b/code_samples/back_office/search/config/append_to_services.yaml new file mode 100644 index 0000000000..ff28921c62 --- /dev/null +++ b/code_samples/back_office/search/config/append_to_services.yaml @@ -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 } diff --git a/code_samples/back_office/search/src/EventSubscriber/MySuggestionEventSubscriber.php b/code_samples/back_office/search/src/EventSubscriber/MySuggestionEventSubscriber.php new file mode 100644 index 0000000000..63dd4384d0 --- /dev/null +++ b/code_samples/back_office/search/src/EventSubscriber/MySuggestionEventSubscriber.php @@ -0,0 +1,83 @@ +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; + } +} diff --git a/code_samples/back_office/search/src/Search/Model/Suggestion/ProductSuggestion.php b/code_samples/back_office/search/src/Search/Model/Suggestion/ProductSuggestion.php new file mode 100644 index 0000000000..5d755c38a4 --- /dev/null +++ b/code_samples/back_office/search/src/Search/Model/Suggestion/ProductSuggestion.php @@ -0,0 +1,24 @@ +getName()); + $this->product = $product; + } + + public function getProduct() + { + return $this->product; + } +} diff --git a/code_samples/back_office/search/src/Search/Serializer/Normalizer/Suggestion/ProductSuggestionNormalizer.php b/code_samples/back_office/search/src/Search/Serializer/Normalizer/Suggestion/ProductSuggestionNormalizer.php new file mode 100644 index 0000000000..af57c761f2 --- /dev/null +++ b/code_samples/back_office/search/src/Search/Serializer/Normalizer/Suggestion/ProductSuggestionNormalizer.php @@ -0,0 +1,39 @@ + '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; + } +} diff --git a/code_samples/back_office/search/templates/themes/admin/ui/global_search_autocomplete_product_item.html.twig b/code_samples/back_office/search/templates/themes/admin/ui/global_search_autocomplete_product_item.html.twig new file mode 100644 index 0000000000..c4f8db5754 --- /dev/null +++ b/code_samples/back_office/search/templates/themes/admin/ui/global_search_autocomplete_product_item.html.twig @@ -0,0 +1,20 @@ +
  • + +
    + {{ product_name }} +
    + {{ product_code }} +
    +
    +
    +
    + + + + + {{ product_type_name }} + +
    +
    +
    +
  • diff --git a/code_samples/back_office/search/templates/themes/admin/ui/global_search_autocomplete_product_template.html.twig b/code_samples/back_office/search/templates/themes/admin/ui/global_search_autocomplete_product_template.html.twig new file mode 100644 index 0000000000..0001e43267 --- /dev/null +++ b/code_samples/back_office/search/templates/themes/admin/ui/global_search_autocomplete_product_template.html.twig @@ -0,0 +1,10 @@ +
    +
    diff --git a/docs/administration/back_office/customize_search_suggestion.md b/docs/administration/back_office/customize_search_suggestion.md new file mode 100644 index 0000000000..ae74dac290 --- /dev/null +++ b/docs/administration/back_office/customize_search_suggestion.md @@ -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: + : + 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 `/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.` 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.', 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 +``` diff --git a/mkdocs.yml b/mkdocs.yml index b56eb360b3..0f23a77d0c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -137,6 +137,7 @@ nav: - Multi-file upload: administration/back_office/multifile_upload.md - Sub-items list: administration/back_office/subitems_list.md - Notifications: administration/back_office/notifications.md + - Customize search suggestion: administration/back_office/customize_search_suggestion.md - Content management: - Content management: content_management/content_management.md - Content management guide: content_management/content_management_guide.md