diff --git a/addons/portal_rating/views/rating_templates.xml b/addons/portal_rating/views/rating_templates.xml index 50759e876d96f..f12377b632312 100644 --- a/addons/portal_rating/views/rating_templates.xml +++ b/addons/portal_rating/views/rating_templates.xml @@ -33,7 +33,7 @@ - + () diff --git a/addons/test_website/__manifest__.py b/addons/test_website/__manifest__.py index 1238a057f6625..2c213f55b8740 100644 --- a/addons/test_website/__manifest__.py +++ b/addons/test_website/__manifest__.py @@ -37,6 +37,7 @@ ], 'web.assets_frontend': [ 'test_website/static/src/interactions/**/*', + 'test_website/static/src/snippets/**/*.xml', ], 'website.website_builder_assets': [ 'test_website/static/src/website_builder/**/*', diff --git a/addons/test_website/models/model.py b/addons/test_website/models/model.py index b9d3e646ca9ef..78a51d02a8f1e 100644 --- a/addons/test_website/models/model.py +++ b/addons/test_website/models/model.py @@ -45,6 +45,8 @@ def _search_get_detail(self, website, order, options): }, 'icon': 'fa-check-square-o', 'order': 'name asc, id desc', + 'template': 'test_website.search_result_item_test_model', + 'group_name': self.env._("Test Models"), } def _get_search_highlight_handlers(self): diff --git a/addons/test_website/static/src/snippets/s_searchbar/000.xml b/addons/test_website/static/src/snippets/s_searchbar/000.xml new file mode 100644 index 0000000000000..2420b57bd0fb3 --- /dev/null +++ b/addons/test_website/static/src/snippets/s_searchbar/000.xml @@ -0,0 +1,15 @@ + + + + + + + +
+
+
+
+ + + + diff --git a/addons/test_website/tests/test_fuzzy.py b/addons/test_website/tests/test_fuzzy.py index ef0d2b925c445..e087885b525d5 100644 --- a/addons/test_website/tests/test_fuzzy.py +++ b/addons/test_website/tests/test_fuzzy.py @@ -30,9 +30,7 @@ def _autocomplete(self, term, expected_count, expected_fuzzy_term, search_type=" def _autocomplete_page(self, term, expected_count, expected_fuzzy_term): self._autocomplete(term, expected_count, expected_fuzzy_term, search_type="pages", options={ - 'displayDescription': False, 'displayDetail': False, - 'displayExtraDetail': False, 'displayExtraLink': False, - 'displayImage': False, 'allowFuzzy': True + 'allowFuzzy': True }) def test_01_many_records(self): @@ -98,9 +96,7 @@ def test_02_pages_search(self): # ORM level, not in SQL, due to how `inherits` works. self.env['website'].browse(1)._search_with_fuzzy( 'pages', 'test', limit=5, order='name asc, website_id desc, id', options={ - 'displayDescription': False, 'displayDetail': False, - 'displayExtraDetail': False, 'displayExtraLink': False, - 'displayImage': False, 'allowFuzzy': True + 'allowFuzzy': True } ) diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py index c6a26d1345dd9..bfcef0173a5c2 100644 --- a/addons/website/controllers/main.py +++ b/addons/website/controllers/main.py @@ -587,7 +587,7 @@ def _get_search_order(self, order): return 'is_published desc, %s, id desc' % order @http.route('/website/snippet/autocomplete', type='jsonrpc', auth='public', website=True, readonly=True) - def autocomplete(self, search_type=None, term=None, order=None, limit=5, max_nb_chars=999, options=None): + def autocomplete(self, search_type=None, term=None, order=None, limit=6, max_nb_chars=999, options=None): """ Returns list of results according to the term and options @@ -612,54 +612,64 @@ def autocomplete(self, search_type=None, term=None, order=None, limit=5, max_nb_ order = self._get_search_order(order) options = options or {} results_count, search_results, fuzzy_term = request.website._search_with_fuzzy(search_type, term, limit, order, options) + # Sort result based in sequence for ordered results. + search_results.sort(key=lambda d: d.get('sequence', float('inf'))) if not results_count: return { - 'results': [], + 'results': {}, 'results_count': 0, 'parts': {}, } term = fuzzy_term or term - search_results = request.website._search_render_results(search_results, limit) + search_results = request.website.sudo()._search_render_results(search_results, limit) mappings = [] - results_data = [] + result = {} for search_result in search_results: - for result in search_result['results_data']: - result['model'] = search_result['model'] - results_data.append(result) + if not len(search_result['results_data']): + continue + search_result['results_data'].sort(key=lambda r: r.get('name', ''), reverse='name desc' in order) mappings.append(search_result['mapping']) - if search_type == 'all': - # Only supported order for 'all' is on name - results_data.sort(key=lambda r: r.get('name', ''), reverse='name desc' in order) - results_data = results_data[:limit] - result = [] - for record in results_data: - mapping = record['_mapping'] - model = request.env[record['model']] - mapped = { - '_fa': record.get('_fa'), - } - for mapped_name, field_meta in mapping.items(): - value = record.get(field_meta.get('name')) - if not value: - mapped[mapped_name] = '' - continue - field_type = field_meta.get('type') - if field_type == 'text' and field_meta.get('truncate', True): - value = self._shorten_around_match(value, term, max_nb_chars) - - if field_meta.get('match'): - skip_field, value, field_type = model._search_highlight_field(field_meta, value, term) - if skip_field: + group_name = search_result.get("group_name") + group_key = '_'.join(group_name.lower().split()) + result_data = [] + for record in search_result['results_data']: + model = request.env[search_result['model']] + mapping = record['_mapping'] + mapped = { + '_fa': record.get('_fa'), + } + model = request.env[search_result['model']] + for mapped_name, field_meta in mapping.items(): + value = record.get(field_meta.get('name')) + if not value: + mapped[mapped_name] = '' continue - - if field_type not in ('image', 'binary') and ('ir.qweb.field.%s' % field_type) in request.env: - opt = {} - if field_type == 'monetary': - opt['display_currency'] = options['display_currency'] - value = request.env[('ir.qweb.field.%s' % field_type)].value_to_html(value, opt) - mapped[mapped_name] = escape(value) - result.append(mapped) + field_type = field_meta.get('type') + if field_type == 'text' and field_meta.get('truncate', True): + value = self._shorten_around_match(value, term, max_nb_chars) + + if field_meta.get('match'): + skip_field, value, field_type = model._search_highlight_field(field_meta, value, term) + if skip_field: + continue + + if field_type not in ('image', 'binary') and ('ir.qweb.field.%s' % field_type) in request.env: + opt = {} + if field_type == 'monetary': + opt['display_currency'] = options['display_currency'] + elif field_type == 'float': + opt['precision'] = field_meta.get('precision', 2) + value = request.env[('ir.qweb.field.%s' % field_type)].value_to_html(value, opt) + mapped[mapped_name] = escape(value) + result_data.append(mapped) + + result[group_key] = { + "groupName": group_name, + "templateKey": search_result.get("template_key"), + "search_count": search_result.get('count'), + "data": result_data, + } return { 'results': result, @@ -670,11 +680,6 @@ def autocomplete(self, search_type=None, term=None, order=None, limit=5, max_nb_ def _get_page_search_options(self, **post): return { - 'displayDescription': False, - 'displayDetail': False, - 'displayExtraDetail': False, - 'displayExtraLink': False, - 'displayImage': False, 'allowFuzzy': not post.get('noFuzzy'), } @@ -708,44 +713,26 @@ def pages_list(self, page=1, search='', **kw): def _get_hybrid_search_options(self, **post): return { - 'displayDescription': True, - 'displayDetail': True, - 'displayExtraDetail': True, - 'displayExtraLink': True, - 'displayImage': True, 'allowFuzzy': not post.get('noFuzzy'), } @http.route([ '/website/search', - '/website/search/page/', '/website/search/', - '/website/search//page/', ], type='http', auth="public", website=True, sitemap=False, readonly=True) - def hybrid_list(self, page=1, search='', search_type='all', **kw): + def hybrid_list(self, search='', search_type='all', **kw): if not search: return request.render("website.list_hybrid") options = self._get_hybrid_search_options(**kw) - data = self.autocomplete(search_type=search_type, term=search, order='name asc', limit=500, max_nb_chars=200, options=options) + data = self.autocomplete(search_type=search_type, term=search, order='name asc', limit=100, max_nb_chars=200, options=options) results = data.get('results', []) - search_count = len(results) + search_count = data.get('results_count', 0) parts = data.get('parts', {}) - step = 50 - pager = portal_pager( - url="/website/search/%s" % search_type, - url_args={'search': search}, - total=search_count, - page=page, - step=step - ) - - results = results[(page - 1) * step:page * step] - + # TODO: Implement pagination or load more button for more records values = { - 'pager': pager, 'results': results, 'parts': parts, 'search': search, diff --git a/addons/website/models/mixins.py b/addons/website/models/mixins.py index c47ae137d31f0..c3f2ffb116a93 100644 --- a/addons/website/models/mixins.py +++ b/addons/website/models/mixins.py @@ -118,6 +118,15 @@ def _get_background(self, height=None, width=None): img = img[:-1] + suffix + ')' return img + def _get_image_url(self): + self.ensure_one() + properties = json_safe.loads(self.cover_properties) + img = properties.get('background-image', None) + if not img: + return None + match = re.search(r"url\(\s*(['\"]?)(.*?)\1\s*\)", img) + return match.group(2) if match else img + def write(self, vals): if 'cover_properties' not in vals: return super().write(vals) @@ -694,7 +703,8 @@ def _search_fetch(self, search_detail, search, limit, order): return results, count def _search_render_results(self, fetch_fields, mapping, icon, limit): - results_data = self.read(fetch_fields)[:limit] + # Some fields are not avaiable in public user group - require sudo to complete result + results_data = self.sudo().read(fetch_fields)[:limit] for result in results_data: result['_fa'] = icon result['_mapping'] = mapping diff --git a/addons/website/models/website_page.py b/addons/website/models/website_page.py index afb39a6d278ac..57255e9780743 100644 --- a/addons/website/models/website_page.py +++ b/addons/website/models/website_page.py @@ -206,7 +206,6 @@ def get_website_meta(self): @api.model def _search_get_detail(self, website, order, options): - with_description = options['displayDescription'] # Read access on website.page requires sudo. requires_sudo = True domain = [website.website_domain()] @@ -224,16 +223,13 @@ def _search_get_detail(self, website, order, options): [('group_ids', '=', False)], [('group_ids', 'in', self.env.user.group_ids.ids)] ])) - search_fields = ['name', 'url'] - fetch_fields = ['id', 'name', 'url'] + search_fields = ['name', 'url', 'arch_db'] + fetch_fields = ['id', 'name', 'url', 'arch'] mapping = { 'name': {'name': 'name', 'type': 'text', 'match': True}, 'website_url': {'name': 'url', 'type': 'text', 'truncate': False}, + 'description': {'name': 'arch', 'type': 'text', 'html': True, 'match': True} } - if with_description: - search_fields.append('arch_db') - fetch_fields.append('arch') - mapping['description'] = {'name': 'arch', 'type': 'text', 'html': True, 'match': True} return { 'model': 'website.page', 'base_domain': domain, @@ -242,6 +238,9 @@ def _search_get_detail(self, website, order, options): 'fetch_fields': fetch_fields, 'mapping': mapping, 'icon': 'fa-file-o', + 'template_key': 'website.search_items_page', + 'group_name': self.env._("Pages"), + 'sequence': 10, } @api.model diff --git a/addons/website/static/src/@types/plugins.d.ts b/addons/website/static/src/@types/plugins.d.ts index 380fdea2338f8..13c11b9fc39ca 100644 --- a/addons/website/static/src/@types/plugins.d.ts +++ b/addons/website/static/src/@types/plugins.d.ts @@ -23,7 +23,7 @@ declare module "plugins" { import { MegaMenuOptionShared } from "@website/builder/plugins/options/mega_menu_option_plugin"; import { NavTabsStyleOptionShared } from "@website/builder/plugins/options/navtabs_style_option_plugin"; import { WebsiteParallaxShared } from "@website/builder/plugins/options/parallax_option_plugin"; - import { searchbar_option_display_items, searchbar_option_order_by_items } from "@website/builder/plugins/options/searchbar_option_plugin"; + import { searchbar_option_order_by_items } from "@website/builder/plugins/options/searchbar_option_plugin"; import { SocialMediaOptionShared } from "@website/builder/plugins/options/social_media_option_plugin"; import { visibility_selector_parameters } from "@website/builder/plugins/options/visibility_option_plugin"; import { WebsitePageConfigOptionShared } from "@website/builder/plugins/options/website_page_config_option_plugin"; @@ -92,7 +92,6 @@ declare module "plugins" { footer_templates_providers: footer_templates_providers; // Data - searchbar_option_display_items: searchbar_option_display_items; searchbar_option_order_by_items: searchbar_option_order_by_items; theme_options: theme_options; visibility_selector_parameters: visibility_selector_parameters; diff --git a/addons/website/static/src/builder/plugins/options/searchbar_option.js b/addons/website/static/src/builder/plugins/options/searchbar_option.js index ea14e1ecdad18..37b925b10c8d0 100644 --- a/addons/website/static/src/builder/plugins/options/searchbar_option.js +++ b/addons/website/static/src/builder/plugins/options/searchbar_option.js @@ -10,6 +10,5 @@ export class SearchbarOption extends BaseOptionComponent { this.getItemValue = useGetItemValue(); this.orderByItems = this.getResource("searchbar_option_order_by_items"); - this.displayItems = this.getResource("searchbar_option_display_items"); } } diff --git a/addons/website/static/src/builder/plugins/options/searchbar_option.xml b/addons/website/static/src/builder/plugins/options/searchbar_option.xml index 11dc6e49e2e98..eb630eea81210 100644 --- a/addons/website/static/src/builder/plugins/options/searchbar_option.xml +++ b/addons/website/static/src/builder/plugins/options/searchbar_option.xml @@ -29,11 +29,6 @@ results - - - - - Default Input Style diff --git a/addons/website/static/src/builder/plugins/options/searchbar_option_plugin.js b/addons/website/static/src/builder/plugins/options/searchbar_option_plugin.js index ff6ce169fbd91..9df9e8303ccc4 100644 --- a/addons/website/static/src/builder/plugins/options/searchbar_option_plugin.js +++ b/addons/website/static/src/builder/plugins/options/searchbar_option_plugin.js @@ -31,27 +31,6 @@ import { BuilderAction } from "@html_builder/core/builder_action"; * }, * }; */ -/** - * @typedef {{ - * label: LazyTranslatedString; - * dataAttribute: string; - * dependency: string; - * }[]} searchbar_option_display_items - * - * Register display options for the website searchbar. - * `dataAttribute` is the attribute which will be used to display the data. - * `dependency` takes an id of another builder option. - * - * Example: - * - * resources: { - * searchbar_option_display_items: { - * label: _t("Description"), - * dataAttribute: "displayDescription", - * dependency: "search_all_opt", - * }, - * }; - */ class SearchbarOptionPlugin extends Plugin { static id = "searchbarOption"; @@ -62,12 +41,6 @@ class SearchbarOptionPlugin extends Plugin { SetSearchTypeAction, SetOrderByAction, SetSearchbarStyleAction, - // This resets the data attribute to an empty string on clean. - // TODO: modify the Python `_search_get_detail()` (grep - // `with_description = options['displayDescription']`) so we can use - // the default `dataAttributeAction`. The python should not need a - // value if it doesn't exist. - SetNonEmptyDataAttributeAction, }, so_content_addition_selector: [".s_searchbar_input"], searchbar_option_order_by_items: { @@ -75,33 +48,6 @@ class SearchbarOptionPlugin extends Plugin { orderBy: "name asc", id: "order_name_asc_opt", }, - searchbar_option_display_items: [ - { - label: _t("Description"), - dataAttribute: "displayDescription", - dependency: "search_all_opt", - }, - { - label: _t("Content"), - dataAttribute: "displayDescription", - dependency: "search_pages_opt", - }, - { - label: _t("Extra Link"), - dataAttribute: "displayExtraLink", - dependency: "search_all_opt", - }, - { - label: _t("Detail"), - dataAttribute: "displayDetail", - dependency: "search_all_opt", - }, - { - label: _t("Image"), - dataAttribute: "displayImage", - dependency: "search_all_opt", - }, - ], // input group should not be contenteditable, while all other children // beside the input are contenteditable content_not_editable_selectors: [".input-group:has( > input)"], @@ -144,21 +90,6 @@ export class SetSearchTypeAction extends BaseSearchBarAction { editingElement.dataset.orderBy = this.defaultSearchType; searchOrderByInputEl.value = this.defaultSearchType; } - - // Reset display options. Has to be done in 2 steps, because - // the same option may be on 2 dependencies, and we don't - // want the 1st to add it and the 2nd to delete it. - const displayDataAttributes = new Set(); - for (const item of this.getResource("searchbar_option_display_items")) { - if (isDependencyActive(item.dependency)) { - displayDataAttributes.add(item.dataAttribute); - } else { - delete editingElement.dataset[item.dataAttribute]; - } - } - for (const dataAttribute of displayDataAttributes) { - editingElement.dataset[dataAttribute] = "true"; - } } } export class SetOrderByAction extends BaseSearchBarAction { @@ -190,29 +121,5 @@ export class SetSearchbarStyleAction extends BaseSearchBarAction { searchButtonEl?.classList.toggle("btn-primary", !isLight); } } -// This resets the data attribute to an empty string on clean. -// TODO: modify the Python `_search_get_detail()` (grep -// `with_description = options['displayDescription']`) so we can use -// the default `dataAttributeAction`. The python should not need a -// value if it doesn't exist. -export class SetNonEmptyDataAttributeAction extends BuilderAction { - static id = "setNonEmptyDataAttribute"; - getValue({ editingElement, params: { mainParam: attributeName } = {} }) { - return editingElement.dataset[attributeName]; - } - isApplied({ editingElement, params: { mainParam: attributeName } = {}, value = "" }) { - return editingElement.dataset[attributeName] === value; - } - apply({ editingElement, params: { mainParam: attributeName } = {}, value }) { - if (value) { - editingElement.dataset[attributeName] = value; - } else { - delete editingElement.dataset[attributeName]; - } - } - clean({ editingElement, params: { mainParam: attributeName } = {} }) { - editingElement.dataset[attributeName] = ""; - } -} registry.category("website-plugins").add(SearchbarOptionPlugin.id, SearchbarOptionPlugin); diff --git a/addons/website/static/src/scss/website.scss b/addons/website/static/src/scss/website.scss index 9209bce823d8f..750404841a3b9 100644 --- a/addons/website/static/src/scss/website.scss +++ b/addons/website/static/src/scss/website.scss @@ -2348,11 +2348,19 @@ $ribbon-padding: 100px; } // Search results -.o_search_result_item_detail { - flex: 1; +.o_search_result_item { + min-height: inherit; - button:disabled { - color: inherit; + .o_search_result_item_content { + flex: 1; + button:disabled { + color: inherit; + } + } + a { + &:focus, &:hover { + background: var(--tertiary-bg) !important; + } } } diff --git a/addons/website/static/src/snippets/s_searchbar/000.xml b/addons/website/static/src/snippets/s_searchbar/000.xml index ff23cff9f8991..e9533e1df95bb 100644 --- a/addons/website/static/src/snippets/s_searchbar/000.xml +++ b/addons/website/static/src/snippets/s_searchbar/000.xml @@ -1,55 +1,69 @@ + + + + + + + +
+ + + + + -
- +
+ + - No results found for ''. Showing results for ''. + + No results found for ''. Showing results for + ''. + - + + No results found. Please try another search. - -
- - - - -
- - - - diff --git a/addons/website/static/src/snippets/s_searchbar/search_bar.js b/addons/website/static/src/snippets/s_searchbar/search_bar.js index 98ac1f3891998..8310f8603e093 100644 --- a/addons/website/static/src/snippets/s_searchbar/search_bar.js +++ b/addons/website/static/src/snippets/s_searchbar/search_bar.js @@ -5,6 +5,7 @@ import { markup } from "@odoo/owl"; import { rpc } from "@web/core/network/rpc"; import { getTemplate } from "@web/core/templates"; import { KeepLast } from "@web/core/utils/concurrency"; +import { SIZES, MEDIAS_BREAKPOINTS } from "@web/core/ui/ui_service"; export class SearchBar extends Interaction { static selector = ".o_searchbar_form"; @@ -22,6 +23,9 @@ export class SearchBar extends Interaction { "t-on-keydown": this.onKeydown, "t-on-search": this.onSearch, }, + ".o_search_result_item a": { + "t-on-keydown": this.onSearchResultKeydown, + }, }; autocompleteMinWidth = 300; @@ -33,7 +37,7 @@ export class SearchBar extends Interaction { const orderByEl = this.el.querySelector(".o_search_order_by"); const form = orderByEl.closest("form"); this.order = orderByEl.value; - this.limit = parseInt(this.inputEl.dataset.limit) || 5; + this.limit = parseInt(this.inputEl.dataset.limit) || 6; this.wasEmpty = !this.inputEl.value; this.linkHasFocus = false; if (this.limit) { @@ -41,10 +45,7 @@ export class SearchBar extends Interaction { } const dataset = this.inputEl.dataset; this.options = { - displayImage: dataset.displayImage, - displayDescription: dataset.displayDescription, - displayExtraLink: dataset.displayExtraLink, - displayDetail: dataset.displayDetail, + searchType: dataset.searchType, // Make it easy for customization to disable fuzzy matching on specific searchboxes allowFuzzy: !dataset.noFuzzy, }; @@ -89,6 +90,13 @@ export class SearchBar extends Interaction { this.render(null); } + getDisplayType() { + if (this.el.clientWidth > MEDIAS_BREAKPOINTS[SIZES.SM].maxWidth) { + return "columns"; + } + return "list"; + } + async fetch() { const res = await rpc("/website/snippet/autocomplete", { search_type: this.searchType, @@ -96,18 +104,25 @@ export class SearchBar extends Interaction { order: this.order, limit: this.limit, max_nb_chars: Math.round( - Math.max(this.autocompleteMinWidth, parseInt(this.el.clientWidth)) * 0.22 + Math.max( + this.autocompleteMinWidth, + parseInt(this.el.clientWidth / (this.getDisplayType() === "columns" ? 3 : 1)) + ) * 0.22 ), options: this.options, }); - const fieldNames = this.getFieldsNames(); - res.results.forEach((record) => { - for (const fieldName of fieldNames) { - if (record[fieldName]) { - record[fieldName] = markup(record[fieldName]); + + const field_set = new Set(this.getFieldsNames()); + for (const group in res.results) { + const data = res.results[group].data; + data.forEach((record) => { + for (const key in record) { + if (field_set.has(key) && record[key]) { + record[key] = markup(record[key]); + } } - } - }); + }); + } return res; } @@ -120,7 +135,7 @@ export class SearchBar extends Interaction { } const prevMenuEl = this.menuEl; if (res && this.limit) { - const results = res["results"]; + const results = res.results; let template = "website.s_searchbar.autocomplete"; const candidate = template + "." + this.searchType; if (getTemplate(candidate)) { @@ -131,10 +146,11 @@ export class SearchBar extends Interaction { { results: results, parts: res["parts"], - hasMoreResults: results.length < res["results_count"], + limit: this.limit, search: this.inputEl.value, fuzzySearch: res["fuzzy_search"], widget: this.options, + displayType: this.getDisplayType(), }, this.el )[0]; @@ -185,16 +201,21 @@ export class SearchBar extends Interaction { this.render(); break; case "ArrowUp": + ev.preventDefault(); + if (this.menuEl) { + const allResults = [...this.menuEl.querySelectorAll(".o_search_result_item a")]; + if (allResults.length > 0) { + allResults[allResults.length - 1].focus(); + } + } + break; case "ArrowDown": ev.preventDefault(); if (this.menuEl) { - const focusableEls = [this.inputEl, ...this.menuEl.children]; - const focusedEl = document.activeElement; - const currentIndex = focusableEls.indexOf(focusedEl) || 0; - const delta = ev.key === "ArrowUp" ? focusableEls.length - 1 : 1; - const nextIndex = (currentIndex + delta) % focusableEls.length; - const nextFocusedEl = focusableEls[nextIndex]; - nextFocusedEl.focus(); + const firstResult = this.menuEl.querySelector(".o_search_result_item a"); + if (firstResult) { + firstResult.focus(); + } } break; case "Enter": @@ -203,6 +224,133 @@ export class SearchBar extends Interaction { } } + /** + * Handle keyboard navigation within search results + * @param {KeyboardEvent} ev + */ + onSearchResultKeydown(ev) { + const allResults = [...this.menuEl.querySelectorAll(".o_search_result_item a")]; + const currentResult = ev.currentTarget; + const currentIndex = allResults.indexOf(currentResult); + + switch (ev.key) { + case "Escape": + this.render(); + break; + case "ArrowUp": { + ev.preventDefault(); + // Check if current element is in the first row + const currentRect = currentResult.getBoundingClientRect(); + const isFirstRow = allResults.every((el, idx) => { + if (idx === currentIndex) { + return true; + } + const rect = el.getBoundingClientRect(); + return rect.top >= currentRect.top - 5; // Allow small tolerance + }); + + if (isFirstRow) { + // Focus back to input when in first row + this.inputEl.focus(); + } else { + this.navigateByDirection(currentIndex, allResults, "up"); + } + break; + } + case "ArrowDown": + ev.preventDefault(); + this.navigateByDirection(currentIndex, allResults, "down"); + break; + case "ArrowLeft": + ev.preventDefault(); + this.navigateByDirection(currentIndex, allResults, "left"); + break; + case "ArrowRight": + ev.preventDefault(); + this.navigateByDirection(currentIndex, allResults, "right"); + break; + } + } + + /** + * Navigate through search results based on their visual position + * @param {number} currentIndex + * @param {Array} allResults + * @param {string} direction - "up", "down", "left", "right" + */ + navigateByDirection(currentIndex, allResults, direction) { + const currentRect = allResults[currentIndex].getBoundingClientRect(); + const currentCenterX = currentRect.left + currentRect.width / 2; + const currentCenterY = currentRect.top + currentRect.height / 2; + + let nextIndex = -1; + let bestDistance = Infinity; + + allResults.forEach((el, index) => { + if (index === currentIndex) { + return; + } + + const rect = el.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + let isInDirection = false; + let distance = 0; + + switch (direction) { + case "down": + if (centerY > currentCenterY) { + isInDirection = true; + distance = Math.sqrt( + Math.pow(centerY - currentCenterY, 2) + + Math.pow(Math.abs(centerX - currentCenterX) / 2, 2) + ); + } + break; + case "up": + if (centerY < currentCenterY) { + isInDirection = true; + distance = Math.sqrt( + Math.pow(currentCenterY - centerY, 2) + + Math.pow(Math.abs(centerX - currentCenterX) / 2, 2) + ); + } + break; + case "right": + if (centerX > currentCenterX) { + isInDirection = true; + distance = Math.sqrt( + Math.pow(centerX - currentCenterX, 2) + + Math.pow(Math.abs(centerY - currentCenterY) / 2, 2) + ); + } + break; + case "left": + if (centerX < currentCenterX) { + isInDirection = true; + distance = Math.sqrt( + Math.pow(currentCenterX - centerX, 2) + + Math.pow(Math.abs(centerY - currentCenterY) / 2, 2) + ); + } + break; + } + + if (isInDirection && distance < bestDistance) { + bestDistance = distance; + nextIndex = index; + } + }); + + // If no element found in direction and moving down, wrap to input + if (nextIndex >= 0) { + allResults[nextIndex].focus(); + } else if (direction === "down") { + this.inputEl.focus(); + } + } + /** * @param {MouseEvent} ev */ diff --git a/addons/website/static/src/snippets/s_searchbar/search_bar_results.js b/addons/website/static/src/snippets/s_searchbar/search_bar_results.js index c73f8455236c9..f8a7d3b99fe05 100644 --- a/addons/website/static/src/snippets/s_searchbar/search_bar_results.js +++ b/addons/website/static/src/snippets/s_searchbar/search_bar_results.js @@ -22,7 +22,7 @@ export class SearchBarResults extends Interaction { "max-height": `max(40vh, ${ document.body.clientHeight - bcr.bottom - 16 }px) !important`, - "min-width": this.autocompleteMinWidth, + "min-width": `${this.autocompleteMinWidth}px`, }; }, "t-att-class": () => ({ diff --git a/addons/website/static/tests/builder/website_builder/searchbar_option.test.js b/addons/website/static/tests/builder/website_builder/searchbar_option.test.js index 25bd73be834c7..5691402ab9afd 100644 --- a/addons/website/static/tests/builder/website_builder/searchbar_option.test.js +++ b/addons/website/static/tests/builder/website_builder/searchbar_option.test.js @@ -57,17 +57,6 @@ test("Available 'order by' options are updated after switching search type", asy expect(".o_popover[role=menu] [data-action-id='setOrderBy']").toHaveCount(2); }); -test("Switching search type changes data checkboxes", async () => { - await setupWebsiteBuilder(searchbarHTML("name asc")); - await contains(":iframe .search-query").click(); - expect("[data-label='Search within'] button.o-dropdown").toHaveText("Pages"); - expect(".form-check-input").toHaveCount(1); - await contains("[data-label='Search within'] button.o-dropdown").click(); - await contains(".o_popover[role=menu] [data-action-value='/website/search']").click(); - expect("[data-label='Search within'] button.o-dropdown").toHaveText("Everything"); - expect(".form-check-input").toHaveCount(4); -}); - test("Switching search type resets 'order by' option to default", async () => { await setupWebsiteBuilder(searchbarHTML("write_date asc")); await contains(":iframe .search-query").click(); diff --git a/addons/website/static/tests/interactions/snippets/search_bar.test.js b/addons/website/static/tests/interactions/snippets/search_bar.test.js index 64486139e032e..09057d2a03ba0 100644 --- a/addons/website/static/tests/interactions/snippets/search_bar.test.js +++ b/addons/website/static/tests/interactions/snippets/search_bar.test.js @@ -30,35 +30,33 @@ const searchTemplate = /* html */ ` `; -function supportAutocomplete() { +function supportAutocomplete(numberOfResults = 3) { onRpc("/website/snippet/autocomplete", async (args) => { const json = JSON.parse(new TextDecoder().decode(await args.arrayBuffer())); expect(json.params.search_type).toBe("test"); expect(json.params.term).toBe("xyz"); expect(json.params.order).toBe("test desc"); expect(json.params.limit).toBe(3); - expect(json.params.options.displayImage).toBe("false"); - expect(json.params.options.displayDescription).toBe("false"); - expect(json.params.options.displayExtraLink).toBe("true"); - expect(json.params.options.displayDetail).toBe("false"); + + const allData = [ + { _fa: "fa-file-o", name: "Xyz 1", website_url: "/website/test/xyz-1" }, + { _fa: "fa-file-o", name: "Xyz 2", website_url: "/website/test/xyz-2" }, + { _fa: "fa-file-o", name: "Xyz 3", website_url: "/website/test/xyz-3" }, + { _fa: "fa-file-o", name: "Xyz 4", website_url: "/website/test/xyz-1" }, + { _fa: "fa-file-o", name: "Xyz 5", website_url: "/website/test/xyz-2" }, + { _fa: "fa-file-o", name: "Xyz 6", website_url: "/website/test/xyz-3" }, + ]; + return { - results: [ - { - _fa: "fa-file-o", - name: "Xyz 1", - website_url: "/website/test/xyz-1", - }, - { - _fa: "fa-file-o", - name: "Xyz 2", - website_url: "/website/test/xyz-2", - }, - { - _fa: "fa-file-o", - name: "Xyz 3", - website_url: "/website/test/xyz-3", + results: { + pages: { + groupName: "Pages", + templateKey: "website.search_items_page", + search_count: 3, + limit: 3, + data: allData.slice(0, numberOfResults), }, - ], + }, results_count: 3, parts: { name: true, @@ -83,36 +81,65 @@ test("searchbar triggers a search when text is entered", async () => { expect(queryAll("form .o_search_result_item")).toHaveLength(3); }); -test("searchbar selects first result on cursor down", async () => { - supportAutocomplete(); +/** + * Test keyboard navigation in search results. + * + * Verifies that: + * 1. ArrowDown from input focuses the first result + * 2. ArrowUp from input focuses the last result + * 3. ArrowLeft/Right navigate horizontally within the grid + * 4. ArrowDown wraps around rows to next row's first column + */ +test("search results keyboard navigation with arrow keys", async () => { + supportAutocomplete(6); await startInteractions(searchTemplate); const inputEl = queryOne("form input[type=search]"); + + // Setup: Type search query to trigger autocomplete await click(inputEl); await press("x"); await press("y"); await press("z"); await advanceTime(400); - const resultEls = queryAll("form a:has(.o_search_result_item)"); - expect(resultEls).toHaveLength(3); + + const resultEls = queryAll("form .o_search_result_item"); + expect(resultEls).toHaveLength(6); expect(document.activeElement).toBe(inputEl); + + // ArrowDown from input focuses first result await press("down"); expect(document.activeElement).toBe(resultEls[0]); -}); -test("searchbar selects last result on cursor up", async () => { - supportAutocomplete(); - await startInteractions(searchTemplate); - const inputEl = queryOne("form input[type=search]"); - await click(inputEl); - await press("x"); - await press("y"); - await press("z"); - await advanceTime(400); - const resultEls = queryAll("form a:has(.o_search_result_item)"); - expect(resultEls).toHaveLength(3); + // ArrowDown moves to next row, same column + await press("down"); + expect(document.activeElement).toBe(resultEls[3]); + + // ArrowLeft navigates to adjacent result (same row) + await press("right"); + expect(document.activeElement).toBe(resultEls[4]); + + // ArrowUp moves to previous row, same column + await press("up"); + expect(document.activeElement).toBe(resultEls[1]); + + // ArrowDown from input focuses first result + await press("left"); + expect(document.activeElement).toBe(resultEls[0]); + + // ArrowUp moves back to input + await press("up"); expect(document.activeElement).toBe(inputEl); + + // ArrowUp from input focuses last result await press("up"); - expect(document.activeElement).toBe(resultEls[2]); + expect(document.activeElement).toBe(resultEls[5]); + + // Here element is in last row, pressing down from any position should + // set focus back to input + await press("left"); + expect(document.activeElement).toBe(resultEls[4]); + await press("down"); + expect(document.activeElement).toBe(inputEl); }); test("searchbar removes results on escape", async () => { @@ -123,7 +150,7 @@ test("searchbar removes results on escape", async () => { await press("y"); await press("z"); await advanceTime(400); - expect(queryAll("form a:has(.o_search_result_item)")).toHaveLength(3); + expect(queryAll("form .o_search_result_item")).toHaveLength(3); await press("escape"); - expect(queryAll("form a:has(.o_search_result_item)")).toHaveLength(0); + expect(queryAll("form .o_search_result_item")).toHaveLength(0); }); diff --git a/addons/website/tests/test_fuzzy.py b/addons/website/tests/test_fuzzy.py index cff1a5be7366e..e8a3b30c0a108 100644 --- a/addons/website/tests/test_fuzzy.py +++ b/addons/website/tests/test_fuzzy.py @@ -112,9 +112,7 @@ def setUpClass(cls): super().setUpClass() cls.website = cls.env['website'].browse(1) cls.WebsiteController = Website() - cls.options = { - 'displayDescription': True, - } + cls.options = {} cls.expectedParts = { 'name': True, 'description': True, @@ -152,7 +150,7 @@ def _autocomplete(self, term): if suggestions['results_count']: self.assertDictEqual(self.expectedParts, suggestions['parts'], f"Parts should contain {self.expectedParts.keys()}") - for result in suggestions['results']: + for result in suggestions['results'].get("pages", {}).get('data', []): self.assertEqual("fa-file-o", result['_fa'], "Expect an fa icon") for field in suggestions['parts'].keys(): value = result[field] @@ -218,36 +216,40 @@ def test_shorten_around_match(self): def test_01_few_results(self): """ Tests an autocomplete with exact match and less than the maximum number of results """ suggestions = self._autocomplete("few") + results = suggestions['results'].get("pages", {}).get('data', []) self.assertEqual(2, suggestions['results_count'], "Text data contains two pages with 'few'") - self.assertEqual(2, len(suggestions['results']), "All results must be present") + self.assertEqual(2, len(results), "All results must be present") self.assertFalse(suggestions['fuzzy_search'], "Expects an exact match") - for result in suggestions['results']: + for result in results: self._check_highlight("few", result['name']) self._check_highlight("few", result['description']) def test_02_many_results(self): """ Tests an autocomplete with exact match and more than the maximum number of results """ suggestions = self._autocomplete("many") + results = suggestions['results'].get("pages", {}).get('data', []) self.assertEqual(6, suggestions['results_count'], "Test data contains six pages with 'many'") - self.assertEqual(5, len(suggestions['results']), "Results must be limited to 5") + self.assertEqual(6, len(results), "Results must be limited to 6") self.assertFalse(suggestions['fuzzy_search'], "Expects an exact match") - for result in suggestions['results']: + for result in results: self._check_highlight("many", result['name']) self._check_highlight("many", result['description']) def test_03_no_result(self): """ Tests an autocomplete without matching results """ suggestions = self._autocomplete("nothing") + results = suggestions['results'].get("pages", {}).get('data', []) self.assertEqual(0, suggestions['results_count'], "Text data contains no page with 'nothing'") - self.assertEqual(0, len(suggestions['results']), "No result must be present") + self.assertEqual(0, len(results), "No result must be present") def test_04_fuzzy_results(self): """ Tests an autocomplete with fuzzy matching results """ suggestions = self._autocomplete("appoximtly") + results = suggestions['results'].get("pages", {}).get('data', []) self.assertEqual("approximately", suggestions['fuzzy_search'], "") self.assertEqual(1, suggestions['results_count'], "Text data contains one page with 'approximately'") - self.assertEqual(1, len(suggestions['results']), "Single result must be present") - for result in suggestions['results']: + self.assertEqual(1, len(results), "Single result must be present") + for result in results: self._check_highlight("approximately", result['name']) self._check_highlight("approximately", result['description']) @@ -256,19 +258,21 @@ def test_05_long_url(self): url = "/this-url-is-so-long-it-would-be-truncated-without-the-fix" self._create_page("Too long", "Way too long URL", url) suggestions = self._autocomplete("long url") + results = suggestions['results'].get("pages", {}).get('data', []) self.assertEqual(1, suggestions['results_count'], "Text data contains one page with 'long url'") - self.assertEqual(1, len(suggestions['results']), "Single result must be present") - self.assertEqual(url, suggestions['results'][0]['website_url'], 'URL must not be truncated') + self.assertEqual(1, len(results), "Single result must be present") + self.assertEqual(url, results[0]['website_url'], 'URL must not be truncated') def test_06_case_insensitive_results(self): """ Tests an autocomplete with exact match and more than the maximum number of results. """ suggestions = self._autocomplete("Many") + results = suggestions['results'].get("pages", {}).get('data', []) self.assertEqual(6, suggestions['results_count'], "Test data contains six pages with 'Many'") - self.assertEqual(5, len(suggestions['results']), "Results must be limited to 5") + self.assertEqual(6, len(results), "Results must be limited to 6") self.assertFalse(suggestions['fuzzy_search'], "Expects an exact match") - for result in suggestions['results']: + for result in results: self._check_highlight("many", result['name']) self._check_highlight("many", result['description']) @@ -301,5 +305,6 @@ def test_09_hyphen(self): self.assertEqual(1, suggestions['results_count'], "Text data contains one page with 'weekend'") self.assertEqual('week-end', suggestions['fuzzy_search'], "Expects a fuzzy match") suggestions = self._autocomplete("week-end") - self.assertEqual(1, len(suggestions['results']), "All results must be present") + results = suggestions['results'].get("pages", {}).get('data', []) + self.assertEqual(1, len(results), "All results must be present") self.assertFalse(suggestions['fuzzy_search'], "Expects an exact match") diff --git a/addons/website/views/snippets/s_searchbar.xml b/addons/website/views/snippets/s_searchbar.xml index ce1de47656189..f088efc723990 100644 --- a/addons/website/views/snippets/s_searchbar.xml +++ b/addons/website/views/snippets/s_searchbar.xml @@ -5,7 +5,7 @@ sitemap.xml + + + +