From 062e7b1c218f77a513d159e1d630f0b23249fb39 Mon Sep 17 00:00:00 2001 From: Pierre Paridans Date: Wed, 14 Sep 2022 08:28:25 +0000 Subject: [PATCH] [IMP] web,*: make ControlPanel, SearchView, SearchPanel responsive This commit transfers responsive related customizations for ControlPanel, SearchView and SearchPanel to community which allows them to render properly on smaller devices. Note: a page reload (aka. F5) is required to properly adapt the UI after a resize to a mobile-like size. X-original-commit: 26a755610da36b01ba07ca224cc5937a672eb6a8 Part-of: odoo/odoo#100759 --- .../project_control_panel.xml | 23 +- .../search/stock_report_search_panel.xml | 9 +- .../src/search/control_panel/control_panel.js | 127 ++++++++- .../search/control_panel/control_panel.scss | 120 ++++++++- .../search/control_panel/control_panel.xml | 189 ++++++++++++-- .../src/search/search_panel/search_panel.js | 68 +++++ .../src/search/search_panel/search_panel.scss | 17 ++ .../search_panel/search_panel.variables.scss | 22 ++ .../src/search/search_panel/search_panel.xml | 77 +++++- .../src/search/search_panel/search_view.scss | 244 ++++++++++++++---- .../search_panel/search_view_extra.scss | 25 -- .../tests/mobile/control_panel_tests.js | 158 ++++++++++++ .../static/tests/mobile/search_panel_tests.js | 75 ++++++ .../static/src/components/views/page_list.xml | 8 +- .../src/components/views/theme_preview.xml | 16 +- 15 files changed, 1056 insertions(+), 122 deletions(-) delete mode 100644 addons/web/static/src/search/search_panel/search_view_extra.scss create mode 100644 addons/web/static/tests/mobile/control_panel_tests.js create mode 100644 addons/web/static/tests/mobile/search_panel_tests.js diff --git a/addons/project/static/src/components/project_control_panel/project_control_panel.xml b/addons/project/static/src/components/project_control_panel/project_control_panel.xml index 7fb222cc7b8..7d97a0b7e5c 100644 --- a/addons/project/static/src/components/project_control_panel/project_control_panel.xml +++ b/addons/project/static/src/components/project_control_panel/project_control_panel.xml @@ -25,10 +25,31 @@ - + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/stock/static/src/views/search/stock_report_search_panel.xml b/addons/stock/static/src/views/search/stock_report_search_panel.xml index 0fddad34d1f..e3990c4f3ee 100644 --- a/addons/stock/static/src/views/search/stock_report_search_panel.xml +++ b/addons/stock/static/src/views/search/stock_report_search_panel.xml @@ -1,7 +1,7 @@ - +
@@ -21,4 +21,11 @@
+ + + + + + +
diff --git a/addons/web/static/src/search/control_panel/control_panel.js b/addons/web/static/src/search/control_panel/control_panel.js index ef3726d51d1..220d25f4617 100644 --- a/addons/web/static/src/search/control_panel/control_panel.js +++ b/addons/web/static/src/search/control_panel/control_panel.js @@ -1,5 +1,6 @@ /** @odoo-module **/ +import { browser } from "@web/core/browser/browser"; import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service"; import { Pager } from "@web/core/pager/pager"; import { useService } from "@web/core/utils/hooks"; @@ -8,8 +9,9 @@ import { FavoriteMenu } from "../favorite_menu/favorite_menu"; import { FilterMenu } from "../filter_menu/filter_menu"; import { GroupByMenu } from "../group_by_menu/group_by_menu"; import { SearchBar } from "../search_bar/search_bar"; +import { Dropdown } from "@web/core/dropdown/dropdown"; -const { Component, useState } = owl; +const { Component, useState, onMounted, useExternalListener, useRef, useEffect } = owl; const MAPPING = { filter: FilterMenu, @@ -18,24 +20,66 @@ const MAPPING = { favorite: FavoriteMenu, }; +const STICKY_CLASS = "o_mobile_sticky"; + export class ControlPanel extends Component { setup() { this.actionService = useService("action"); this.pagerProps = this.env.config.pagerProps ? useState(this.env.config.pagerProps) : undefined; + + this.root = useRef("root"); + + this.state = useState({ + showSearchBar: false, + showMobileSearch: false, + showViewSwitcher: false, + }); + + this.onScrollThrottledBound = this.onScrollThrottled.bind(this); + + useExternalListener(window, "click", this.onWindowClick); + useEffect(() => { + if ( + !this.env.isSmall || + ("adaptToScroll" in this.display && !this.display.adaptToScroll) + ) { + return; + } + const scrollingEl = this.getScrollingElement(); + scrollingEl.addEventListener("scroll", this.onScrollThrottledBound); + this.root.el.style.top = "0px"; + return () => { + scrollingEl.removeEventListener("scroll", this.onScrollThrottledBound); + }; + }); + onMounted(() => { + if ( + !this.env.isSmall || + ("adaptToScroll" in this.display && !this.display.adaptToScroll) + ) { + return; + } + this.oldScrollTop = 0; + this.lastScrollTop = 0; + this.initialScrollTop = this.getScrollingElement().scrollTop; + }); + } + + getScrollingElement() { + return this.root.el.parentElement; } /** - * @param {KeyboardEvent} ev + * Reset mobile search state */ - onBottomLeftKeydown(ev) { - const hotkey = getActiveHotkey(ev); - if (hotkey === "arrowdown") { - this.env.searchModel.trigger("focus-view"); - ev.preventDefault(); - ev.stopPropagation(); - } + resetSearchState() { + Object.assign(this.state, { + showSearchBar: false, + showMobileSearch: false, + showViewSwitcher: false, + }); } /** @@ -85,18 +129,81 @@ export class ControlPanel extends Component { } /** - * Called when a view is clicked in the view switcher. + * Show or hide the control panel on the top screen. + * The function is throttled to avoid refreshing the scroll position more + * often than necessary. + */ + onScrollThrottled() { + if (this.isScrolling) { + return; + } + this.isScrolling = true; + browser.requestAnimationFrame(() => (this.isScrolling = false)); + + const scrollTop = this.getScrollingElement().scrollTop; + const delta = Math.round(scrollTop - this.oldScrollTop); + + if (scrollTop > this.initialScrollTop) { + // Beneath initial position => sticky display + this.root.el.classList.add(STICKY_CLASS); + if (delta < 0) { + // Going up + this.lastScrollTop = Math.min(0, this.lastScrollTop - delta); + } else { + // Going down | not moving + this.lastScrollTop = Math.max( + -this.root.el.offsetHeight, + -this.root.el.offsetTop - delta + ); + } + this.root.el.style.top = `${this.lastScrollTop}px`; + } else { + // Above initial position => standard display + this.root.el.classList.remove(STICKY_CLASS); + this.lastScrollTop = 0; + } + + this.oldScrollTop = scrollTop; + } + + /** + * Called when a view is clicked in the view switcher + * and reset mobile search state on switch view. * * @param {ViewType} viewType */ onViewClicked(viewType) { + this.resetSearchState(); this.actionService.switchView(viewType); } + + /** + * @private + * @param {MouseEvent} ev + */ + onWindowClick(ev) { + if (this.state.showViewSwitcher && !ev.target.closest(".o_cp_switch_buttons")) { + this.state.showViewSwitcher = false; + } + } + + /** + * @param {KeyboardEvent} ev + */ + onBottomLeftKeydown(ev) { + const hotkey = getActiveHotkey(ev); + if (hotkey === "arrowdown") { + this.env.searchModel.trigger("focus-view"); + ev.preventDefault(); + ev.stopPropagation(); + } + } } ControlPanel.components = { ...Object.values(MAPPING), Pager, SearchBar, + Dropdown, }; ControlPanel.template = "web.ControlPanel"; diff --git a/addons/web/static/src/search/control_panel/control_panel.scss b/addons/web/static/src/search/control_panel/control_panel.scss index 78c585c91c9..b17df5f9897 100644 --- a/addons/web/static/src/search/control_panel/control_panel.scss +++ b/addons/web/static/src/search/control_panel/control_panel.scss @@ -1,4 +1,3 @@ - .o_control_panel { border-bottom: 1px solid $border-color; @include o-webclient-padding($top: 5px, $bottom: 5px); @@ -17,12 +16,111 @@ } } + @include media-breakpoint-down(md) { + padding-right: 0; + width: 100%; + + > div.o_cp_bottom { + min-height: auto; + } + + &.o_mobile_sticky { + @include o-position-sticky(); + z-index: 10; + } + + .o_cp_action_menus { + .o_dropdown_title { + @include visually-hidden; + } + + .o_dropdown_chevron, .o_dropdown_caret { + display: none; + } + } + + .o_cp_searchview { + min-height: 35px; + + &.o_searchview_quick { + display: flex; + align-items: flex-start; + width: 100%; + + > .o_searchview { + display: flex; + flex: 1 1 auto; + align-items: center; + border-bottom: 1px solid $o-brand-secondary; + + > .o_searchview_input_container { + flex: 1 1 auto; + } + > .o_enable_searchview { + margin: 0; + padding-left: 0; + &, &:hover { + color: $gray-600; + } + } + } + } + } + + .o_cp_bottom_left, .o_cp_bottom_right { + flex: 1 1 auto; + } + + .o_cp_switch_buttons { + &.show > .dropdown-menu { + display: inline-flex; + min-width: 0px; + } + } + } + .breadcrumb { font-size: 18px; > li { @include o-text-overflow($max-width: 90%); } + + @include media-breakpoint-down(md) { + width: 100%; + overflow: hidden; + + > li { + margin: 0; + + &:before { + display: none; + } + + &.o_back_button { + &, & ~ li { + margin: auto 0; + } + + &:before { + font-family: FontAwesome; + content: ""; // fa-arrow-left + display: inline-block; + + padding: 0; // override bootstrap + color: inherit; + } + + &.btn { + padding: 0.4rem; + } + + > a { + display: none; + } + } + } + } } .o_cp_top_right { @@ -42,6 +140,20 @@ padding-right: 10px; } + @include media-breakpoint-up(sm) { + display: flex; + padding-right: 25px; + + > .btn-group { + margin: auto 0; + display: block; + } + + .dropdown-toggle { + margin-right: 0; + } + } + .o_hidden_input_file { position: relative; input.o_input_file { @@ -119,6 +231,12 @@ } } +@include media-breakpoint-down(md) { + .o_rtl .o_control_panel .o_back_button:before { + transform: rotate(180deg); + } +} + @media print { .o_control_panel { display: none; diff --git a/addons/web/static/src/search/control_panel/control_panel.xml b/addons/web/static/src/search/control_panel/control_panel.xml index d26bb729023..e83a9217e15 100644 --- a/addons/web/static/src/search/control_panel/control_panel.xml +++ b/addons/web/static/src/search/control_panel/control_panel.xml @@ -3,50 +3,170 @@
+ + + + + + +
+
+ + +
+
+ + + +
+
+ + + +
+
+
+
+ + +
+
+ + + + + + + + + +
+
+
+ + + + + + + +
-
- - + + + -
-
- - + + +
+
-
- - +
+ +
- - - -
-
+
@@ -67,4 +187,21 @@ + + + + diff --git a/addons/web/static/src/search/search_panel/search_panel.js b/addons/web/static/src/search/search_panel/search_panel.js index 4713abfc719..fbf3f759bbf 100644 --- a/addons/web/static/src/search/search_panel/search_panel.js +++ b/addons/web/static/src/search/search_panel/search_panel.js @@ -4,6 +4,27 @@ import { useBus } from "@web/core/utils/hooks"; const { Component, onMounted, onWillStart, useRef, useState } = owl; +//------------------------------------------------------------------------- +// Helpers +//------------------------------------------------------------------------- + +const isFilter = (s) => s.type === "filter"; +const isActiveCategory = (s) => s.type === "category" && s.activeValueId; + +/** + * @param {Map} values + * @returns {Object[]} + */ +const nameOfCheckedValues = (values) => { + const names = []; + for (const [, value] of values) { + if (value.checked) { + names.push(value.display_name); + } + } + return names; +}; + /** * Search panel * @@ -18,6 +39,7 @@ export class SearchPanel extends Component { this.state = useState({ active: {}, expanded: {}, + showMobileSearch: false, }); this.root = useRef("root"); this.scrollTop = 0; @@ -108,6 +130,52 @@ export class SearchPanel extends Component { return parentId ? [...this.getAncestorValueIds(category, parentId), parentId] : []; } + /** + * Returns a formatted version of the active categories to populate + * the selection banner of the control panel summary. + * @returns {Object[]} + */ + getCategorySelection() { + const activeCategories = this.env.searchModel.getSections(isActiveCategory); + const selection = []; + for (const category of activeCategories) { + const parentIds = this.getAncestorValueIds(category, category.activeValueId); + const orderedCategoryNames = [...parentIds, category.activeValueId].map( + (valueId) => category.values.get(valueId).display_name + ); + selection.push({ + values: orderedCategoryNames, + icon: category.icon, + color: category.color, + }); + } + return selection; + } + + /** + * Returns a formatted version of the active filters to populate + * the selection banner of the control panel summary. + * @returns {Object[]} + */ + getFilterSelection() { + const filters = this.env.searchModel.getSections(isFilter); + const selection = []; + for (const { groups, values, icon, color } of filters) { + let filterValues; + if (groups) { + filterValues = Object.keys(groups) + .map((groupId) => nameOfCheckedValues(groups[groupId].values)) + .flat(); + } else if (values) { + filterValues = nameOfCheckedValues(values); + } + if (filterValues.length) { + selection.push({ values: filterValues, icon, color }); + } + } + return selection; + } + /** * Prevent unnecessary calls to the model by ensuring a different category * is clicked. diff --git a/addons/web/static/src/search/search_panel/search_panel.scss b/addons/web/static/src/search/search_panel/search_panel.scss index 76eac6091fe..93d848ab09d 100644 --- a/addons/web/static/src/search/search_panel/search_panel.scss +++ b/addons/web/static/src/search/search_panel/search_panel.scss @@ -37,3 +37,20 @@ width: map-get($spacers, 4); } } + +.o_mobile_search_content { + --SearchPanel-width: 100%; + --SearchPanel-fontSize: 1.1em; +} + +@include media-breakpoint-down(md) { + .o_component_with_search_panel, + .o_controller_with_searchpanel { + flex-direction: column; + align-items: initial; + + .o_renderer_with_searchpanel.o_list_view { + width: 100%; + } + } +} diff --git a/addons/web/static/src/search/search_panel/search_panel.variables.scss b/addons/web/static/src/search/search_panel/search_panel.variables.scss index 9c9f8299966..deca4c5b8ba 100644 --- a/addons/web/static/src/search/search_panel/search_panel.variables.scss +++ b/addons/web/static/src/search/search_panel/search_panel.variables.scss @@ -3,3 +3,25 @@ $o-search-panel-width: 200px; $o-search-panel-font-size: 1em; + +@mixin o-details-modal($top: 0, $bottom: 0) { + position: fixed; + z-index: $zindex-modal; + right: 0; + top: $top; + bottom: $bottom; + left: 0; +} + +@mixin o-details-modal-header { + padding: 0.7rem 1.4rem; + height: $o-navbar-height; +} + +@mixin o-details-hide-caret { + // Hide the caret. For details see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/summary + list-style-type: none; + &::-webkit-details-marker { + display: none; + } +} diff --git a/addons/web/static/src/search/search_panel/search_panel.xml b/addons/web/static/src/search/search_panel/search_panel.xml index 926c347ecdd..e4aa92df2ea 100644 --- a/addons/web/static/src/search/search_panel/search_panel.xml +++ b/addons/web/static/src/search/search_panel/search_panel.xml @@ -2,6 +2,15 @@ + + + + + + + + +
+ + + + + + + + + + + + + + diff --git a/addons/web/static/src/search/search_panel/search_view.scss b/addons/web/static/src/search/search_panel/search_view.scss index 2ae6ec39b99..9ecb64bdc51 100644 --- a/addons/web/static/src/search/search_panel/search_view.scss +++ b/addons/web/static/src/search/search_panel/search_view.scss @@ -3,78 +3,107 @@ padding: 0 20px 1px 0; position: relative; + @include media-breakpoint-up(md) { + border-bottom: 1px solid $border-color; + padding-bottom: 3px; + + &:focus-within { + border-bottom-color: $primary; + } + } + + @include media-breakpoint-down(md) { + margin-right: 5px; // o_switch_view_button_icon alignment + padding-right: 0; + } + .o_searchview_input_container { display: flex; flex-flow: row wrap; position: relative; + } - .o_searchview_facet { + .o_searchview_facet { + display: flex; + flex: 0 0 auto; + margin: 1px 3px 0 0; + max-width: 100%; + position: relative; + $o-searchview-facet-remove-width: 18px; + + @include media-breakpoint-down(md) { + flex-flow: row wrap; + } + + .o_searchview_facet_label { + align-items: center; + flex: 0 0 auto; + padding: 0 3px; + @include o-text-overflow($display: flex); + } + + .o_facet_values { + direction: ltr#{"/*rtl:ignore*/"}; + padding: 0 $o-searchview-facet-remove-width 0 5px; + + .o_facet_values_sep { + font-style: italic; + margin: 0 0.3rem; + } + } + + .o_facet_remove { + align-items: center; + cursor: pointer; display: flex; flex: 0 0 auto; - margin: 1px 3px 0 0; - max-width: 100%; - position: relative; - $o-searchview-facet-remove-width: 18px; - - .o_searchview_facet_label { - align-items: center; - flex: 0 0 auto; - padding: 0 3px; - @include o-text-overflow($display: flex); - } - - .o_facet_values { - direction: ltr#{"/*rtl:ignore*/"}; - padding: 0 $o-searchview-facet-remove-width 0 5px; - - .o_facet_values_sep { - font-style: italic; - margin: 0 0.3rem; - } - } - - .o_facet_remove { - align-items: center; - cursor: pointer; - display: flex; - flex: 0 0 auto; - justify-content: center; - width: $o-searchview-facet-remove-width; - @include o-position-absolute(0, 0, 0); - } + justify-content: center; + width: $o-searchview-facet-remove-width; + @include o-position-absolute(0, 0, 0); + @include o-hover-text-color($text-muted, $o-brand-odoo); } + } - .o_searchview_input { - flex: 1 0 auto; - width: 75px; - } + .o_searchview_input { + flex: 1 0 auto; + width: 75px; + border: none; + outline: none; + } - .o_searchview_autocomplete { - width: 100%; - @include o-position-absolute(100%, $left: auto); + .o_searchview_autocomplete { + width: 100%; + @include o-position-absolute(100%, $left: auto); - .o_menu_item { - align-items: center; - display: flex; - padding-left: 25px; + .o_menu_item { + align-items: center; + display: flex; + padding-left: 25px; - &.o_indent { - padding-left: 50px; - } + &.o_indent { + padding-left: 50px; + } - a { - color: inherit; + a { + color: inherit; - &.o_expand { - display: flex; - justify-content: center; - width: 25px; - @include o-position-absolute($left: 0); - } + &.o_expand { + display: flex; + justify-content: center; + width: 25px; + @include o-position-absolute($left: 0); } } } } + + .o_searchview_icon { + @include o-position-absolute($top: 4px, $right: 0); + } +} + +.o_search_options { + margin: auto 0; } // Filter menu @@ -115,3 +144,110 @@ } } } + +.o_web_client .o_mobile_search { + align-items: normal; + background-color: white; + border: none; + bottom: 0; + display: flex; + flex-direction: column; + left: 0; + overflow: auto; + padding: 0; + position: fixed; + top: 0; + width: 100%; + z-index: $zindex-modal; + + .o_mobile_search_header { + background-color: $o-brand-odoo; + display: flex; + min-height: $o-navbar-height; + justify-content: space-between; + width: 100%; + + .o_mobile_search_button { + color: white; + + &:active { + background-color: darken($o-brand-primary, 10%); + } + } + } + + .o_mobile_search_content { + flex: auto; + height: auto; + overflow-y: auto; + width: 100%; + + .o_searchview_input_container { + display: flex; + padding: 15px 20px 0 20px; + position: relative; + + .o_searchview_input { + border-bottom: 1px solid $o-brand-secondary; + margin-bottom: 15px; + width: 100%; + } + + .o_searchview_facet { + border-radius: 10px; + display: inline-flex; + order: 1; + + .o_searchview_facet_label { + border-radius: 2em 0em 0em 2em; + } + } + + .o_searchview_autocomplete { + top: 100%; + + > li { + margin: 5px 0px; + } + } + } + + .o_mobile_search_filter { + padding: 8px 20px 15%; + flex: auto; + + .dropdown { + margin-top: 15px; + flex-direction: column; + line-height: 2; + + &:not(.show) { + box-shadow: 0 0 0 1px $border-color; + } + } + + .dropdown, .dropdown-toggle { + width: 100%; + } + + // We disable the backdrop in this case because it prevents any + // interaction outside of a dropdown while it is open. + .dropdown-backdrop { + z-index: -1; + } + + .dropdown-menu { + // Here we use !important because of popper js adding custom style + // to element so to override it use !important + position: relative !important; + top: 0 !important; + left: 0 !important; + width: 100%; + } + } + } + + .o_mobile_search_footer { + width: 100%; + } +} diff --git a/addons/web/static/src/search/search_panel/search_view_extra.scss b/addons/web/static/src/search/search_panel/search_view_extra.scss deleted file mode 100644 index dfb99632951..00000000000 --- a/addons/web/static/src/search/search_panel/search_view_extra.scss +++ /dev/null @@ -1,25 +0,0 @@ -.o_searchview { - background-color: white; - border: 1px solid #ccc; - border-radius: $border-radius; - padding: 1px 25px 3px 5px; - - .o_searchview_facet { - background: $o-brand-lightsecondary; - - .o_facet_remove { - color: map-get($grays, '600'); - &:hover { - color: map-get($grays, '700'); - } - } - } - - .o_searchview_input { - border: none; - padding: 4px 0 0 0; - } - .o_searchview_icon { - @include o-position-absolute($top: 6px, $right: 5px); - } -} diff --git a/addons/web/static/tests/mobile/control_panel_tests.js b/addons/web/static/tests/mobile/control_panel_tests.js new file mode 100644 index 00000000000..59aa373f50a --- /dev/null +++ b/addons/web/static/tests/mobile/control_panel_tests.js @@ -0,0 +1,158 @@ +/** @odoo-module **/ + +import { click, getFixture, triggerEvent, nextTick } from "@web/../tests/helpers/utils"; +import { ControlPanel } from "@web/search/control_panel/control_panel"; +import { + editSearch, + makeWithSearch, + setupControlPanelServiceRegistry, +} from "@web/../tests/search/helpers"; +import { registry } from "@web/core/registry"; +import { uiService } from "@web/core/ui/ui_service"; + +let serverData; +let target; + +QUnit.module("Search", (hooks) => { + hooks.beforeEach(async () => { + setupControlPanelServiceRegistry(); + target = getFixture(); + registry.category("services").add("ui", uiService); + + serverData = { + models: { + foo: { + fields: { + birthday: { string: "Birthday", type: "date", store: true, sortable: true }, + date_field: { string: "Date", type: "date", store: true, sortable: true }, + }, + records: [{ date_field: "2022-02-14" }], + }, + }, + views: { + "foo,false,search": ` + + + + + `, + }, + }; + }); + + QUnit.module("Control Panel (mobile)"); + + QUnit.test("Display control panel mobile", async (assert) => { + await makeWithSearch({ + serverData, + resModel: "foo", + Component: ControlPanel, + searchMenuTypes: ["filter"], + searchViewId: false, + }); + + assert.containsOnce(target, ".breadcrumb"); + assert.containsOnce(target, ".o_enable_searchview"); + assert.containsNone(target, ".o_searchview"); + assert.containsNone(target, ".o_toggle_searchview_full"); + + await click(target, ".o_enable_searchview"); + + assert.containsNone(target, ".breadcrumb"); + assert.containsOnce(target, ".o_enable_searchview"); + assert.containsOnce(target, ".o_searchview"); + assert.containsOnce(target, ".o_toggle_searchview_full"); + + await click(target, ".o_toggle_searchview_full"); + + assert.containsOnce(document.body, ".o_searchview.o_mobile_search"); + assert.containsN(document.body, ".o_mobile_search .o_mobile_search_button", 2); + assert.strictEqual( + document.body.querySelector(".o_mobile_search_header").textContent.trim(), + "FILTER CLEAR" + ); + assert.containsOnce(document.body, ".o_searchview.o_mobile_search .o_cp_searchview"); + assert.containsOnce(document.body, ".o_searchview.o_mobile_search .o_mobile_search_footer"); + + await click(document.body.querySelector(".o_mobile_search_button")); + + assert.containsNone(target, ".breadcrumb"); + assert.containsOnce(target, ".o_enable_searchview"); + assert.containsOnce(target, ".o_searchview"); + assert.containsOnce(target, ".o_toggle_searchview_full"); + + await click(target, ".o_enable_searchview"); + + assert.containsOnce(target, ".breadcrumb"); + assert.containsOnce(target, ".o_enable_searchview"); + assert.containsNone(target, ".o_searchview"); + assert.containsNone(target, ".o_toggle_searchview_full"); + }); + + QUnit.test("Make a simple search in mobile mode", async (assert) => { + await makeWithSearch({ + serverData, + resModel: "foo", + Component: ControlPanel, + searchMenuTypes: ["filter"], + searchViewFields: { + birthday: { string: "Birthday", type: "date", store: true, sortable: true }, + }, + searchViewArch: ` + + + + `, + }); + assert.containsNone(target, ".o_searchview"); + + await click(target, ".o_enable_searchview"); + assert.containsOnce(target, ".o_searchview"); + const input = target.querySelector(".o_searchview input"); + assert.containsNone(target, ".o_searchview_autocomplete"); + + await editSearch(target, "2022-02-14"); + assert.strictEqual(input.value, "2022-02-14", "input value should be updated"); + assert.containsOnce(target, ".o_searchview_autocomplete"); + + await triggerEvent(input, null, "keydown", { key: "Escape" }); + assert.containsNone(target, ".o_searchview_autocomplete"); + + await click(target, ".o_enable_searchview"); + assert.containsNone(target, ".o_searchview"); + }); + + QUnit.test("Control panel is shown/hide on top when scrolling", async (assert) => { + await makeWithSearch({ + serverData, + resModel: "foo", + Component: ControlPanel, + searchMenuTypes: ["filter"], + }); + const contentHeight = 200; + const sampleContent = document.createElement("div"); + sampleContent.style.minHeight = `${2 * contentHeight}px`; + target.appendChild(sampleContent); + const { maxHeight, overflow } = target.style; + target.style.maxHeight = `${contentHeight}px`; + target.style.overflow = "auto"; + target.scrollTo({ top: 50 }); + await nextTick(); + + assert.hasClass( + target.querySelector(".o_control_panel"), + "o_mobile_sticky", + "control panel becomes sticky when the target is not on top" + ); + target.scrollTo({ top: -50 }); + await nextTick(); + + assert.doesNotHaveClass( + target.querySelector(".o_control_panel"), + "o_mobile_sticky", + "control panel is not sticky anymore" + ); + target.style.maxHeight = maxHeight; + target.style.overflow = overflow; + }); +}); diff --git a/addons/web/static/tests/mobile/search_panel_tests.js b/addons/web/static/tests/mobile/search_panel_tests.js new file mode 100644 index 00000000000..b78d4e4c077 --- /dev/null +++ b/addons/web/static/tests/mobile/search_panel_tests.js @@ -0,0 +1,75 @@ +/** @odoo-module **/ + +import { click, getFixture } from "@web/../tests/helpers/utils"; +import { SearchPanel } from "@web/search/search_panel/search_panel"; +import { makeWithSearch, setupControlPanelServiceRegistry } from "@web/../tests/search/helpers"; +import { registry } from "@web/core/registry"; +import { uiService } from "@web/core/ui/ui_service"; + +const { Component, xml } = owl; + +let serverData; +let target; + +QUnit.module("Search", (hooks) => { + hooks.beforeEach(async () => { + setupControlPanelServiceRegistry(); + target = getFixture(); + registry.category("services").add("ui", uiService); + + serverData = { + models: { + foo: { + fields: { + tag_id: { + string: "Many2One", + type: "many2one", + relation: "tag", + store: true, + sortable: true, + }, + }, + records: [ + { id: 1, tag_id: 2 }, + { id: 2, tag_id: 1 }, + { id: 3, tag_id: 1 }, + ], + }, + tag: { + fields: { + name: { string: "Name", type: "string" }, + }, + records: [ + { id: 1, name: "Gold" }, + { id: 2, name: "Silver" }, + ], + }, + }, + }; + }); + + QUnit.module("Search Panel (mobile)"); + + QUnit.test("basic search panel rendering", async (assert) => { + class Parent extends Component {} + Parent.components = { SearchPanel }; + Parent.template = xml``; + await makeWithSearch({ + serverData, + resModel: "foo", + Component: Parent, + searchViewFields: serverData.models.foo.fields, + searchViewArch: ` + + + + + `, + }); + assert.containsOnce(target, ".o_search_panel.o_search_panel_summary"); + + await click(target, ".o_search_panel .o_search_panel_current_selection"); + assert.containsOnce(document.body, ".o_search_panel.o_mobile_search"); + assert.containsN(document.body, ".o_search_panel_category_value", 3); // All, Gold, Silver + }); +}); diff --git a/addons/website/static/src/components/views/page_list.xml b/addons/website/static/src/components/views/page_list.xml index 9949e251d05..a64e6368b67 100644 --- a/addons/website/static/src/components/views/page_list.xml +++ b/addons/website/static/src/components/views/page_list.xml @@ -33,10 +33,16 @@ - + + + + + + + diff --git a/addons/website/static/src/components/views/theme_preview.xml b/addons/website/static/src/components/views/theme_preview.xml index 545b9aa3675..1c970c4999b 100644 --- a/addons/website/static/src/components/views/theme_preview.xml +++ b/addons/website/static/src/components/views/theme_preview.xml @@ -16,7 +16,7 @@
- +
@@ -27,7 +27,13 @@ - + + + + + + +