[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
This commit is contained in:
Pierre Paridans
2022-09-23 01:39:32 +02:00
parent 66517b0e0b
commit 062e7b1c21
15 changed files with 1056 additions and 122 deletions
@@ -25,10 +25,31 @@
</xpath>
</t>
<t t-name="project.ProjectControlPanel" t-inherit="web.ControlPanel" t-inherit-mode="primary" owl="1">
<t t-name="project.Breadcrumbs.Small" t-inherit="web.Breadcrumbs.Small" t-inherit-mode="primary" owl="1">
<xpath expr="//ol" position="inside">
<t t-call="project.ProjectControlPanelContent"/>
</xpath>
</t>
<t t-name="project.ProjectControlPanel.Regular" t-inherit="web.ControlPanel.Regular" t-inherit-mode="primary" owl="1">
<xpath expr="//t[@t-call='web.Breadcrumbs']" position="replace">
<t t-call="project.Breadcrumbs"/>
</xpath>
</t>
<t t-name="project.ProjectControlPanel.Small" t-inherit="web.ControlPanel.Small" t-inherit-mode="primary" owl="1">
<xpath expr="//t[@t-call='web.Breadcrumbs.Small']" position="replace">
<t t-call="project.Breadcrumbs.Small"/>
</xpath>
</t>
<t t-name="project.ProjectControlPanel" t-inherit="web.ControlPanel" t-inherit-mode="primary" owl="1">
<xpath expr="//t[@t-call='web.ControlPanel.Regular']" position="replace">
<t t-call="project.ProjectControlPanel.Regular"/>
</xpath>
<xpath expr="//t[@t-call='web.ControlPanel.Small']" position="replace">
<t t-call="project.ProjectControlPanel.Small"/>
</xpath>
</t>
</templates>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="stock.StockReportSearchPanel" t-inherit="web.SearchPanel" owl="1">
<t t-name="stock.StockReportSearchPanel.Regular" t-inherit="web.SearchPanel.Regular" owl="1">
<xpath expr="//div/section[1]" position="before">
<section t-if="warehouses.length > 1" class="o_search_panel_section o_search_panel_warehouse">
<header class="o_search_panel_section_header pt-4 pb-2 text-uppercase o_cursor_default">
@@ -21,4 +21,11 @@
</section>
</xpath>
</t>
<t t-name="stock.StockReportSearchPanel" t-inherit="web.SearchPanel" t-inherit-mode="primary" owl="1">
<xpath expr="//t[@t-call='web.SearchPanel.Regular']" position="replace">
<t t-call="stock.StockReportSearchPanel.Regular"/>
</xpath>
</t>
</templates>
@@ -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";
@@ -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;
@@ -3,50 +3,170 @@
<t t-name="web.ControlPanel" owl="1">
<div class="o_control_panel" t-ref="root">
<t t-if="env.isSmall">
<t t-call="web.ControlPanel.Small" />
</t>
<t t-else="">
<t t-call="web.ControlPanel.Regular" />
</t>
</div>
</t>
<t t-name="web.ControlPanel.Regular" owl="1">
<div t-if="display['top']" class="o_cp_top">
<div class="o_cp_top_left">
<t t-slot="control-panel-top-left" t-if="display['top-left']">
<t t-call="web.Breadcrumbs" t-if="!env.config.noBreadcrumbs"/>
</t>
</div>
<div class="o_cp_top_right">
<t t-slot="control-panel-top-right" t-if="display['top-right']">
<SearchBar/>
</t>
</div>
</div>
<div t-if="display['bottom']" class="o_cp_bottom">
<div class="o_cp_bottom_left" t-on-keydown="onBottomLeftKeydown">
<t t-slot="control-panel-bottom-left-buttons" t-if="display['bottom-left'] and display['bottom-left-buttons']" />
<t t-slot="control-panel-bottom-left" t-if="display['bottom-left']"/>
</div>
<div t-if="display['bottom-right']" class="o_cp_bottom_right">
<t t-slot="control-panel-bottom-right">
<div class="btn-group o_search_options position-static" role="search">
<t t-foreach="searchMenus" t-as="menu" t-key="menu.key">
<t t-component="menu.Component"/>
</t>
</div>
</t>
<div class="o_cp_pager" role="search">
<Pager t-if="pagerProps and pagerProps.total > 0" t-props="pagerProps"/>
</div>
<t t-if="env.config.viewSwitcherEntries.length > 1">
<nav class="btn-group o_cp_switch_buttons">
<t t-foreach="env.config.viewSwitcherEntries" t-as="view" t-key="view.type">
<button class="btn btn-light o_switch_view "
t-attf-class="o_{{view.type}} {{view.icon}} {{view.active ? 'active' : ''}}"
t-att-data-tooltip="view.name"
t-on-click="() => this.onViewClicked(view.type)"
/>
</t>
</nav>
</t>
</div>
</div>
</t>
<t t-name="web.ControlPanel.Small" owl="1">
<t t-if="state.showMobileSearch">
<t t-portal="'body'">
<div class="o_searchview o_mobile_search">
<div class="o_mobile_search_header">
<button
type="button"
class="o_mobile_search_button btn"
t-on-click="() => state.showMobileSearch = false"
>
<i class="fa fa-arrow-left"/>
<strong class="ms-2">FILTER</strong>
</button>
<button
type="button"
class="o_mobile_search_button btn"
t-on-click="() => env.searchModel.clearQuery()"
>
CLEAR
</button>
</div>
<div class="o_mobile_search_content">
<t t-if="display['top-right']">
<t t-slot="control-panel-top-right">
<SearchBar/>
</t>
</t>
<t t-if="display['bottom-right']">
<t t-slot="control-panel-bottom-right">
<div class="o_mobile_search_filter o_search_options">
<t t-foreach="searchMenus" t-as="menu" t-key="menu.key">
<t t-component="menu.Component"/>
</t>
</div>
</t>
</t>
</div>
<button
type="button"
class="btn btn-primary o_mobile_search_footer"
t-on-click="() => state.showMobileSearch = false"
>
SEE RESULT
</button>
</div>
</t>
</t>
<t t-else="">
<div t-if="display['top']" class="o_cp_top">
<div class="o_cp_top_left">
<t t-slot="control-panel-top-left" t-if="display['top-left']">
<t t-call="web.Breadcrumbs" t-if="!env.config.noBreadcrumbs"/>
<t t-if="display['top-left'] and !state.showSearchBar">
<t t-slot="control-panel-top-left">
<t t-call="web.Breadcrumbs.Small" />
</t>
</div>
<div class="o_cp_top_right">
<t t-slot="control-panel-top-right" t-if="display['top-right']">
<SearchBar/>
</t>
<t t-if="display['top-right']">
<button type="button" class="o_enable_searchview btn btn-link"
t-att-class="state.showSearchBar ? 'fa fa-arrow-left' : 'oi oi-search'"
t-on-click="() => state.showSearchBar = !state.showSearchBar"
/>
<t t-if="state.showSearchBar or !display['top-left']">
<t t-slot="control-panel-top-right">
<SearchBar class="o_searchview_quick" />
<button
type="button"
class="o_toggle_searchview_full btn fa fa-filter"
t-on-click="() => state.showMobileSearch = !state.showMobileSearch"
/>
</t>
</t>
</div>
</t>
</div>
<div t-if="display['bottom']" class="o_cp_bottom">
<div class="o_cp_bottom_left" t-on-keydown="onBottomLeftKeydown">
<t t-slot="control-panel-bottom-left-buttons" t-if="display['bottom-left'] and display['bottom-left-buttons']" />
<t t-slot="control-panel-bottom-left" t-if="display['bottom-left']"/>
<div t-if="display['bottom-left']" class="o_cp_bottom_left">
<t t-slot="control-panel-bottom-left-buttons" t-if="display['bottom-left-buttons']"/>
<t t-slot="control-panel-bottom-left"/>
</div>
<div t-if="display['bottom-right']" class="o_cp_bottom_right">
<t t-slot="control-panel-bottom-right">
<div class="btn-group o_search_options position-static" role="search">
<t t-foreach="searchMenus" t-as="menu" t-key="menu.key">
<t t-component="menu.Component"/>
</t>
</div>
</t>
<div class="o_cp_pager" role="search">
<Pager t-if="pagerProps and pagerProps.total > 0" t-props="pagerProps"/>
</div>
<t t-if="env.config.viewSwitcherEntries.length > 1">
<nav class="btn-group o_cp_switch_buttons">
<t t-foreach="env.config.viewSwitcherEntries" t-as="view" t-key="view.type">
<button class="btn btn-light o_switch_view "
t-attf-class="o_{{view.type}} {{view.icon}} {{view.active ? 'active' : ''}}"
t-att-data-tooltip="view.name"
t-on-click="() => this.onViewClicked(view.type)"
<t t-set="view" t-value="env.config.viewSwitcherEntries.find((v) => v.active)" />
<Dropdown
position="'bottom-end'"
menuClass="'d-inline-flex'"
togglerClass="'btn btn-link'"
>
<t t-set-slot="toggler">
<i
class="fa-lg o_switch_view"
t-attf-class="o_{{view.type}} {{view.icon}} {{view.active ? 'active' : ''}}"
/>
</t>
</t>
<t t-foreach="env.config.viewSwitcherEntries" t-as="view" t-key="view.type">
<button class="btn btn-light fa-lg o_switch_view"
t-attf-class="o_{{view.type}} {{view.icon}} {{view.active ? 'active' : ''}}"
t-att-data-tooltip="view.name"
t-on-click="() => this.onViewClicked(view.type)"
/>
</t>
</Dropdown>
</nav>
</t>
</div>
</div>
</div>
</t>
</t>
<t t-name="web.Breadcrumbs" owl="1">
@@ -67,4 +187,21 @@
</ol>
</t>
<t t-name="web.Breadcrumbs.Small" owl="1">
<ol class="breadcrumb">
<t t-set="breadcrumbs" t-value="env.config.breadcrumbs" />
<t t-if="breadcrumbs.length > 1">
<t t-set="breadcrumb" t-value="breadcrumbs[breadcrumbs.length - 2]" />
<li class="breadcrumb-item o_back_button btn btn-secondary"
t-on-click.prevent="() => this.onBreadcrumbClicked(breadcrumb.jsId)"
/>
</t>
<li t-if="breadcrumbs.length > 0" class="breadcrumb-item active">
<t t-set="breadcrumb" t-value="breadcrumbs[breadcrumbs.length - 1]" />
<t t-if="breadcrumb.name" t-esc="breadcrumb.name"/>
<em t-else="" class="text-warning">Unnamed</em>
</li>
</ol>
</t>
</templates>
@@ -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<string | false, Object>} 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.
@@ -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%;
}
}
}
@@ -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;
}
}
@@ -2,6 +2,15 @@
<templates id="template" xml:space="preserve">
<t t-name="web.SearchPanel" owl="1">
<t t-if="env.isSmall">
<t t-call="web.SearchPanel.Small" />
</t>
<t t-else="">
<t t-call="web.SearchPanel.Regular" />
</t>
</t>
<t t-name="web.SearchPanelContent" owl="1">
<div class="o_search_panel flex-grow-0 flex-shrink-0 border-end pe-2 pb-5 ps-4 h-100 bg-white overflow-auto" t-att-class="env.searchModel.searchPanelInfo.className" t-ref="root">
<section t-foreach="sections" t-as="section" t-key="section.id"
t-attf-class="o_search_panel_section o_search_panel_{{ section.type }}"
@@ -31,7 +40,7 @@
<header class="o_search_panel_group_header pb-1">
<div class="form-check w-100">
<!-- TODO: "indeterminate" could not be set in the template and had to be set in
JS manually. See https://github.com/odoo/owl/issues/713 (adapt when solved)
JS manually. See https://github.com/odoo/owl/issues/713 (adapt when solved)
-->
<input type="checkbox"
class="form-check-input"
@@ -77,6 +86,72 @@
</div>
</t>
<t t-name="web.SearchPanel.Regular" t-inherit="web.SearchPanelContent" t-inherit-mode="primary" owl="1"/>
<t t-name="web.SearchPanel.Small" owl="1">
<t t-if="state.showMobileSearch">
<t t-portal="'body'">
<div class="o_search_panel o_searchview o_mobile_search" t-ref="root">
<div class="o_mobile_search_header">
<button type="button"
class="o_mobile_search_button btn"
t-on-click="() => state.showMobileSearch = false"
>
<i class="fa fa-arrow-left" />
<strong class="ms-2">FILTER</strong>
</button>
</div>
<div class="o_mobile_search_content">
<t t-call="web.SearchPanelContent" />
</div>
<button type="button"
class="btn btn-primary o_mobile_search_footer"
t-on-click.stop="() => state.showMobileSearch = false"
>
<t>SEE RESULT</t>
</button>
</div>
</t>
</t>
<!-- Summary header -->
<t t-else="">
<button
class="o_search_panel o_search_panel_summary btn w-100 overflow-visible"
t-on-click="() => state.showMobileSearch = true"
t-ref="root"
>
<t t-set="categories" t-value="getCategorySelection()" />
<t t-set="filters" t-value="getFilterSelection()" />
<div class="d-flex align-items-center">
<i class="fa fa-fw fa-filter" />
<div class="o_search_panel_current_selection text-truncate ms-2 me-auto">
<t t-if="!categories.length and !filters.length">All</t>
<t t-else="">
<t t-foreach="categories" t-as="category" t-key="category.id">
<span class="o_search_panel_category me-1">
<i t-if="category.icon"
t-attf-class="o_search_panel_section_icon fa {{ category.icon }} me-1"
t-att-style="category.color and ('color: ' + category.color)"
/>
<t t-esc="category.values.join('/')" />
</span>
</t>
<t t-foreach="filters" t-as="filter" t-key="filter.id">
<span class="o_search_panel_filter me-1">
<i t-if="filter.icon"
t-attf-class="o_search_panel_section_icon fa {{ filter.icon }} me-1"
t-att-style="filter.color and ('color: ' + filter.color)"
/>
<t t-esc="filter.values.join(', ')" />
</span>
</t>
</t>
</div>
</div>
</button>
</t>
</t>
<t t-name="web.SearchPanel.Category" owl="1">
<t t-foreach="values" t-as="valueId" t-key="valueId">
<t t-set="value" t-value="section.values.get(valueId)"/>
@@ -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%;
}
}
@@ -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);
}
}
@@ -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": `
<search>
<filter name="birthday" date="birthday"/>
<filter name="date_field" date="date_field"/>
</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: `
<search>
<field name="birthday"/>
</search>
`,
});
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;
});
});
@@ -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`<SearchPanel/>`;
await makeWithSearch({
serverData,
resModel: "foo",
Component: Parent,
searchViewFields: serverData.models.foo.fields,
searchViewArch: `
<search>
<searchpanel>
<field name="tag_id" icon="fa-bars" string="Tags"/>
</searchpanel>
</search>`,
});
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
});
});
@@ -33,10 +33,16 @@
</xpath>
</t>
<t t-name="website.ControlPanel" t-inherit="web.ControlPanel" t-inherit-mode="extension" owl="1">
<t t-name="website.ControlPanel.Regular" t-inherit="web.ControlPanel.Regular" t-inherit-mode="extension" owl="1">
<xpath expr="//div[hasclass('o_search_options')]/*[last()]" position="after">
<t t-slot="control-panel-website-extra-actions"/>
</xpath>
</t>
<t t-name="website.ControlPanel" t-inherit="web.ControlPanel" t-inherit-mode="primary" owl="1">
<xpath expr="//t[@t-call='web.ControlPanel.Regular']" position="replace">
<t t-call="website.ControlPanel.Regular"/>
</xpath>
</t>
</templates>
@@ -16,7 +16,7 @@
</div>
</t>
<t t-name="website.ThemePreviewForm.ControlPanel" t-inherit="web.ControlPanel" t-inherit-mode="primary" owl="1">
<t t-name="website.ThemePreviewForm.ControlPanel.Regular" t-inherit="web.ControlPanel.Regular" t-inherit-mode="primary" owl="1">
<xpath expr="//div[hasclass('o_cp_bottom_right')]" position="replace">
<div class="o_cp_bottom_right" role="group">
<input class="btn-check" id="themeViewerDesktop" type="radio" name="viewer" data-mode='desktop' autocomplete="off" checked='checked'/>
@@ -27,7 +27,13 @@
</xpath>
</t>
<t t-name="website.ThemePreviewKanban.ControlPanel" t-inherit="web.ControlPanel" t-inherit-mode="primary" owl="1">
<t t-name="website.ThemePreviewForm.ControlPanel" t-inherit="web.ControlPanel" t-inherit-mode="primary" owl="1">
<xpath expr="//t[@t-call='web.ControlPanel.Regular']" position="replace">
<t t-call="website.ThemePreviewForm.ControlPanel.Regular"/>
</xpath>
</t>
<t t-name="website.ThemePreviewKanban.ControlPanel.Regular" t-inherit="web.ControlPanel.Regular" t-inherit-mode="primary" owl="1">
<xpath expr="//div[hasclass('o_cp_top_left')]" position="replace">
<div class="o_cp_top_left">
<ol class="breadcrumb" role="navigation">
@@ -47,4 +53,10 @@
</xpath>
</t>
<t t-name="website.ThemePreviewKanban.ControlPanel" t-inherit="web.ControlPanel" t-inherit-mode="primary" owl="1">
<xpath expr="//t[@t-call='web.ControlPanel.Regular']" position="replace">
<t t-call="website.ThemePreviewKanban.ControlPanel.Regular"/>
</xpath>
</t>
</templates>