Initial commit: Core packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:45 +02:00
commit 12c29a983b
9512 changed files with 8379910 additions and 0 deletions

View file

@ -0,0 +1,154 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { makeContext } from "@web/core/context";
import { session } from "@web/session";
import { registry } from "@web/core/registry";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { useService } from "@web/core/utils/hooks";
import { Component, onWillStart, onWillUpdateProps } from "@odoo/owl";
let registryActionId = 0;
/**
* Action menus (or Action/Print bar, previously called 'Sidebar')
*
* The side bar is the group of dropdown menus located on the left side of the
* control panel. Its role is to display a list of items depending on the view
* type and selected records and to execute a set of actions on active records.
* It is made out of 2 dropdown: Print and Action.
*
* @extends Component
*/
export class ActionMenus extends Component {
setup() {
this.orm = useService("orm");
this.actionService = useService("action");
onWillStart(async () => {
this.actionItems = await this.setActionItems(this.props);
});
onWillUpdateProps(async (nextProps) => {
this.actionItems = await this.setActionItems(nextProps);
});
}
get printItems() {
const printActions = this.props.items.print || [];
return printActions.map((action) => ({
action,
description: action.name,
key: action.id,
}));
}
//---------------------------------------------------------------------
// Private
//---------------------------------------------------------------------
async setActionItems(props) {
// Callback based actions
const callbackActions = (props.items.other || []).map((action) =>
Object.assign({ key: `action-${action.description}` }, action)
);
// Action based actions
const actionActions = props.items.action || [];
const formattedActions = actionActions.map((action) => ({
action,
description: action.name,
key: action.id,
}));
// ActionMenus action registry components
const registryActions = [];
for (const { Component, getProps } of registry.category("action_menus").getAll()) {
const itemProps = await getProps(props, this.env);
if (itemProps) {
registryActions.push({
Component,
key: `registry-action-${registryActionId++}`,
props: itemProps,
});
}
}
return [...callbackActions, ...formattedActions, ...registryActions];
}
//---------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
async executeAction(action) {
let activeIds = this.props.getActiveIds();
if (this.props.isDomainSelected) {
activeIds = await this.orm.search(this.props.resModel, this.props.domain, {
limit: session.active_ids_limit,
context: this.props.context,
});
}
const activeIdsContext = {
active_id: activeIds[0],
active_ids: activeIds,
active_model: this.props.resModel,
};
if (this.props.domain) {
// keep active_domain in context for backward compatibility
// reasons, and to allow actions to bypass the active_ids_limit
activeIdsContext.active_domain = this.props.domain;
}
const context = makeContext([this.props.context, activeIdsContext]);
return this.actionService.doAction(action.id, {
additionalContext: context,
onClose: this.props.onActionExecuted,
});
}
/**
* Handler used to determine which way must be used to execute a selected
* action: it will be either:
* - a callback (function given by the view controller);
* - an action ID (string);
* - an URL (string).
* @private
* @param {Object} item
*/
async onItemSelected(item) {
if (!(await this.props.shouldExecuteAction(item))) {
return;
}
if (item.callback) {
item.callback([item]);
} else if (item.action) {
this.executeAction(item.action);
} else if (item.url) {
// Event has been prevented at its source: we need to redirect manually.
browser.location = item.url;
}
}
}
ActionMenus.components = {
Dropdown,
DropdownItem,
};
ActionMenus.props = {
getActiveIds: Function,
context: Object,
resModel: String,
domain: { type: Array, optional: true },
isDomainSelected: { type: Boolean, optional: true },
items: {
type: Object,
shape: {
action: { type: Array, optional: true },
print: { type: Array, optional: true },
other: { type: Array, optional: true },
},
},
onActionExecuted: { type: Function, optional: true },
shouldExecuteAction: { type: Function, optional: true },
};
ActionMenus.defaultProps = {
onActionExecuted: () => {},
shouldExecuteAction: () => true,
};
ActionMenus.template = "web.ActionMenus";

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.ActionMenus" owl="1">
<div class="o_cp_action_menus">
<Dropdown t-if="printItems.length" class="'d-inline-block'" togglerClass="'btn btn-light'" hotkey="'shift+u'">
<t t-set-slot="toggler">
<i class="me-md-1 fa fa-print"/>
<span class="o_dropdown_title">Print</span>
</t>
<t t-foreach="printItems" t-as="item" t-key="item.key">
<DropdownItem class="'o_menu_item'" onSelected="() => this.onItemSelected(item)">
<t t-esc="item.description"/>
</DropdownItem>
</t>
</Dropdown>
<Dropdown t-if="actionItems.length" class="'d-inline-block'" togglerClass="'btn btn-light'" hotkey="'u'">
<t t-set-slot="toggler">
<i class="me-md-1 fa fa-cog"/>
<span class="o_dropdown_title">Action</span>
</t>
<t t-foreach="actionItems" t-as="item" t-key="item.key">
<t t-if="item.Component" t-component="item.Component" t-props="item.props" />
<DropdownItem t-else="" class="'o_menu_item'" onSelected="() => this.onItemSelected(item)">
<t t-esc="item.description"/>
</DropdownItem>
</t>
</Dropdown>
</div>
</t>
</templates>

View file

@ -0,0 +1,34 @@
/** @odoo-module **/
import { Dropdown } from "@web/core/dropdown/dropdown";
import { SearchDropdownItem } from "@web/search/search_dropdown_item/search_dropdown_item";
import { FACET_ICONS } from "../utils/misc";
import { useBus } from "@web/core/utils/hooks";
import { Component } from "@odoo/owl";
export class ComparisonMenu extends Component {
setup() {
this.icon = FACET_ICONS.comparison;
useBus(this.env.searchModel, "update", this.render);
}
/**
* @returns {Object[]}
*/
get items() {
return this.env.searchModel.getSearchItems(
(searchItem) => searchItem.type === "comparison"
);
}
/**
* @param {number} itemId
*/
onComparisonSelected(itemId) {
this.env.searchModel.toggleSearchItem(itemId);
}
}
ComparisonMenu.template = "web.ComparisonMenu";
ComparisonMenu.components = { Dropdown, DropdownItem: SearchDropdownItem };

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.ComparisonMenu" owl="1">
<Dropdown class="'o_comparison_menu btn-group'" togglerClass="'btn btn-light'">
<t t-set-slot="toggler">
<i class="me-1" t-att-class="icon"/>
<span class="o_dropdown_title">Comparison</span>
</t>
<t t-foreach="items" t-as="item" t-key="item.id">
<DropdownItem class="{ o_menu_item: true, selected: item.isActive }"
checked="item.isActive"
parentClosingMode="'none'"
t-esc="item.description"
onSelected="() => this.onComparisonSelected(item.id)"
/>
</t>
</Dropdown>
</t>
</templates>

View file

@ -0,0 +1,239 @@
/** @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";
import { ComparisonMenu } from "../comparison_menu/comparison_menu";
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";
import { Dialog } from "@web/core/dialog/dialog";
import {
Component,
useState,
onMounted,
useExternalListener,
useRef,
useEffect,
useSubEnv,
} from "@odoo/owl";
const MAPPING = {
filter: FilterMenu,
groupBy: GroupByMenu,
comparison: ComparisonMenu,
favorite: FavoriteMenu,
};
const STICKY_CLASS = "o_mobile_sticky";
export class ControlPanelSearchDialog extends Component {
setup() {
useSubEnv(this.props.env);
}
}
ControlPanelSearchDialog.template = "web.ControlPanelSearchDialog";
ControlPanelSearchDialog.props = ["close", "slots?", "display", "env", "searchMenus"];
ControlPanelSearchDialog.components = { Dialog, SearchBar };
export class ControlPanel extends Component {
setup() {
this.actionService = useService("action");
this.dialog = useService("dialog");
this.pagerProps = this.env.config.pagerProps
? useState(this.env.config.pagerProps)
: undefined;
this.breadcrumbs = useState(this.env.config.breadcrumbs);
this.root = useRef("root");
this.state = useState({
showSearchBar: 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;
}
/**
* Reset mobile search state
*/
resetSearchState() {
Object.assign(this.state, {
showSearchBar: false,
showViewSwitcher: false,
});
}
/**
* @returns {Object}
*/
get display() {
const display = Object.assign(
{
"top-left": true,
"top-right": true,
"bottom-left": true,
"bottom-left-buttons": true,
"bottom-right": true,
},
this.props.display
);
display.top = display["top-left"] || display["top-right"];
display.bottom = display["bottom-left"] || display["bottom-right"];
return display;
}
/**
* @returns {Component[]}
*/
get searchMenus() {
const searchMenus = [];
for (const key of this.env.searchModel.searchMenuTypes) {
// look in display instead?
if (
key === "comparison" &&
this.env.searchModel.getSearchItems((i) => i.type === "comparison").length === 0
) {
continue;
}
searchMenus.push({ Component: MAPPING[key], key });
}
return searchMenus;
}
openSearchDialog() {
this.dialog.add(ControlPanelSearchDialog, {
slots: this.props.slots,
display: this.display,
searchMenus: this.searchMenus,
env: {
searchModel: this.env.searchModel,
config: this.env.config,
},
});
}
/**
* Called when an element of the breadcrumbs is clicked.
*
* @param {string} jsId
*/
onBreadcrumbClicked(jsId) {
this.actionService.restore(jsId);
}
/**
* 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 | not moving
this.lastScrollTop = Math.min(0, this.lastScrollTop - delta);
} else {
// Going down
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";

View file

@ -0,0 +1,243 @@
.o_control_panel {
border-bottom: 1px solid $border-color;
@include o-webclient-padding($top: map-get($spacers, 2), $bottom: map-get($spacers, 2));
background-color: $o-control-panel-background-color;
> div {
display: flex;
min-height: $o-statusbar-height;
}
@include media-breakpoint-up(md) {
.o_cp_top_left, .o_cp_top_right,
.o_cp_bottom_left, .o_cp_bottom_right {
width: 50%;
}
}
@include media-breakpoint-down(md) {
> div.o_cp_bottom {
justify-content: space-between;
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_switch_buttons {
&.show > .dropdown-menu {
display: inline-flex;
min-width: 0px;
}
}
}
.breadcrumb {
font-size: 18px;
min-width: 0;
> li {
@include o-text-overflow();
}
@include media-breakpoint-down(md) {
width: 100%;
overflow: hidden;
flex-wrap: nowrap;
> li {
margin: 0;
&:before {
display: none;
}
&.o_back_button {
flex-shrink: 0;
&, & ~ li {
margin: auto 0;
}
&:before {
font-family: FontAwesome;
content: ""; // fa-arrow-left
display: inline-block;
padding: 0; // override bootstrap
color: inherit;
}
&.btn {
padding: $o-cp-button-sm-no-border-padding;
}
> a {
display: none;
}
}
}
}
}
.o_cp_top_right {
min-height: $o-cp-breadcrumb-height;
}
.o_cp_bottom_left {
display: flex;
flex-wrap: wrap;
gap: map-get($spacers, 3) map-get($spacers, 2);
> .o_cp_action_menus {
margin-left: auto;
padding-right: 0;
@include media-breakpoint-up(md) {
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 {
position: absolute;
top: 1px;
opacity: 0;
width: 100%;
height: 26px;
}
.o_form_binary_form span {
padding: 3px 25px;
color: $o-brand-primary;
}
.o_form_binary_form:hover {
background-color: $table-hover-bg;
}
}
.o_sidebar_delete_attachment {
padding: 0px;
position: absolute;
top: 5px;
right: 10px;
}
.dropdown-toggle {
margin-right: 0;
@include media-breakpoint-up(md) {
margin-right: 15px;
}
}
}
}
.o_cp_bottom_right {
display: flex;
flex-wrap: wrap;
@include media-breakpoint-up(md) {
column-gap: $o-horizontal-padding;
}
> .o_form_status_indicator {
@include media-breakpoint-down(md) {
.o_form_status_indicator_buttons > .btn {
padding: $o-cp-button-sm-no-border-padding;
}
}
}
> .o_cp_pager {
padding-left: 5px;
text-align: center;
user-select: none;
.o_pager_limit_fetch:not(.disabled), .o_pager_value {
cursor: pointer;
}
}
}
}
.o_x2m_control_panel {
display: flex;
flex-flow: row wrap;
.o_cp_buttons {
display: flex;
margin-right: auto;
> div {
margin-top: 5px;
}
.o-kanban-button-new {
margin-left: $o-kanban-record-margin;
}
}
.o_cp_pager {
display: flex;
margin-left: auto;
}
}
@include media-breakpoint-down(md) {
.o_rtl .o_control_panel .o_back_button:before {
transform: rotate(180deg);
}
}
@media print {
.o_control_panel {
display: none;
}
}

View file

@ -0,0 +1,2 @@
$o-control-panel-background-color: $o-view-background-color !default;

View file

@ -0,0 +1,208 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<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 t-if="pagerProps and pagerProps.total > 0" class="o_cp_pager" role="search">
<Pager 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-hotkey="view.accessKey"
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">
<div t-if="display['top']" class="o_cp_top">
<t t-if="display['top-left'] and !state.showSearchBar">
<t t-slot="control-panel-top-left">
<t t-call="web.Breadcrumbs.Small" />
</t>
</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="() => this.openSearchDialog()"
/>
</t>
</t>
</t>
</div>
<div t-if="display['bottom']" class="o_cp_bottom">
<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">
<div t-if="pagerProps and pagerProps.total > 0" class="o_cp_pager" role="search">
<Pager t-props="pagerProps"/>
</div>
<t t-if="env.config.viewSwitcherEntries.length > 1">
<nav class="btn-group o_cp_switch_buttons">
<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-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>
<t t-slot="control-panel-bottom-right"/>
</div>
</div>
</t>
<t t-name="web.ControlPanelSearchDialog" owl="1">
<Dialog>
<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="() => this.props.close()"
>
<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="props.display['top-right']">
<t t-slot="control-panel-top-right">
<SearchBar/>
</t>
</t>
<t t-if="props.display['bottom-right']">
<t t-slot="control-panel-bottom-right">
<div class="o_mobile_search_filter o_search_options">
<t t-foreach="props.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="() => this.props.close()"
>
SEE RESULT
</button>
</div>
</Dialog>
</t>
<t t-name="web.Breadcrumbs" owl="1">
<ol class="breadcrumb">
<t t-foreach="breadcrumbs" t-as="breadcrumb" t-key="breadcrumb.jsId">
<t t-set="isPenultimate" t-value="breadcrumb_index === breadcrumbs.length - 2"/>
<li t-if="!breadcrumb_last" class="breadcrumb-item" t-att-data-hotkey="isPenultimate and 'b'" t-att-class="{ o_back_button: isPenultimate}" t-on-click.prevent="() => this.onBreadcrumbClicked(breadcrumb.jsId)">
<a href="#">
<t t-if="breadcrumb.name" t-esc="breadcrumb.name"/>
<em t-else="" class="text-warning">Unnamed</em>
</a>
</li>
<li t-else="" class="breadcrumb-item active d-flex align-items-center">
<span class="text-truncate" t-if="breadcrumb.name" t-esc="breadcrumb.name"/>
<em t-else="" class="text-warning">Unnamed</em>
<t t-slot="control-panel-status-indicator" />
</li>
</t>
</ol>
</t>
<t t-name="web.Breadcrumbs.Small" owl="1">
<ol class="breadcrumb">
<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>

View file

@ -0,0 +1,99 @@
/** @odoo-module **/
import { Dropdown } from "@web/core/dropdown/dropdown";
import { CheckBox } from "@web/core/checkbox/checkbox";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { Component, useRef, useState } from "@odoo/owl";
const favoriteMenuRegistry = registry.category("favoriteMenu");
export class CustomFavoriteItem extends Component {
setup() {
this.notificationService = useService("notification");
this.descriptionRef = useRef("description");
this.state = useState({
description: this.env.config.getDisplayName(),
isDefault: false,
isShared: false,
});
}
/**
* @param {Event} ev
*/
saveFavorite(ev) {
if (!this.state.description) {
this.notificationService.add(
this.env._t("A name for your favorite filter is required."),
{ type: "danger" }
);
ev.stopPropagation();
return this.descriptionRef.el.focus();
}
const favorites = this.env.searchModel.getSearchItems(
(s) => s.type === "favorite" && s.description === this.state.description
);
if (favorites.length) {
this.notificationService.add(this.env._t("A filter with same name already exists."), {
type: "danger",
});
ev.stopPropagation();
return this.descriptionRef.el.focus();
}
const { description, isDefault, isShared } = this.state;
this.env.searchModel.createNewFavorite({ description, isDefault, isShared });
Object.assign(this.state, {
description: this.env.config.getDisplayName(),
isDefault: false,
isShared: false,
});
}
/**
* @param {boolean} checked
*/
onDefaultCheckboxChange(checked) {
this.state.isDefault = checked;
if (checked) {
this.state.isShared = false;
}
}
/**
* @param {boolean} checked
*/
onShareCheckboxChange(checked) {
this.state.isShared = checked;
if (checked) {
this.state.isDefault = false;
}
}
/**
* @param {KeyboardEvent} ev
*/
onInputKeydown(ev) {
switch (ev.key) {
case "Enter":
ev.preventDefault();
this.saveFavorite(ev);
break;
case "Escape":
// Gives the focus back to the component.
ev.preventDefault();
ev.target.blur();
break;
}
}
}
CustomFavoriteItem.template = "web.CustomFavoriteItem";
CustomFavoriteItem.components = { CheckBox, Dropdown };
favoriteMenuRegistry.add(
"custom-favorite-item",
{ Component: CustomFavoriteItem, groupNumber: 3 },
{ sequence: 0 }
);

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CustomFavoriteItem" owl="1" >
<Dropdown class="'o_add_favorite'">
<t t-set-slot="toggler">
Save current search
</t>
<div class="px-3 py-2">
<input type="text"
class="o_input"
t-ref="description"
t-model.trim="state.description"
t-on-keydown="onInputKeydown"
/>
<CheckBox value="state.isDefault" onChange.bind="onDefaultCheckboxChange">
Use by default
</CheckBox>
<CheckBox value="state.isShared" onChange.bind="onShareCheckboxChange">
Share with all users
</CheckBox>
</div>
<div class="px-3 py-2">
<button class="o_save_favorite btn btn-primary" t-on-click="saveFavorite">
Save
</button>
</div>
</Dropdown>
</t>
</templates>

View file

@ -0,0 +1,67 @@
/** @odoo-module **/
import { Dropdown } from "@web/core/dropdown/dropdown";
import { SearchDropdownItem } from "@web/search/search_dropdown_item/search_dropdown_item";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { FACET_ICONS } from "../utils/misc";
import { registry } from "@web/core/registry";
import { useBus, useService } from "@web/core/utils/hooks";
import { Component } from "@odoo/owl";
const favoriteMenuRegistry = registry.category("favoriteMenu");
export class FavoriteMenu extends Component {
setup() {
this.icon = FACET_ICONS.favorite;
this.dialogService = useService("dialog");
useBus(this.env.searchModel, "update", this.render);
}
/**
* @returns {Array}
*/
get items() {
const favorites = this.env.searchModel.getSearchItems(
(searchItem) => searchItem.type === "favorite"
);
const registryMenus = [];
for (const item of favoriteMenuRegistry.getAll()) {
if ("isDisplayed" in item ? item.isDisplayed(this.env) : true) {
registryMenus.push({
Component: item.Component,
groupNumber: item.groupNumber,
key: item.Component.name,
});
}
}
return [...favorites, ...registryMenus];
}
/**
* @param {number} itemId
*/
onFavoriteSelected(itemId) {
this.env.searchModel.toggleSearchItem(itemId);
}
/**
* @param {number} itemId
*/
openConfirmationDialog(itemId) {
const { userId } = this.items.find((item) => item.id === itemId);
const dialogProps = {
title: this.env._t("Warning"),
body: userId
? this.env._t("Are you sure that you want to remove this filter?")
: this.env._t(
"This filter is global and will be removed for everybody if you continue."
),
confirm: () => this.env.searchModel.deleteFavorite(itemId),
cancel: () => {},
};
this.dialogService.add(ConfirmationDialog, dialogProps);
}
}
FavoriteMenu.template = "web.FavoriteMenu";
FavoriteMenu.components = { Dropdown, DropdownItem: SearchDropdownItem };

View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.FavoriteMenu" owl="1">
<Dropdown class="'o_favorite_menu btn-group'" togglerClass="'btn btn-light'">
<t t-set-slot="toggler">
<i class="me-1" t-att-class="icon"/>
<span class="o_dropdown_title">Favorites</span>
</t>
<t t-set="currentGroup" t-value="null"/>
<t t-foreach="items" t-as="item" t-key="item.id or item.key">
<t t-if="currentGroup !== null and currentGroup !== item.groupNumber">
<div role="separator" class="dropdown-divider"/>
</t>
<t t-if="item.type ==='favorite'">
<DropdownItem class="{ o_menu_item: true, selected: item.isActive }"
checked="item.isActive"
parentClosingMode="'none'"
onSelected="() => this.onFavoriteSelected(item.id)"
>
<span class="d-flex p-0 align-items-center justify-content-between">
<t t-esc="item.description"/>
<i class="ms-1 o_icon_right fa fa-trash-o"
title="Delete item"
t-on-click.stop="() => this.openConfirmationDialog(item.id)"
/>
</span>
</DropdownItem>
</t>
<t t-else="">
<t t-component="item.Component"/>
</t>
<t t-set="currentGroup" t-value="item.groupNumber"/>
</t>
</Dropdown>
</t>
</templates>

View file

@ -0,0 +1,344 @@
/** @odoo-module **/
import { DatePicker, DateTimePicker } from "@web/core/datepicker/datepicker";
import { Domain } from "@web/core/domain";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { serializeDate, serializeDateTime } from "@web/core/l10n/dates";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { Component, useState } from "@odoo/owl";
const { DateTime } = luxon;
const formatters = registry.category("formatters");
const parsers = registry.category("parsers");
const FIELD_TYPES = {
binary: "binary",
boolean: "boolean",
char: "char",
date: "date",
datetime: "datetime",
float: "number",
id: "id",
integer: "number",
json: "json",
html: "char",
many2many: "char",
many2one: "char",
monetary: "number",
one2many: "char",
text: "char",
selection: "selection",
};
// FilterMenu parameters
const FIELD_OPERATORS = {
binary: [
{ symbol: "!=", description: _lt("is set"), value: false },
{ symbol: "=", description: _lt("is not set"), value: false },
],
boolean: [
{ symbol: "=", description: _lt("is Yes"), value: true },
{ symbol: "!=", description: _lt("is No"), value: true },
],
char: [
{ symbol: "ilike", description: _lt("contains") },
{ symbol: "not ilike", description: _lt("doesn't contain") },
{ symbol: "=", description: _lt("is equal to") },
{ symbol: "!=", description: _lt("is not equal to") },
{ symbol: "!=", description: _lt("is set"), value: false },
{ symbol: "=", description: _lt("is not set"), value: false },
],
json: [
{ symbol: "ilike", description: _lt("contains") },
{ symbol: "not ilike", description: _lt("doesn't contain") },
{ symbol: "=", description: _lt("is equal to") },
{ symbol: "!=", description: _lt("is not equal to") },
{ symbol: "!=", description: _lt("is set"), value: false },
{ symbol: "=", description: _lt("is not set"), value: false },
],
date: [
{ symbol: "=", description: _lt("is equal to") },
{ symbol: "!=", description: _lt("is not equal to") },
{ symbol: ">", description: _lt("is after") },
{ symbol: "<", description: _lt("is before") },
{ symbol: ">=", description: _lt("is after or equal to") },
{ symbol: "<=", description: _lt("is before or equal to") },
{ symbol: "between", description: _lt("is between") },
{ symbol: "!=", description: _lt("is set"), value: false },
{ symbol: "=", description: _lt("is not set"), value: false },
],
datetime: [
{ symbol: "between", description: _lt("is between") },
{ symbol: "=", description: _lt("is equal to") },
{ symbol: "!=", description: _lt("is not equal to") },
{ symbol: ">", description: _lt("is after") },
{ symbol: "<", description: _lt("is before") },
{ symbol: ">=", description: _lt("is after or equal to") },
{ symbol: "<=", description: _lt("is before or equal to") },
{ symbol: "!=", description: _lt("is set"), value: false },
{ symbol: "=", description: _lt("is not set"), value: false },
],
id: [{ symbol: "=", description: _lt("is") }],
number: [
{ symbol: "=", description: _lt("is equal to") },
{ symbol: "!=", description: _lt("is not equal to") },
{ symbol: ">", description: _lt("greater than") },
{ symbol: "<", description: _lt("less than") },
{ symbol: ">=", description: _lt("greater than or equal to") },
{ symbol: "<=", description: _lt("less than or equal to") },
{ symbol: "!=", description: _lt("is set"), value: false },
{ symbol: "=", description: _lt("is not set"), value: false },
],
selection: [
{ symbol: "=", description: _lt("is") },
{ symbol: "!=", description: _lt("is not") },
{ symbol: "!=", description: _lt("is set"), value: false },
{ symbol: "=", description: _lt("is not set"), value: false },
],
};
function parseField(field, value) {
if (FIELD_TYPES[field.type] === "char") {
return value;
}
const type = field.type === "id" ? "integer" : field.type;
const parse = parsers.contains(type) ? parsers.get(type) : (v) => v;
return parse(value);
}
function formatField(field, value) {
if (FIELD_TYPES[field.type] === "char") {
return value;
}
const type = field.type === "id" ? "integer" : field.type;
const format = formatters.contains(type) ? formatters.get(type) : (v) => v;
return format(value, { digits: field.digits });
}
export class CustomFilterItem extends Component {
setup() {
this.conditions = useState([]);
// Format, filter and sort the fields props
this.fields = Object.values(this.env.searchModel.searchViewFields)
.filter((field) => this.validateField(field))
.concat({ string: "ID", type: "id", name: "id" })
.sort(({ string: a }, { string: b }) => (a > b ? 1 : a < b ? -1 : 0));
// Give access to constants variables to the template.
this.OPERATORS = FIELD_OPERATORS;
this.FIELD_TYPES = FIELD_TYPES;
// Add first condition
this.addNewCondition();
}
//---------------------------------------------------------------------
// Protected
//---------------------------------------------------------------------
/**
* Populate the conditions list with a new condition having as properties:
* - the last condition or the first available field
* - the last condition or the first available operator
* - a null or empty array value
*/
addNewCondition() {
const lastCondition = [...this.conditions].pop();
const condition = lastCondition
? Object.assign({}, lastCondition)
: {
field: 0,
operator: 0,
};
this.setDefaultValue(condition);
this.conditions.push(condition);
}
/**
* @param {Object} field
* @returns {boolean}
*/
validateField(field) {
return (
!field.deprecated && field.searchable && FIELD_TYPES[field.type] && field.name !== "id"
);
}
/**
* @param {Object} condition
*/
setDefaultValue(condition) {
const field = this.fields[condition.field];
const genericType = FIELD_TYPES[field.type];
const operator = FIELD_OPERATORS[genericType][condition.operator];
// Logical value
switch (genericType) {
case "id":
case "number": {
condition.value = 0;
break;
}
case "date":
case "datetime": {
condition.value = [DateTime.local()];
if (operator.symbol === "between") {
condition.value.push(DateTime.local());
}
if (genericType === "datetime") {
condition.value[0] = condition.value[0].set({ hour: 0, minute: 0, second: 0 });
if (operator.symbol === "between") {
condition.value[1] = condition.value[1].set({
hour: 23,
minute: 59,
second: 59,
});
}
}
break;
}
case "selection": {
const [firstValue] = this.fields[condition.field].selection[0];
condition.value = firstValue;
break;
}
default: {
condition.value = "";
}
}
// Displayed value (no needed for dates: they are handled by the DatePicker component)
if (!["date", "datetime"].includes(field.type)) {
condition.displayedValue = formatField(field, condition.value);
}
}
//---------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
/**
* Convert all conditions to prefilters.
*/
onApply() {
const preFilters = this.conditions.map((condition) => {
const field = this.fields[condition.field];
const genericType = this.FIELD_TYPES[field.type];
const operator = this.OPERATORS[genericType][condition.operator];
const descriptionArray = [field.string, operator.description.toString()];
const domainArray = [];
let domainValue;
// Field type specifics
if ("value" in operator) {
domainValue = [operator.value];
// No description to push here
} else if (["date", "datetime"].includes(genericType)) {
const serialize = genericType === "date" ? serializeDate : serializeDateTime;
domainValue = condition.value.map(serialize);
descriptionArray.push(
`"${condition.value
.map((val) => formatField(field, val))
.join(" " + this.env._t("and") + " ")}"`
);
} else {
domainValue = [condition.value];
if (field.type === "selection") {
descriptionArray.push(
`"${field.selection.find((v) => v[0] === condition.value)[1]}"`
);
} else {
descriptionArray.push(`"${condition.value}"`);
}
}
// Operator specifics
if (operator.symbol === "between") {
domainArray.push(
[field.name, ">=", domainValue[0]],
[field.name, "<=", domainValue[1]]
);
} else {
domainArray.push([field.name, operator.symbol, domainValue[0]]);
}
const preFilter = {
description: descriptionArray.join(" "),
domain: new Domain(domainArray).toString(),
type: "filter",
};
return preFilter;
});
this.env.searchModel.createNewFilters(preFilters);
// remove conditions
while (this.conditions.length) {
this.conditions.pop();
}
this.addNewCondition();
}
/**
* @param {Object} condition
* @param {number} valueIndex
* @param {Date} ev
*/
onDateTimeChanged(condition, valueIndex, date) {
condition.value[valueIndex] = date;
}
/**
* @param {Object} condition
* @param {Event} ev
*/
onFieldSelect(condition, ev) {
Object.assign(condition, {
field: ev.target.selectedIndex,
operator: 0,
});
this.setDefaultValue(condition);
}
/**
* @param {Object} condition
* @param {Event} ev
*/
onOperatorSelect(condition, ev) {
condition.operator = ev.target.selectedIndex;
this.setDefaultValue(condition);
}
/**
* @param {Object} condition
*/
onRemoveCondition(conditionIndex) {
this.conditions.splice(conditionIndex, 1);
}
/**
* @param {Object} condition
* @param {Event} ev
*/
onValueChange(condition, ev) {
if (!ev.target.value) {
return this.setDefaultValue(condition);
}
const field = this.fields[condition.field];
try {
const parsed = parseField(field, ev.target.value);
const formatted = formatField(field, parsed);
// Only updates values if it can be correctly parsed and formatted.
condition.value = parsed;
condition.displayedValue = formatted;
} catch (_err) {
// Parsing error: nothing is done
}
// Only reset the target's value if it is not a selection field.
if (field.type !== "selection") {
ev.target.value = condition.displayedValue;
}
}
}
CustomFilterItem.components = { DatePicker, DateTimePicker, Dropdown };
CustomFilterItem.template = "web.CustomFilterItem";

View file

@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CustomFilterItem" owl="1">
<Dropdown class="'o_add_custom_filter_menu'">
<t t-set-slot="toggler">
Add Custom Filter
</t>
<t t-foreach="conditions" t-as="condition" t-key="condition_index">
<div class=" o_filter_condition dropdown-item-text position-relative">
<t t-set="fieldType" t-value="fields[condition.field].type"/>
<t t-set="selectedOperator" t-value="OPERATORS[FIELD_TYPES[fieldType]][condition.operator]"/>
<span t-if="!condition_first" class="o_or_filter">or</span>
<select class="o_input o_generator_menu_field"
t-on-change="ev => this.onFieldSelect(condition, ev)"
>
<option t-foreach="fields" t-as="field" t-key="field_index"
t-att-value="field.name"
t-att-selected="field_index === condition.field"
t-esc="field.string"
/>
</select>
<select class="o_input o_generator_menu_operator"
t-on-change="ev => this.onOperatorSelect(condition, ev)"
>
<option t-foreach="OPERATORS[FIELD_TYPES[fieldType]]" t-as="operator" t-key="operator_index"
t-att-value="operator.symbol"
t-att-selected="operator_index === condition.operator"
t-esc="operator.description"
/>
</select>
<span t-if="!('value' in selectedOperator)" class="o_generator_menu_value">
<t t-if="fieldType === 'date'">
<DatePicker
date="condition.value[0]"
onDateTimeChanged="date => this.onDateTimeChanged(condition, 0, date)"
/>
<DatePicker t-if="selectedOperator.symbol === 'between'"
date="condition.value[1]"
onDateTimeChanged="date => this.onDateTimeChanged(condition, 1, date)"
/>
</t>
<t t-elif="fieldType === 'datetime'">
<DateTimePicker
date="condition.value[0]"
onDateTimeChanged="date => this.onDateTimeChanged(condition, 0, date)"
/>
<DateTimePicker t-if="selectedOperator.symbol === 'between'"
date="condition.value[1]"
onDateTimeChanged="date => this.onDateTimeChanged(condition, 1, date)"
/>
</t>
<select t-elif="fieldType === 'selection'" class="o_input"
t-on-change="ev => this.onValueChange(condition, ev)"
>
<option t-foreach="fields[condition.field].selection" t-as="option" t-key="option_index"
t-att-value="option[0]"
t-esc="option[1]"
/>
</select>
<!-- @todo (DAM) I think that the localization should be better consisered below -->
<input t-elif="fieldType === 'float'"
class="o_input"
step="0.01"
t-att-type="DECIMAL_POINT === '.' ? 'number' : 'text'"
t-attf-title="Number using {{ DECIMAL_POINT }} as decimal separator."
t-attf-pattern="[0-9]+([\\{{ DECIMAL_POINT }}][0-9]+)?"
t-att-value="condition.displayedValue"
t-on-change="ev => this.onValueChange(condition, ev)"
/>
<input t-elif="['integer', 'id'].includes(fieldType)"
class="o_input"
step="1"
type="number"
t-att-value="condition.displayedValue"
t-on-change="ev => this.onValueChange(condition, ev)"
/>
<input t-else=""
type="text"
class="o_input"
t-att-value="condition.displayedValue"
t-on-change="ev => this.onValueChange(condition, ev)"
/>
</span>
<i t-if="conditions.length gt 1"
class="fa fa-trash-o o_generator_menu_delete"
role="image"
aria-label="Delete"
title="Delete"
t-on-click="() => this.onRemoveCondition(condition_index)"
/>
</div>
</t>
<div class="px-3 py-2">
<button type="button"
class="btn btn-primary o_apply_filter me-2"
t-on-click="onApply"
>
Apply
</button>
<button type="button"
class="btn btn-secondary o_add_condition"
t-on-click.stop="addNewCondition"
>
<i class="fa fa-plus-circle"/>
<t>Add a condition</t>
</button>
</div>
</Dropdown>
</t>
</templates>

View file

@ -0,0 +1,42 @@
/** @odoo-module **/
import { Dropdown } from "@web/core/dropdown/dropdown";
import { SearchDropdownItem } from "@web/search/search_dropdown_item/search_dropdown_item";
import { CustomFilterItem } from "./custom_filter_item";
import { FACET_ICONS } from "../utils/misc";
import { useBus } from "@web/core/utils/hooks";
import { Component } from "@odoo/owl";
export class FilterMenu extends Component {
setup() {
this.icon = FACET_ICONS.filter;
useBus(this.env.searchModel, "update", this.render);
}
/**
* @returns {Object[]}
*/
get items() {
return this.env.searchModel.getSearchItems((searchItem) =>
["filter", "dateFilter"].includes(searchItem.type)
);
}
/**
* @param {Object} param0
* @param {number} param0.itemId
* @param {number} [param0.optionId]
*/
onFilterSelected({ itemId, optionId }) {
if (optionId) {
this.env.searchModel.toggleDateFilter(itemId, optionId);
} else {
this.env.searchModel.toggleSearchItem(itemId);
}
}
}
FilterMenu.components = { CustomFilterItem, Dropdown, DropdownItem: SearchDropdownItem };
FilterMenu.template = "web.FilterMenu";

View file

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.FilterMenu" owl="1">
<Dropdown class="'o_filter_menu btn-group ' + props.class" togglerClass="'btn btn-light'">
<t t-set-slot="toggler">
<i class="me-1" t-att-class="icon"/>
<span class="o_dropdown_title">Filters</span>
</t>
<t t-set="currentGroup" t-value="null"/>
<t t-foreach="items" t-as="item" t-key="item.id">
<t t-if="currentGroup !== null and currentGroup !== item.groupNumber">
<div class="dropdown-divider" role="separator"/>
</t>
<t t-if="item.options">
<Dropdown togglerClass="'o_menu_item' + (item.isActive ? ' selected' : '')">
<t t-set-slot="toggler">
<t t-esc="item.description"/>
</t>
<t t-set="subGroup" t-value="null"/>
<t t-foreach="item.options" t-as="option" t-key="option.id">
<t t-if="subGroup !== null and subGroup !== option.groupNumber">
<div class="dropdown-divider" role="separator"/>
</t>
<DropdownItem class="{ o_item_option: true, selected: option.isActive }"
t-esc="option.description"
checked="option.isActive"
parentClosingMode="'none'"
onSelected="() => this.onFilterSelected({ itemId: item.id, optionId: option.id })"
/>
<t t-set="subGroup" t-value="option.groupNumber"/>
</t>
</Dropdown>
</t>
<t t-else="">
<DropdownItem class="{ o_menu_item: true, selected: item.isActive }"
checked="item.isActive"
parentClosingMode="'none'"
t-esc="item.description"
onSelected="() => this.onFilterSelected({ itemId: item.id })"
/>
</t>
<t t-set="currentGroup" t-value="item.groupNumber"/>
</t>
<t t-if="items.length">
<div role="separator" class="dropdown-divider"/>
</t>
<CustomFilterItem/>
</Dropdown>
</t>
</templates>

View file

@ -0,0 +1,17 @@
/** @odoo-module **/
import { Dropdown } from "@web/core/dropdown/dropdown";
import { Component, useState } from "@odoo/owl";
export class CustomGroupByItem extends Component {
setup() {
this.state = useState({});
if (this.props.fields.length) {
this.state.fieldName = this.props.fields[0].name;
}
}
}
CustomGroupByItem.template = "web.CustomGroupByItem";
CustomGroupByItem.components = { Dropdown };

View file

@ -0,0 +1,26 @@
<templates xml:space="preserve">
<t t-name="web.CustomGroupByItem" owl="1">
<Dropdown class="'o_add_custom_group_menu'">
<t t-set-slot="toggler">
Add Custom Group
</t>
<div class="px-3 py-2">
<select class="w-100 o_input" t-model="state.fieldName">
<option t-foreach="props.fields" t-as="field" t-key="field.name"
t-att-value="field.name"
t-esc="field.string"
/>
</select>
</div>
<div class="px-3 py-2">
<button class="btn btn-primary w-100"
t-on-click="() => props.onAddCustomGroup(state.fieldName)"
>
Apply
</button>
</div>
</Dropdown>
</t>
</templates>

View file

@ -0,0 +1,85 @@
/** @odoo-module **/
import { Dropdown } from "@web/core/dropdown/dropdown";
import { SearchDropdownItem } from "@web/search/search_dropdown_item/search_dropdown_item";
import { CustomGroupByItem } from "./custom_group_by_item";
import { FACET_ICONS, GROUPABLE_TYPES } from "../utils/misc";
import { sortBy } from "@web/core/utils/arrays";
import { useBus } from "@web/core/utils/hooks";
import { Component } from "@odoo/owl";
export class GroupByMenu extends Component {
setup() {
this.icon = FACET_ICONS.groupBy;
this.dropdownProps = Object.keys(this.props)
.filter((key) => key in Dropdown.props)
.reduce((obj, key) => ({ ...obj, [key]: this.props[key] }), {});
const fields = [];
for (const [fieldName, field] of Object.entries(this.env.searchModel.searchViewFields)) {
if (this.validateField(fieldName, field)) {
fields.push(Object.assign({ name: fieldName }, field));
}
}
this.fields = sortBy(fields, "string");
useBus(this.env.searchModel, "update", this.render);
}
/**
* @returns {boolean}
*/
get hideCustomGroupBy() {
return this.env.searchModel.hideCustomGroupBy || false;
}
/**
* @returns {Object[]}
*/
get items() {
return this.env.searchModel.getSearchItems((searchItem) =>
["groupBy", "dateGroupBy"].includes(searchItem.type)
);
}
/**
* @param {string} fieldName
* @param {Object} field
* @returns {boolean}
*/
validateField(fieldName, field) {
const { sortable, store, type } = field;
return (
(type === "many2many" ? store : sortable) &&
fieldName !== "id" &&
GROUPABLE_TYPES.includes(type)
);
}
/**
* @param {Object} param0
* @param {number} param0.itemId
* @param {number} [param0.optionId]
*/
onGroupBySelected({ itemId, optionId }) {
if (optionId) {
this.env.searchModel.toggleDateGroupBy(itemId, optionId);
} else {
this.env.searchModel.toggleSearchItem(itemId);
}
}
/**
* @param {string} fieldName
*/
onAddCustomGroup(fieldName) {
this.env.searchModel.createNewGroupBy(fieldName);
}
}
GroupByMenu.components = { CustomGroupByItem, Dropdown, DropdownItem: SearchDropdownItem };
GroupByMenu.template = "web.GroupByMenu";
GroupByMenu.defaultProps = {
showActiveItems: true,
showCaretDown: false,
};

View file

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.GroupByMenu" owl="1">
<Dropdown class="'o_group_by_menu btn-group'"
togglerClass="'btn btn-light'"
t-props="dropdownProps"
>
<t t-set-slot="toggler">
<i class="me-1" t-att-class="icon"/>
<span class="o_dropdown_title">Group By<t t-if="props.showCaretDown"> <i class="fa fa-caret-down ms-1"/></t></span>
</t>
<t t-set="currentGroup" t-value="null"/>
<t t-foreach="items" t-as="item" t-key="item.id">
<t t-if="currentGroup !== null and currentGroup !== item.groupNumber">
<div class="dropdown-divider" role="separator"/>
</t>
<t t-if="item.options">
<Dropdown togglerClass="'o_menu_item' + (item.isActive ? ' selected' : '')">
<t t-set-slot="toggler">
<t t-esc="item.description"/>
</t>
<t t-set="subGroup" t-value="null"/>
<t t-foreach="item.options" t-as="option" t-key="option.id">
<t t-if="subGroup !== null and subGroup !== option.groupNumber">
<div class="dropdown-divider" role="separator"/>
</t>
<DropdownItem class="{ o_item_option: true, selected: option.isActive }"
checked="option.isActive ? true : false"
parentClosingMode="'none'"
t-esc="option.description"
onSelected="() => this.onGroupBySelected({ itemId: item.id, optionId: option.id})"
/>
<t t-set="subGroup" t-value="option.groupNumber"/>
</t>
</Dropdown>
</t>
<t t-else="">
<DropdownItem class="{ o_menu_item: true, selected: item.isActive }"
checked="item.isActive"
parentClosingMode="'none'"
t-esc="item.description"
onSelected="() => this.onGroupBySelected({ itemId: item.id })"
/>
</t>
<t t-set="currentGroup" t-value="item.groupNumber"/>
</t>
<t t-if="!hideCustomGroupBy and fields.length">
<div t-if="items.length" role="separator" class="dropdown-divider"/>
<CustomGroupByItem fields="fields" onAddCustomGroup.bind="onAddCustomGroup"/>
</t>
</Dropdown>
</t>
</templates>

View file

@ -0,0 +1,51 @@
/** @odoo-module **/
import { pick } from "@web/core/utils/objects";
import { Component, useRef } from "@odoo/owl";
/**
* @param {Object} params
* @returns {Object}
*/
export function extractLayoutComponents(params) {
return pick(params, "ControlPanel", "SearchPanel", "Banner");
}
export class Layout extends Component {
setup() {
this.components = extractLayoutComponents(this.env.config);
this.contentRef = useRef("content");
}
get controlPanelSlots() {
const slots = { ...this.props.slots };
slots["control-panel-bottom-left-buttons"] = slots["layout-buttons"];
delete slots["layout-buttons"];
delete slots.default;
return slots;
}
get display() {
const { controlPanel } = this.props.display;
if (!controlPanel || !this.env.inDialog) {
return this.props.display;
}
return {
...this.props.display,
controlPanel: {
...controlPanel,
"top-left": false,
"bottom-left-buttons": false,
},
};
}
}
Layout.template = "web.Layout";
Layout.props = {
className: { type: String, optional: true },
display: { type: Object, optional: true },
slots: { type: Object, optional: true },
};
Layout.defaultProps = {
display: {},
};

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.Layout" owl="1">
<t t-if="env.inDialog" t-portal="'#' + env.dialogId + ' .modal-footer'">
<t t-slot="layout-buttons"/>
</t>
<t t-component="components.ControlPanel" slots="controlPanelSlots" t-if="display.controlPanel" display="display.controlPanel"/>
<t t-component="components.Banner" t-if="display.banner"/>
<div t-ref="content" class="o_content" t-attf-class="{{props.className}}" t-att-class="{ o_component_with_search_panel: display.searchPanel }">
<t t-component="components.SearchPanel" t-if="display.searchPanel"/>
<t t-slot="default" contentRef="contentRef" />
</div>
</t>
</templates>

View file

@ -0,0 +1,37 @@
/** @odoo-module **/
import { useEnv, useChildSubEnv, useState, onWillRender } from "@odoo/owl";
/**
* @typedef PagerUpdateParams
* @property {number} offset
* @property {number} limit
*/
/**
* @typedef PagerProps
* @property {number} offset
* @property {number} limit
* @property {number} total
* @property {(params: PagerUpdateParams) => any} onUpdate
* @property {boolean} [isEditable]
* @property {boolean} [withAccessKey]
*/
/**
* @param {() => PagerProps} getProps
*/
export function usePager(getProps) {
const env = useEnv();
const pagerState = useState({});
useChildSubEnv({
config: {
...env.config,
pagerProps: pagerState,
},
});
onWillRender(() => {
Object.assign(pagerState, getProps() || { total: 0 });
});
}

View file

@ -0,0 +1,361 @@
/** @odoo-module **/
import { makeContext } from "@web/core/context";
import { _lt } from "@web/core/l10n/translation";
import { evaluateExpr } from "@web/core/py_js/py";
import { XMLParser } from "@web/core/utils/xml";
import { DEFAULT_INTERVAL, DEFAULT_PERIOD } from "@web/search/utils/dates";
const ALL = _lt("All");
const DEFAULT_LIMIT = 200;
const DEFAULT_VIEWS_WITH_SEARCH_PANEL = ["kanban", "list"];
/**
* Returns the split 'group_by' key from the given context attribute.
* This helper accepts any invalid context or one that does not have
* a valid 'group_by' key, and falls back to an empty list.
* @param {string} context
* @returns {string[]}
*/
function getContextGroubBy(context) {
try {
return makeContext([context]).group_by.split(":");
} catch (_err) {
return [];
}
}
function reduceType(type) {
if (type === "dateFilter") {
return "filter";
}
if (type === "dateGroupBy") {
return "groupBy";
}
return type;
}
export class SearchArchParser extends XMLParser {
constructor(searchViewDescription, fields, searchDefaults = {}, searchPanelDefaults = {}) {
super();
const { irFilters, arch } = searchViewDescription;
this.fields = fields || {};
this.irFilters = irFilters || [];
this.arch = arch || "<search/>";
this.labels = [];
this.preSearchItems = [];
this.searchPanelInfo = {
className: "",
viewTypes: DEFAULT_VIEWS_WITH_SEARCH_PANEL,
};
this.sections = [];
this.searchDefaults = searchDefaults;
this.searchPanelDefaults = searchPanelDefaults;
this.currentGroup = [];
this.currentTag = null;
this.groupNumber = 0;
this.pregroupOfGroupBys = [];
}
parse() {
this.visitXML(this.arch, (node, visitChildren) => {
switch (node.tagName) {
case "search":
this.visitSearch(node, visitChildren);
break;
case "searchpanel":
return this.visitSearchPanel(node);
case "group":
this.visitGroup(node, visitChildren);
break;
case "separator":
this.visitSeparator();
break;
case "field":
this.visitField(node);
break;
case "filter":
this.visitFilter(node);
break;
}
});
return {
labels: this.labels,
preSearchItems: this.preSearchItems,
searchPanelInfo: this.searchPanelInfo,
sections: this.sections,
};
}
pushGroup(tag = null) {
if (this.currentGroup.length) {
if (this.currentTag === "groupBy") {
this.pregroupOfGroupBys.push(...this.currentGroup);
} else {
this.preSearchItems.push(this.currentGroup);
}
}
this.currentTag = tag;
this.currentGroup = [];
this.groupNumber++;
}
visitField(node) {
this.pushGroup("field");
const preField = { type: "field" };
const modifiers = JSON.parse(node.getAttribute("modifiers") || "{}");
if (modifiers.invisible === true) {
preField.invisible = true;
}
if (node.hasAttribute("domain")) {
preField.domain = node.getAttribute("domain");
}
if (node.hasAttribute("filter_domain")) {
preField.filterDomain = node.getAttribute("filter_domain");
} else if (node.hasAttribute("operator")) {
preField.operator = node.getAttribute("operator");
}
if (node.hasAttribute("context")) {
preField.context = node.getAttribute("context");
}
if (node.hasAttribute("name")) {
const name = node.getAttribute("name");
preField.fieldName = name;
preField.fieldType = this.fields[name].type;
if (name in this.searchDefaults) {
preField.isDefault = true;
let value = this.searchDefaults[name];
value = Array.isArray(value) ? value[0] : value;
let operator = preField.operator;
if (!operator) {
let type = preField.fieldType;
if (node.hasAttribute("widget")) {
type = node.getAttribute("widget");
}
// Note: many2one as a default filter will have a
// numeric value instead of a string => we want "="
// instead of "ilike".
if (["char", "html", "many2many", "one2many", "text"].includes(type)) {
operator = "ilike";
} else {
operator = "=";
}
}
preField.defaultRank = -10;
const { fieldType, fieldName } = preField;
const { selection, context, relation } = this.fields[fieldName];
preField.defaultAutocompleteValue = { label: `${value}`, operator, value };
if (fieldType === "selection") {
const option = selection.find((sel) => sel[0] === value);
if (!option) {
throw Error();
}
preField.defaultAutocompleteValue.label = option[1];
} else if (fieldType === "many2one") {
this.labels.push((orm) => {
return orm.call(relation, "name_get", [value], { context }).then((results) => {
preField.defaultAutocompleteValue.label = results[0][1];
});
});
}
}
} else {
throw Error(); //but normally this should have caught earlier with view arch validation server side
}
if (node.hasAttribute("string")) {
preField.description = node.getAttribute("string");
} else if (preField.fieldName) {
preField.description = this.fields[preField.fieldName].string;
} else {
preField.description = "Ω";
}
this.currentGroup.push(preField);
}
visitFilter(node) {
const preSearchItem = { type: "filter" };
if (node.hasAttribute("context")) {
const context = node.getAttribute("context");
const [fieldName, defaultInterval] = getContextGroubBy(context);
const groupByField = this.fields[fieldName];
if (groupByField) {
preSearchItem.type = "groupBy";
preSearchItem.fieldName = fieldName;
preSearchItem.fieldType = groupByField.type;
if (["date", "datetime"].includes(groupByField.type)) {
preSearchItem.type = "dateGroupBy";
preSearchItem.defaultIntervalId = defaultInterval || DEFAULT_INTERVAL;
}
} else {
preSearchItem.context = context;
}
}
if (reduceType(preSearchItem.type) !== this.currentTag) {
this.pushGroup(reduceType(preSearchItem.type));
}
if (preSearchItem.type === "filter") {
if (node.hasAttribute("date")) {
const fieldName = node.getAttribute("date");
preSearchItem.type = "dateFilter";
preSearchItem.fieldName = fieldName;
preSearchItem.fieldType = this.fields[fieldName].type;
preSearchItem.defaultGeneratorIds = [DEFAULT_PERIOD];
if (node.hasAttribute("default_period")) {
preSearchItem.defaultGeneratorIds = node
.getAttribute("default_period")
.split(",");
}
} else {
let stringRepr = "[]";
if (node.hasAttribute("domain")) {
stringRepr = node.getAttribute("domain");
}
preSearchItem.domain = stringRepr;
}
}
const modifiers = JSON.parse(node.getAttribute("modifiers") || "{}");
if (modifiers.invisible === true) {
preSearchItem.invisible = true;
const fieldName = preSearchItem.fieldName;
if (fieldName && !this.fields[fieldName]) {
// In some case when a field is limited to specific groups
// on the model, we need to ensure to discard related filter
// as it may still be present in the view (in 'invisible' state)
return;
}
}
preSearchItem.groupNumber = this.groupNumber;
if (node.hasAttribute("name")) {
const name = node.getAttribute("name");
preSearchItem.name = name;
if (name in this.searchDefaults) {
preSearchItem.isDefault = true;
if (["groupBy", "dateGroupBy"].includes(preSearchItem.type)) {
const value = this.searchDefaults[name];
preSearchItem.defaultRank = typeof value === "number" ? value : 100;
} else {
preSearchItem.defaultRank = -5;
}
}
}
if (node.hasAttribute("string")) {
preSearchItem.description = node.getAttribute("string");
} else if (preSearchItem.fieldName) {
preSearchItem.description = this.fields[preSearchItem.fieldName].string;
} else if (node.hasAttribute("help")) {
preSearchItem.description = node.getAttribute("help");
} else if (node.hasAttribute("name")) {
preSearchItem.description = node.getAttribute("name");
} else {
preSearchItem.description = "Ω";
}
this.currentGroup.push(preSearchItem);
}
visitGroup(node, visitChildren) {
this.pushGroup();
visitChildren();
this.pushGroup();
}
visitSearch(node, visitChildren) {
visitChildren();
this.pushGroup();
if (this.pregroupOfGroupBys.length) {
this.preSearchItems.push(this.pregroupOfGroupBys);
}
}
visitSearchPanel(searchPanelNode) {
let hasCategoryWithCounters = false;
let hasFilterWithDomain = false;
let nextSectionId = 1;
if (searchPanelNode.hasAttribute("class")) {
this.searchPanelInfo.className = searchPanelNode.getAttribute("class");
}
if (searchPanelNode.hasAttribute("view_types")) {
this.searchPanelInfo.viewTypes = searchPanelNode.getAttribute("view_types").split(",");
}
for (const node of searchPanelNode.children) {
if (node.nodeType !== 1 || node.tagName !== "field") {
continue;
}
const modifiers = JSON.parse(node.getAttribute("modifiers") || "{}");
if (modifiers.invisible === true) {
continue;
}
const attrs = {};
for (const attrName of node.getAttributeNames()) {
attrs[attrName] = node.getAttribute(attrName);
}
const type = attrs.select === "multi" ? "filter" : "category";
const section = {
color: attrs.color || null,
description: attrs.string || this.fields[attrs.name].string,
enableCounters: Boolean(evaluateExpr(attrs.enable_counters || "0")),
expand: Boolean(evaluateExpr(attrs.expand || "0")),
fieldName: attrs.name,
icon: attrs.icon || null,
id: nextSectionId++,
limit: evaluateExpr(attrs.limit || String(DEFAULT_LIMIT)),
type,
values: new Map(),
};
if (type === "category") {
section.activeValueId = this.searchPanelDefaults[attrs.name];
section.icon = section.icon || "fa-folder";
section.hierarchize = Boolean(evaluateExpr(attrs.hierarchize || "1"));
section.values.set(false, {
childrenIds: [],
display_name: ALL.toString(),
id: false,
bold: true,
parentId: false,
});
hasCategoryWithCounters = hasCategoryWithCounters || section.enableCounters;
} else {
section.domain = attrs.domain || "[]";
section.groupBy = attrs.groupby || null;
section.icon = section.icon || "fa-filter";
hasFilterWithDomain = hasFilterWithDomain || section.domain !== "[]";
}
this.sections.push([section.id, section]);
}
/**
* Category counters are automatically disabled if a filter domain is found
* to avoid inconsistencies with the counters. The underlying problem could
* actually be solved by reworking the search panel and the way the
* counters are computed, though this is not the current priority
* considering the time it would take, hence this quick "fix".
*/
if (hasCategoryWithCounters && hasFilterWithDomain) {
// If incompatibilities are found -> disables all category counters
for (const section of this.sections) {
if (section.type === "category") {
section.enableCounters = false;
}
}
// ... and triggers a warning
console.warn(
"Warning: categories with counters are incompatible with filters having a domain attribute.",
"All category counters have been disabled to avoid inconsistencies."
);
}
return false; // we do not want to let the parser keep visiting children
}
visitSeparator() {
this.pushGroup();
}
}

View file

@ -0,0 +1,462 @@
/** @odoo-module **/
import { Domain } from "@web/core/domain";
import { serializeDate, serializeDateTime } from "@web/core/l10n/dates";
import { registry } from "@web/core/registry";
import { KeepLast } from "@web/core/utils/concurrency";
import { useAutofocus, useBus, useService } from "@web/core/utils/hooks";
import { fuzzyTest } from "@web/core/utils/search";
import { Component, useExternalListener, useRef, useState } from "@odoo/owl";
const parsers = registry.category("parsers");
const CHAR_FIELDS = ["char", "html", "many2many", "many2one", "one2many", "text"];
let nextItemId = 1;
export class SearchBar extends Component {
setup() {
this.fields = this.env.searchModel.searchViewFields;
this.searchItems = this.env.searchModel.getSearchItems((f) => f.type === "field");
this.root = useRef("root");
// core state
this.state = useState({
expanded: [],
focusedIndex: 0,
query: "",
});
// derived state
this.items = useState([]);
this.subItems = {};
this.orm = useService("orm");
this.keepLast = new KeepLast();
this.inputRef = this.env.config.disableSearchBarAutofocus ? useRef("autofocus") : useAutofocus();
useBus(this.env.searchModel, "focus-search", () => {
this.inputRef.el.focus();
});
useBus(this.env.searchModel, "update", this.render);
useExternalListener(window, "click", this.onWindowClick);
useExternalListener(window, "keydown", this.onWindowKeydown);
}
//---------------------------------------------------------------------
// Private
//---------------------------------------------------------------------
/**
* @param {Object} [options={}]
* @param {number[]} [options.expanded]
* @param {number} [options.focusedIndex]
* @param {string} [options.query]
* @param {Object[]} [options.subItems]
* @returns {Object[]}
*/
async computeState(options = {}) {
const query = "query" in options ? options.query : this.state.query;
const expanded = "expanded" in options ? options.expanded : this.state.expanded;
const focusedIndex =
"focusedIndex" in options ? options.focusedIndex : this.state.focusedIndex;
const subItems = "subItems" in options ? options.subItems : this.subItems;
const tasks = [];
for (const id of expanded) {
if (!subItems[id]) {
tasks.push({ id, prom: this.computeSubItems(id, query) });
}
}
const prom = this.keepLast.add(Promise.all(tasks.map((task) => task.prom)));
if (tasks.length) {
const taskResults = await prom;
tasks.forEach((task, index) => {
subItems[task.id] = taskResults[index];
});
}
this.state.expanded = expanded;
this.state.query = query;
this.state.focusedIndex = focusedIndex;
this.subItems = subItems;
this.inputRef.el.value = query;
const trimmedQuery = this.state.query.trim();
this.items.length = 0;
if (!trimmedQuery) {
return;
}
for (const searchItem of this.searchItems) {
const field = this.fields[searchItem.fieldName];
const type = field.type === "reference" ? "char" : field.type;
/** @todo do something with respect to localization (rtl) */
const preposition = this.env._t(["date", "datetime"].includes(type) ? "at" : "for");
if (["selection", "boolean"].includes(type)) {
const options = field.selection || [
[true, this.env._t("Yes")],
[false, this.env._t("No")],
];
for (const [value, label] of options) {
if (fuzzyTest(trimmedQuery.toLowerCase(), label.toLowerCase())) {
this.items.push({
id: nextItemId++,
searchItemDescription: searchItem.description,
preposition,
searchItemId: searchItem.id,
label,
/** @todo check if searchItem.operator is fine (here and elsewhere) */
operator: searchItem.operator || "=",
value,
});
}
}
continue;
}
const parser = parsers.contains(type) ? parsers.get(type) : (str) => str;
let value;
try {
switch (type) {
case "date": {
value = serializeDate(parser(trimmedQuery));
break;
}
case "datetime": {
value = serializeDateTime(parser(trimmedQuery));
break;
}
case "many2one": {
value = trimmedQuery;
break;
}
default: {
value = parser(trimmedQuery);
}
}
} catch (_e) {
continue;
}
const item = {
id: nextItemId++,
searchItemDescription: searchItem.description,
preposition,
searchItemId: searchItem.id,
label: this.state.query,
operator: searchItem.operator || (CHAR_FIELDS.includes(type) ? "ilike" : "="),
value,
};
if (type === "many2one") {
item.isParent = true;
item.isExpanded = this.state.expanded.includes(item.searchItemId);
}
this.items.push(item);
if (item.isExpanded) {
this.items.push(...this.subItems[searchItem.id]);
}
}
}
/**
* @param {number} searchItemId
* @param {string} query
* @returns {Object[]}
*/
async computeSubItems(searchItemId, query) {
const searchItem = this.searchItems.find((i) => i.id === searchItemId);
const field = this.fields[searchItem.fieldName];
let domain = [];
if (searchItem.domain) {
try {
domain = new Domain(searchItem.domain).toList();
} catch (_e) {
// Pass
}
}
const options = await this.orm.call(field.relation, "name_search", [], {
args: domain,
context: field.context,
limit: 8,
name: query.trim(),
});
const subItems = [];
if (options.length) {
const operator = searchItem.operator || "=";
for (const [value, label] of options) {
subItems.push({
id: nextItemId++,
isChild: true,
searchItemId,
value,
label,
operator,
});
}
} else {
subItems.push({
id: nextItemId++,
isChild: true,
searchItemId,
label: this.env._t("(no result)"),
unselectable: true,
});
}
return subItems;
}
/**
* @param {number} [index]
*/
focusFacet(index) {
const facets = this.root.el.getElementsByClassName("o_searchview_facet");
if (facets.length) {
if (index === undefined) {
facets[facets.length - 1].focus();
} else {
facets[index].focus();
}
}
}
/**
* @param {Object} facet
*/
removeFacet(facet) {
this.env.searchModel.deactivateGroup(facet.groupId);
this.inputRef.el.focus();
}
resetState(options = { focus: true }) {
this.computeState({ expanded: [], focusedIndex: 0, query: "", subItems: [] });
if (options.focus) {
this.inputRef.el.focus();
}
}
/**
* @param {Object} item
*/
selectItem(item) {
if (!item.unselectable) {
const { searchItemId, label, operator, value } = item;
this.env.searchModel.addAutoCompletionValues(searchItemId, { label, operator, value });
}
this.resetState();
}
/**
* @param {Object} item
* @param {boolean} shouldExpand
*/
toggleItem(item, shouldExpand) {
const id = item.searchItemId;
const expanded = [...this.state.expanded];
const index = expanded.findIndex((id0) => id0 === id);
if (shouldExpand === true) {
if (index < 0) {
expanded.push(id);
}
} else {
if (index >= 0) {
expanded.splice(index, 1);
}
}
this.computeState({ expanded });
}
//---------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
/**
* @param {Object} facet
* @param {number} facetIndex
* @param {KeyboardEvent} ev
*/
onFacetKeydown(facet, facetIndex, ev) {
switch (ev.key) {
case "ArrowLeft": {
if (facetIndex === 0) {
this.inputRef.el.focus();
} else {
this.focusFacet(facetIndex - 1);
}
break;
}
case "ArrowRight": {
const facets = this.root.el.getElementsByClassName("o_searchview_facet");
if (facetIndex === facets.length - 1) {
this.inputRef.el.focus();
} else {
this.focusFacet(facetIndex + 1);
}
break;
}
case "Backspace": {
this.removeFacet(facet);
break;
}
}
}
/**
* @param {Object} facet
*/
onFacetRemove(facet) {
this.removeFacet(facet);
}
/**
* @param {number} index
*/
onItemMousemove(focusedIndex) {
this.state.focusedIndex = focusedIndex;
this.inputRef.el.focus();
}
/**
* @param {KeyboardEvent} ev
*/
onSearchKeydown(ev) {
if (ev.isComposing) {
// This case happens with an IME for example: we let it handle all key events.
return;
}
const focusedItem = this.items[this.state.focusedIndex];
let focusedIndex;
switch (ev.key) {
case "ArrowDown":
ev.preventDefault();
if (this.items.length) {
if (this.state.focusedIndex >= this.items.length - 1) {
focusedIndex = 0;
} else {
focusedIndex = this.state.focusedIndex + 1;
}
} else {
this.env.searchModel.trigger("focus-view");
}
break;
case "ArrowUp":
ev.preventDefault();
if (this.items.length) {
if (
this.state.focusedIndex === 0 ||
this.state.focusedIndex > this.items.length - 1
) {
focusedIndex = this.items.length - 1;
} else {
focusedIndex = this.state.focusedIndex - 1;
}
}
break;
case "ArrowLeft":
if (focusedItem && focusedItem.isParent && focusedItem.isExpanded) {
ev.preventDefault();
this.toggleItem(focusedItem, false);
} else if (focusedItem && focusedItem.isChild) {
ev.preventDefault();
focusedIndex = this.items.findIndex(
(item) => item.isParent && item.searchItemId === focusedItem.searchItemId
);
} else if (ev.target.selectionStart === 0) {
// focus rightmost facet if any.
this.focusFacet();
} else {
// do nothing and navigate inside text
}
break;
case "ArrowRight":
if (ev.target.selectionStart === this.state.query.length) {
if (focusedItem && focusedItem.isParent) {
ev.preventDefault();
if (focusedItem.isExpanded) {
focusedIndex = this.state.focusedIndex + 1;
} else {
this.toggleItem(focusedItem, true);
}
} else if (ev.target.selectionStart === this.state.query.length) {
// Priority 3: focus leftmost facet if any.
this.focusFacet(0);
}
}
break;
case "Backspace":
if (!this.state.query.length) {
const facets = this.env.searchModel.facets;
if (facets.length) {
this.removeFacet(facets[facets.length - 1]);
}
}
break;
case "Enter":
if (!this.state.query.length) {
this.env.searchModel.search(); /** @todo keep this thing ?*/
break;
} else if (focusedItem) {
ev.preventDefault(); // keep the focus inside the search bar
this.selectItem(focusedItem);
}
break;
case "Tab":
if (this.state.query.length && focusedItem) {
ev.preventDefault(); // keep the focus inside the search bar
this.selectItem(focusedItem);
}
break;
case "Escape":
this.resetState();
break;
}
if (focusedIndex !== undefined) {
this.state.focusedIndex = focusedIndex;
}
}
/**
* @param {InputEvent} ev
*/
onSearchInput(ev) {
const query = ev.target.value;
if (query.trim()) {
this.computeState({ query, expanded: [], focusedIndex: 0, subItems: [] });
} else if (this.items.length) {
this.resetState();
}
}
/**
* @param {MouseEvent} ev
*/
onWindowClick(ev) {
if (this.items.length && !this.root.el.contains(ev.target)) {
this.resetState({ focus: false });
}
}
/**
* @param {KeyboardEvent} ev
*/
onWindowKeydown(ev) {
if (this.items.length && ev.key === "Escape") {
this.resetState();
}
}
}
SearchBar.template = "web.SearchBar";

View file

@ -0,0 +1,12 @@
// = Search Bar
// ============================================================================
.o_searchview_facet {
.o_facet_values {
border: $border-width solid var(--SearchBar-facet-background, #{$o-brand-odoo});
}
.o_searchview_facet_label {
background: var(--SearchBar-facet-background, #{$o-brand-odoo});
color: var(--SearchBar-facet-color, #{$o-white});
}
}

View file

@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.SearchBar.Facets" owl="1">
<t t-foreach="env.searchModel.facets" t-as="facet" t-key="facet_index">
<div class="o_searchview_facet"
role="img"
aria-label="search"
tabindex="0"
t-on-keydown="ev => this.onFacetKeydown(facet, facet_index, ev)"
>
<t t-if="facet.icon">
<span t-attf-class="o_searchview_facet_label {{ facet.icon }}"/>
</t>
<t t-else="">
<span class="o_searchview_facet_label" t-esc="facet.title"/>
</t>
<div class="o_facet_values border-start-0">
<t t-foreach="facet.values" t-as="facetValue" t-key="facetValue_index">
<t t-if="!facetValue_first">
<span class="o_facet_values_sep" t-esc="facet.separator"/>
</t>
<span class="o_facet_value" t-esc="facetValue"/>
</t>
</div>
<i class="o_facet_remove oi oi-close btn btn-link opacity-50 opacity-100-hover text-900"
role="img"
aria-label="Remove"
title="Remove"
t-on-click="() => this.onFacetRemove(facet)"
/>
</div>
</t>
</t>
<t t-name="web.SearchBar.Input" owl="1">
<input type="text"
class="o_searchview_input"
accesskey="Q"
placeholder="Search..."
role="searchbox"
t-ref="autofocus"
t-on-keydown="onSearchKeydown"
t-on-input="onSearchInput"
/>
</t>
<t t-name="web.SearchBar.Items" owl="1">
<ul class="dropdown-menu o_searchview_autocomplete dropdown-menu show" role="menu">
<t t-foreach="items" t-as="item" t-key="item.id">
<li class="o_menu_item dropdown-item"
t-att-class="{ o_indent: item.isChild, focus: item_index === state.focusedIndex}"
t-att-id="item.id"
t-on-click="() => this.selectItem(item)"
t-on-mousemove="() => this.onItemMousemove(item_index)"
>
<t t-if="item.isParent">
<a class="o_expand"
href="#"
t-on-click.stop.prevent="() => this.toggleItem(item, !item.isExpanded)"
>
<i t-attf-class="fa fa-caret-{{ item.isExpanded ? 'down' : 'right' }}"/>
</a>
</t>
<a href="#" t-on-click.prevent="">
<t t-if="item.isChild">
<t t-esc="item.label"/>
</t>
<t t-else="">
Search <b t-esc="item.searchItemDescription"/> <t t-esc="item.preposition"/>: <b class="fst-italic text-primary" t-esc="item.label"/>
</t>
</a>
</li>
</t>
</ul>
</t>
<t t-name="web.SearchBar" owl="1">
<div class="o_cp_searchview d-flex flex-grow-1" role="search" t-ref="root">
<div class="o_searchview pb-1 align-self-center border-bottom flex-grow-1" role="search" aria-autocomplete="list">
<i class="o_searchview_icon oi oi-search"
role="img"
aria-label="Search..."
title="Search..."
/>
<div class="o_searchview_input_container">
<t t-call="web.SearchBar.Facets"/>
<t t-call="web.SearchBar.Input"/>
<t t-if="items.length">
<t t-call="web.SearchBar.Items"/>
</t>
</div>
</div>
</div>
</t>
</templates>

View file

@ -0,0 +1,13 @@
/** @odoo-module **/
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
export class SearchDropdownItem extends DropdownItem {}
SearchDropdownItem.template = "web.SearchDropdownItem";
SearchDropdownItem.props = {
...DropdownItem.props,
checked: {
type: Boolean,
optional: false,
},
};

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.SearchDropdownItem" t-inherit="web.DropdownItem" t-inherit-mode="primary" owl="1">
<xpath expr="//t[@role='menuitem']" position="attributes">
<attribute name="role">menuitemcheckbox</attribute>
<attribute name="t-att-aria-checked">props.checked ? 'true' : 'false'</attribute>
</xpath>
</t>
</templates>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,281 @@
/** @odoo-module **/
import { useBus } from "@web/core/utils/hooks";
import { Component, onMounted, onWillUpdateProps, onWillStart, useRef, useState } from "@odoo/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
*
* Represent an extension of the search interface located on the left side of
* the view. It is divided in sections defined in a "<searchpanel>" node located
* inside of a "<search>" arch. Each section is represented by a list of different
* values (categories or ungrouped filters) or groups of values (grouped filters).
* Its state is directly affected by its model (@see SearchModel).
*/
export class SearchPanel extends Component {
setup() {
this.state = useState({
active: {},
expanded: {},
showMobileSearch: false,
});
this.root = useRef("root");
this.scrollTop = 0;
this.hasImportedState = false;
this.importState(this.props.importedState);
useBus(this.env.searchModel, "update", async () => {
await this.env.searchModel.sectionsPromise;
this.updateActiveValues();
this.render();
});
onWillStart(async () => {
await this.env.searchModel.sectionsPromise;
this.expandDefaultValue();
this.updateActiveValues();
});
onWillUpdateProps(async () => {
await this.env.searchModel.sectionsPromise;
this.updateActiveValues();
});
onMounted(() => {
this.updateGroupHeadersChecked();
if (this.hasImportedState) {
this.root.el.scroll({ top: this.scrollTop });
}
});
}
//---------------------------------------------------------------------
// Getters
//---------------------------------------------------------------------
get sections() {
return this.env.searchModel.getSections((s) => !s.empty);
}
//---------------------------------------------------------------------
// Public
//---------------------------------------------------------------------
exportState() {
const exported = {
expanded: this.state.expanded,
scrollTop: this.root.el.scrollTop,
};
return JSON.stringify(exported);
}
importState(stringifiedState) {
this.hasImportedState = Boolean(stringifiedState);
if (this.hasImportedState) {
const state = JSON.parse(stringifiedState);
this.state.expanded = state.expanded;
this.scrollTop = state.scrollTop;
}
}
//---------------------------------------------------------------------
// Protected
//---------------------------------------------------------------------
/**
* Expands category values holding the default value of a category.
*/
expandDefaultValue() {
if (this.hasImportedState) {
return;
}
const categories = this.env.searchModel.getSections((s) => s.type === "category");
for (const category of categories) {
this.state.expanded[category.id] = {};
if (category.activeValueId) {
const ancestorIds = this.getAncestorValueIds(category, category.activeValueId);
for (const ancestorId of ancestorIds) {
this.state.expanded[category.id][ancestorId] = true;
}
}
}
}
/**
* @param {Object} category
* @param {number} categoryValueId
* @returns {number[]} list of ids of the ancestors of the given value in
* the given category.
*/
getAncestorValueIds(category, categoryValueId) {
const { parentId } = category.values.get(categoryValueId);
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.
* @param {Object} category
* @param {Object} value
*/
async toggleCategory(category, value) {
if (value.childrenIds.length) {
const categoryState = this.state.expanded[category.id];
if (categoryState[value.id] && category.activeValueId === value.id) {
delete categoryState[value.id];
} else {
categoryState[value.id] = true;
}
}
if (category.activeValueId !== value.id) {
this.env.searchModel.toggleCategoryValue(category.id, value.id);
}
}
/**
* @param {number} filterId
* @param {{ values: Map<Object> }} group
*/
toggleFilterGroup(filterId, { values }) {
const valueIds = [];
const checked = [...values.values()].every(
(value) => this.state.active[filterId][value.id]
);
values.forEach(({ id }) => {
valueIds.push(id);
this.state.active[filterId][id] = !checked;
});
this.env.searchModel.toggleFilterValues(filterId, valueIds, !checked);
}
/**
* @param {number} filterId
* @param {Object} [group]
* @param {number} valueId
* @param {MouseEvent} ev
*/
toggleFilterValue(filterId, valueId, { currentTarget }) {
this.state.active[filterId][valueId] = currentTarget.checked;
this.updateGroupHeadersChecked();
this.env.searchModel.toggleFilterValues(filterId, [valueId]);
}
updateActiveValues() {
for (const section of this.sections) {
if (section.type === "category") {
this.state.active[section.id] = section.activeValueId;
} else {
this.state.active[section.id] = {};
if (section.groups) {
for (const group of section.groups.values()) {
for (const value of group.values.values()) {
this.state.active[section.id][value.id] = value.checked;
}
}
}
if (section && section.values) {
for (const value of section.values.values()) {
this.state.active[section.id][value.id] = value.checked;
}
}
}
}
}
/**
* Updates the "checked" or "indeterminate" state of each of the group
* headers according to the state of their values.
*/
updateGroupHeadersChecked() {
const groups = this.root.el.querySelectorAll(":scope .o_search_panel_filter_group");
for (const group of groups) {
const header = group.querySelector(":scope .o_search_panel_group_header input");
const vals = [...group.querySelectorAll(":scope .o_search_panel_filter_value input")];
header.checked = false;
header.indeterminate = false;
if (vals.every((v) => v.checked)) {
header.checked = true;
} else if (vals.some((v) => v.checked)) {
header.indeterminate = true;
}
}
}
}
SearchPanel.props = {
importedState: { type: String, optional: true },
};
SearchPanel.subTemplates = {
category: "web.SearchPanel.Category",
filtersGroup: "web.SearchPanel.FiltersGroup",
};
SearchPanel.template = "web.SearchPanel";

View file

@ -0,0 +1,57 @@
// ------- View with SearchPanel -------
.o_component_with_search_panel,
.o_controller_with_searchpanel {
display: flex;
align-items: flex-start;
.o_renderer,
.o_renderer_with_searchpanel {
flex: 1 1 100%;
overflow: auto; // make the renderer and search panel scroll individually
max-height: 100%;
position: relative;
height: 100%;
@include media-breakpoint-down(md) {
overflow: visible;
}
}
}
// ------- SearchPanel -------
.o_search_panel {
width: var(--SearchPanel-width, #{$o-search-panel-width});
font-size: var(--SearchPanel-fontSize, #{$o-search-panel-font-size});
.o_search_panel_category_value,
.o_search_panel_filter_value input,
.o_search_panel_filter_value .o_search_panel_label_title,
.o_search_panel_group_header input,
.o_search_panel_group_header .o_search_panel_label_title
{
cursor: pointer;
}
.o_toggle_fold {
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%;
}
}
}

View file

@ -0,0 +1,27 @@
// = Search Panel Variables
// ============================================================================
$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;
}
}

View file

@ -0,0 +1,230 @@
<?xml version="1.0" encoding="UTF-8"?>
<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-view 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 }}"
>
<header class="o_search_panel_section_header pt-4 pb-2 text-uppercase cursor-default">
<i t-attf-class="fa {{ section.icon }} o_search_panel_section_icon {{!section.color &amp;&amp; section.type == 'filter' ? 'text-warning' : !section.color ? 'text-odoo': ''}} me-2"
t-att-style="section.color and ('color: ' + section.color)"
/>
<b t-esc="section.description"/>
</header>
<div t-if="section.errorMsg" class="alert alert-warning">
<span><t t-esc="section.errorMsg"/></span>
</div>
<ul t-else="" class="list-group d-block o_search_panel_field">
<t t-if="section.type === 'category'" t-call="{{ constructor.subTemplates.category }}">
<t t-set="values" t-value="section.rootIds"/>
</t>
<t t-elif="section.groups">
<li
t-foreach="section.sortedGroupIds" t-as="groupId" t-key="groupId"
class="o_search_panel_filter_group list-group-item p-0 border-0"
t-att-class="groupId_last? 'mb-0' : 'mb-3'"
>
<!-- TODO: this is a workaround for issue https://github.com/odoo/owl/issues/695 (remove when solved) -->
<t t-set="_section" t-value="section"/>
<t t-set="group" t-value="section.groups.get(groupId)"/>
<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)
-->
<input type="checkbox"
class="form-check-input"
t-attf-id="{{ section.id }}_input_{{ groupId }})"
t-on-click="() => this.toggleFilterGroup(section.id, group)"
/>
<label
t-attf-for="{{ section.id }}_input_{{ groupId }})"
class="o_search_panel_label form-check-label d-flex align-items-center justify-content-between w-100 o_cursor_pointer"
t-att-class="{ o_with_counters: group.enableCounters }"
t-att-title="group.tooltip or false"
>
<span class="o_search_panel_label_title text-truncate">
<span t-if="group.hex_color" class="me-1" t-attf-style="color: {{ group.hex_color }};"></span>
<t t-esc="group.name"/>
</span>
</label>
</div>
</header>
<ul class="list-group d-block">
<t t-call="{{ constructor.subTemplates.filtersGroup }}">
<t t-set="values" t-value="group.values"/>
<t t-set="isChildList" t-value="true"/>
<!-- TODO: this is a workaround for issue https://github.com/odoo/owl/issues/695 (remove when solved) -->
<t t-set="section" t-value="_section"/>
</t>
</ul>
</li>
<ul t-if="section.groups.get(false)" class="list-group d-block">
<t t-call="{{ constructor.subTemplates.filtersGroup }}">
<t t-set="group" t-value="section.groups.get(false)"/>
<t t-set="values" t-value="group.values"/>
<!-- TODO: this is a workaround for issue https://github.com/odoo/owl/issues/695 (remove when solved) -->
<t t-set="section" t-value="section"/>
</t>
</ul>
</t>
<t t-else="" t-call="{{ constructor.subTemplates.filtersGroup }}">
<t t-set="values" t-value="section.values"/>
</t>
</ul>
</section>
</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)"/>
<li class="o_search_panel_category_value list-group-item py-1 o_cursor_pointer border-0"
t-att-class="isChildList ? 'o_treeEntry ps-4 pe-0' : 'ps-0 pe-2'"
>
<header
class="list-group-item list-group-item-action d-flex align-items-center p-0 border-0"
t-att-class="{'active text-900 fw-bold': state.active[section.id] === valueId}"
t-on-click="() => this.toggleCategory(section, value)"
>
<div
class="o_search_panel_label d-flex align-items-center overflow-hidden w-100 o_cursor_pointer mb-0"
t-att-class="{'o_with_counters': section.enableCounters }"
t-att-data-tooltip="value.display_name"
>
<button class="o_toggle_fold btn p-0 flex-shrink-0 text-center">
<i
t-if="value.childrenIds.length"
class="fa"
t-att-class="{
'fa-caret-down' : state.expanded[section.id][valueId],
'fa-caret-right ms-1': !state.expanded[section.id][valueId]
}"
/>
</button>
<span
class="o_search_panel_label_title text-truncate"
t-att-class="{'fw-bold' : value.bold}"
t-esc="value.display_name"
/>
</div>
<small t-if="section.enableCounters and value.__count gt 0"
class="o_search_panel_counter text-muted mx-2 fw-bold"
t-esc="value.__count"
/>
</header>
<ul t-if="value.childrenIds.length and state.expanded[section.id][valueId]"
class="list-group d-block"
>
<t t-call="{{ constructor.subTemplates.category }}">
<t t-set="values" t-value="value.childrenIds"/>
<t t-set="isChildList" t-value="true"/>
</t>
</ul>
</li>
</t>
</t>
<t t-name="web.SearchPanel.FiltersGroup" owl="1">
<li t-foreach="[...values.keys()]" t-as="valueId" t-key="valueId"
class="o_search_panel_filter_value list-group-item p-0 mb-1 border-0 o_cursor_pointer"
t-att-class="{ 'ps-2' : isChildList }"
>
<t t-set="value" t-value="values.get(valueId)"/>
<div class="form-check w-100">
<input type="checkbox"
t-attf-id="{{ section.id }}_input_{{ valueId }}"
t-att-checked="state.active[section.id][valueId]"
class="form-check-input"
t-on-click="ev => this.toggleFilterValue(section.id, valueId, ev)"
/>
<label class="o_search_panel_label form-check-label d-flex align-items-center justify-content-between w-100 o_cursor_pointer"
t-attf-for="{{ section.id }}_input_{{ valueId }}"
t-att-title="(group and group.tooltip) or false">
<span class="o_search_panel_label_title text-truncate" t-esc="value.display_name"/>
<span t-if="section.enableCounters and value.__count gt 0"
class="o_search_panel_counter text-muted mx-2 small"
t-esc="value.__count"
/>
</label>
</div>
</li>
</t>
</templates>

View file

@ -0,0 +1,260 @@
.o_searchview {
align-items: flex-end;
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 {
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;
&::before, &::after {
content: " ";
}
}
}
.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);
@include o-hover-text-color($text-muted, $o-brand-odoo);
}
}
.o_searchview_input {
flex: 1 0 auto;
width: 75px;
border: none;
outline: none;
background-color: var(--o-input-background-color, #{$input-bg});
}
.o_searchview_autocomplete {
width: 100%;
@include o-position-absolute(100%, $left: auto);
.o_menu_item {
align-items: center;
display: flex;
padding-left: 25px;
&.o_indent {
padding-left: 50px;
}
a {
color: inherit;
&.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 {
flex-wrap: wrap;
margin: auto 0;
flex-grow: 1;
}
// Filter menu
.o_filter_menu {
.o_filter_condition {
&.o_filter_condition_with_buttons {
padding-right: 0;
padding-left: $dropdown-item-padding-x*1.5;
}
.o_or_filter {
@include o-position-absolute($left: $dropdown-item-padding-x*.2);
}
.o_generator_menu_value {
color: $o-main-text-color; // avoid to inherit .dropdown-item style
.datepickerbutton {
cursor: pointer;
@include o-position-absolute(3px, -20px);
}
}
.o_generator_menu_delete {
@include o-hover-opacity(0.8, 1);
@include o-position-absolute($dropdown-item-padding-x*.3, $dropdown-item-padding-x*.2, auto, auto);
&:hover {
background-color: map-get($grays, '100');
}
}
}
.o_add_condition {
line-height: 1.1;
.fa {
font-size: $font-size-lg;
}
}
}
.o_web_client .o_mobile_search {
align-items: normal;
background-color: var(--mobileSearch-bg, #{$o-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: var(--mobileSearch__header-bg, #{$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%;
background-color: var(--o-input-background-color, #{$input-bg});
}
.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%;
}
}

View file

@ -0,0 +1,431 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { Domain } from "@web/core/domain";
import { serializeDate, serializeDateTime } from "@web/core/l10n/dates";
import { localization } from "@web/core/l10n/localization";
export const DEFAULT_PERIOD = "this_month";
export const QUARTERS = {
1: { description: _lt("Q1"), coveredMonths: [1, 2, 3] },
2: { description: _lt("Q2"), coveredMonths: [4, 5, 6] },
3: { description: _lt("Q3"), coveredMonths: [7, 8, 9] },
4: { description: _lt("Q4"), coveredMonths: [10, 11, 12] },
};
export const MONTH_OPTIONS = {
this_month: {
id: "this_month",
groupNumber: 1,
format: "MMMM",
plusParam: {},
granularity: "month",
},
last_month: {
id: "last_month",
groupNumber: 1,
format: "MMMM",
plusParam: { months: -1 },
granularity: "month",
},
antepenultimate_month: {
id: "antepenultimate_month",
groupNumber: 1,
format: "MMMM",
plusParam: { months: -2 },
granularity: "month",
},
};
export const QUARTER_OPTIONS = {
fourth_quarter: {
id: "fourth_quarter",
groupNumber: 1,
description: QUARTERS[4].description,
setParam: { quarter: 4 },
granularity: "quarter",
},
third_quarter: {
id: "third_quarter",
groupNumber: 1,
description: QUARTERS[3].description,
setParam: { quarter: 3 },
granularity: "quarter",
},
second_quarter: {
id: "second_quarter",
groupNumber: 1,
description: QUARTERS[2].description,
setParam: { quarter: 2 },
granularity: "quarter",
},
first_quarter: {
id: "first_quarter",
groupNumber: 1,
description: QUARTERS[1].description,
setParam: { quarter: 1 },
granularity: "quarter",
},
};
export const YEAR_OPTIONS = {
this_year: {
id: "this_year",
groupNumber: 2,
format: "yyyy",
plusParam: {},
granularity: "year",
},
last_year: {
id: "last_year",
groupNumber: 2,
format: "yyyy",
plusParam: { years: -1 },
granularity: "year",
},
antepenultimate_year: {
id: "antepenultimate_year",
groupNumber: 2,
format: "yyyy",
plusParam: { years: -2 },
granularity: "year",
},
};
export const PERIOD_OPTIONS = Object.assign({}, MONTH_OPTIONS, QUARTER_OPTIONS, YEAR_OPTIONS);
export const DEFAULT_INTERVAL = "month";
export const INTERVAL_OPTIONS = {
year: { description: _lt("Year"), id: "year", groupNumber: 1 },
quarter: { description: _lt("Quarter"), id: "quarter", groupNumber: 1 },
month: { description: _lt("Month"), id: "month", groupNumber: 1 },
week: { description: _lt("Week"), id: "week", groupNumber: 1 },
day: { description: _lt("Day"), id: "day", groupNumber: 1 },
};
// ComparisonMenu parameters
export const COMPARISON_OPTIONS = {
previous_period: {
description: _lt("Previous Period"),
id: "previous_period",
},
previous_year: {
description: _lt("Previous Year"),
id: "previous_year",
plusParam: { years: -1 },
},
};
export const PER_YEAR = {
year: 1,
quarter: 4,
month: 12,
};
//-------------------------------------------------------------------------
// Functions
//-------------------------------------------------------------------------
/**
* Constructs the string representation of a domain and its description. The
* domain is of the form:
* ['|', d_1 ,..., '|', d_n]
* where d_i is a time range of the form
* ['&', [fieldName, >=, leftBound_i], [fieldName, <=, rightBound_i]]
* where leftBound_i and rightBound_i are date or datetime computed accordingly
* to the given options and reference moment.
*/
export function constructDateDomain(
referenceMoment,
fieldName,
fieldType,
selectedOptionIds,
comparisonOptionId
) {
let plusParam;
let selectedOptions;
if (comparisonOptionId) {
[plusParam, selectedOptions] = getComparisonParams(
referenceMoment,
selectedOptionIds,
comparisonOptionId
);
} else {
selectedOptions = getSelectedOptions(referenceMoment, selectedOptionIds);
}
const yearOptions = selectedOptions.year;
const otherOptions = [...(selectedOptions.quarter || []), ...(selectedOptions.month || [])];
sortPeriodOptions(yearOptions);
sortPeriodOptions(otherOptions);
const ranges = [];
for (const yearOption of yearOptions) {
const constructRangeParams = {
referenceMoment,
fieldName,
fieldType,
plusParam,
};
if (otherOptions.length) {
for (const option of otherOptions) {
const setParam = Object.assign(
{},
yearOption.setParam,
option ? option.setParam : {}
);
const { granularity } = option;
const range = constructDateRange(
Object.assign({ granularity, setParam }, constructRangeParams)
);
ranges.push(range);
}
} else {
const { granularity, setParam } = yearOption;
const range = constructDateRange(
Object.assign({ granularity, setParam }, constructRangeParams)
);
ranges.push(range);
}
}
const domain = Domain.combine(
ranges.map((range) => range.domain),
"OR"
);
const description = ranges.map((range) => range.description).join("/");
return { domain, description };
}
/**
* Constructs the string representation of a domain and its description. The
* domain is a time range of the form:
* ['&', [fieldName, >=, leftBound],[fieldName, <=, rightBound]]
* where leftBound and rightBound are some date or datetime determined by setParam,
* plusParam, granularity and the reference moment.
*/
export function constructDateRange(params) {
const { referenceMoment, fieldName, fieldType, granularity, setParam, plusParam } = params;
if ("quarter" in setParam) {
// Luxon does not consider quarter key in setParam (like moment did)
setParam.month = QUARTERS[setParam.quarter].coveredMonths[0];
delete setParam.quarter;
}
const date = referenceMoment.set(setParam).plus(plusParam || {});
// compute domain
const leftDate = date.startOf(granularity);
const rightDate = date.endOf(granularity);
let leftBound;
let rightBound;
if (fieldType === "date") {
leftBound = serializeDate(leftDate);
rightBound = serializeDate(rightDate);
} else {
leftBound = serializeDateTime(leftDate);
rightBound = serializeDateTime(rightDate);
}
const domain = new Domain(["&", [fieldName, ">=", leftBound], [fieldName, "<=", rightBound]]);
// compute description
const descriptions = [date.toFormat("yyyy")];
const method = localization.direction === "rtl" ? "push" : "unshift";
if (granularity === "month") {
descriptions[method](date.toFormat("MMMM"));
} else if (granularity === "quarter") {
const quarter = date.quarter;
descriptions[method](QUARTERS[quarter].description.toString());
}
const description = descriptions.join(" ");
return { domain, description };
}
/**
* Returns a version of the options in COMPARISON_OPTIONS with translated descriptions.
* @see getOptionsWithDescriptions
*/
export function getComparisonOptions() {
return getOptionsWithDescriptions(COMPARISON_OPTIONS);
}
/**
* Returns the params plusParam and selectedOptions necessary for the computation
* of a comparison domain.
*/
export function getComparisonParams(referenceMoment, selectedOptionIds, comparisonOptionId) {
const comparisonOption = COMPARISON_OPTIONS[comparisonOptionId];
const selectedOptions = getSelectedOptions(referenceMoment, selectedOptionIds);
if (comparisonOption.plusParam) {
return [comparisonOption.plusParam, selectedOptions];
}
const plusParam = {};
let globalGranularity = "year";
if (selectedOptions.month) {
globalGranularity = "month";
} else if (selectedOptions.quarter) {
globalGranularity = "quarter";
}
const granularityFactor = PER_YEAR[globalGranularity];
const years = selectedOptions.year.map((o) => o.setParam.year);
const yearMin = Math.min(...years);
const yearMax = Math.max(...years);
let optionMin = 0;
let optionMax = 0;
if (selectedOptions.quarter) {
const quarters = selectedOptions.quarter.map((o) => o.setParam.quarter);
if (globalGranularity === "month") {
delete selectedOptions.quarter;
for (const quarter of quarters) {
for (const month of QUARTERS[quarter].coveredMonths) {
const monthOption = selectedOptions.month.find(
(o) => o.setParam.month === month
);
if (!monthOption) {
selectedOptions.month.push({
setParam: { month },
granularity: "month",
});
}
}
}
} else {
optionMin = Math.min(...quarters);
optionMax = Math.max(...quarters);
}
}
if (selectedOptions.month) {
const months = selectedOptions.month.map((o) => o.setParam.month);
optionMin = Math.min(...months);
optionMax = Math.max(...months);
}
const num = -1 + granularityFactor * (yearMin - yearMax) + optionMin - optionMax;
const key =
globalGranularity === "year"
? "years"
: globalGranularity === "month"
? "months"
: "quarters";
plusParam[key] = num;
return [plusParam, selectedOptions];
}
/**
* Returns a version of the options in INTERVAL_OPTIONS with translated descriptions.
* @see getOptionsWithDescriptions
*/
export function getIntervalOptions() {
return getOptionsWithDescriptions(INTERVAL_OPTIONS);
}
/**
* Returns a version of the options in PERIOD_OPTIONS with translated descriptions
* and a key defautlYearId used in the control panel model when toggling a period option.
*/
export function getPeriodOptions(referenceMoment) {
// adapt when solution for moment is found...
const options = [];
const originalOptions = Object.values(PERIOD_OPTIONS);
for (const option of originalOptions) {
const { id, groupNumber } = option;
let description;
let defaultYear;
switch (option.granularity) {
case "quarter":
description = option.description.toString();
defaultYear = referenceMoment.set(option.setParam).year;
break;
case "month":
case "year": {
const date = referenceMoment.plus(option.plusParam);
description = date.toFormat(option.format);
defaultYear = date.year;
break;
}
}
const setParam = getSetParam(option, referenceMoment);
options.push({ id, groupNumber, description, defaultYear, setParam });
}
const periodOptions = [];
for (const option of options) {
const { id, groupNumber, description, defaultYear } = option;
const yearOption = options.find((o) => o.setParam && o.setParam.year === defaultYear);
periodOptions.push({
id,
groupNumber,
description,
defaultYearId: yearOption.id,
});
}
return periodOptions;
}
/**
* Returns a version of the options in OPTIONS with translated descriptions (if any).
* @param {Object{}} OPTIONS
* @returns {Object[]}
*/
export function getOptionsWithDescriptions(OPTIONS) {
const options = [];
for (const option of Object.values(OPTIONS)) {
options.push(Object.assign({}, option, { description: option.description.toString() }));
}
return options;
}
/**
* Returns a partial version of the period options whose ids are in selectedOptionIds
* partitioned by granularity.
*/
export function getSelectedOptions(referenceMoment, selectedOptionIds) {
const selectedOptions = { year: [] };
for (const optionId of selectedOptionIds) {
const option = PERIOD_OPTIONS[optionId];
const setParam = getSetParam(option, referenceMoment);
const granularity = option.granularity;
if (!selectedOptions[granularity]) {
selectedOptions[granularity] = [];
}
selectedOptions[granularity].push({ granularity, setParam });
}
return selectedOptions;
}
/**
* Returns the setParam object associated with the given periodOption and
* referenceMoment.
*/
export function getSetParam(periodOption, referenceMoment) {
if (periodOption.granularity === "quarter") {
return periodOption.setParam;
}
const date = referenceMoment.plus(periodOption.plusParam);
const granularity = periodOption.granularity;
const setParam = { [granularity]: date[granularity] };
return setParam;
}
export function rankInterval(intervalOptionId) {
return Object.keys(INTERVAL_OPTIONS).indexOf(intervalOptionId);
}
/**
* Sorts in place an array of 'period' options.
*/
export function sortPeriodOptions(options) {
options.sort((o1, o2) => {
var _a, _b;
const granularity1 = o1.granularity;
const granularity2 = o2.granularity;
if (granularity1 === granularity2) {
return (
((_a = o1.setParam[granularity1]) !== null && _a !== void 0 ? _a : 0) -
((_b = o2.setParam[granularity1]) !== null && _b !== void 0 ? _b : 0)
);
}
return granularity1 < granularity2 ? -1 : 1;
});
}
/**
* Checks if a year id is among the given array of period option ids.
*/
export function yearSelected(selectedOptionIds) {
return selectedOptionIds.some((optionId) => Object.keys(YEAR_OPTIONS).includes(optionId));
}

View file

@ -0,0 +1,62 @@
/** @odoo-module **/
import { DEFAULT_INTERVAL, INTERVAL_OPTIONS } from "./dates";
/**
* @param {string} descr
*/
function errorMsg(descr) {
return `Invalid groupBy description: ${descr}`;
}
/**
* @param {string} descr
* @param {Object} fields
* @returns {Object}
*/
export function getGroupBy(descr, fields) {
let fieldName;
let interval;
let spec;
[fieldName, interval] = descr.split(":");
if (!fieldName) {
throw Error();
}
if (fields) {
if (!fields[fieldName]) {
throw Error(errorMsg(descr));
}
const fieldType = fields[fieldName].type;
if (["date", "datetime"].includes(fieldType)) {
if (!interval) {
interval = DEFAULT_INTERVAL;
} else if (!Object.keys(INTERVAL_OPTIONS).includes(interval)) {
throw Error(errorMsg(descr));
}
spec = `${fieldName}:${interval}`;
} else if (interval) {
throw Error(errorMsg(descr));
} else {
spec = fieldName;
interval = null;
}
} else {
if (interval) {
if (!Object.keys(INTERVAL_OPTIONS).includes(interval)) {
throw Error(errorMsg(descr));
}
spec = `${fieldName}:${interval}`;
} else {
spec = fieldName;
interval = null;
}
}
return {
fieldName,
interval,
spec,
toJSON() {
return spec;
},
};
}

View file

@ -0,0 +1,19 @@
/** @odoo-module **/
export const FACET_ICONS = {
filter: "fa fa-filter",
groupBy: "oi oi-group",
favorite: "fa fa-star",
comparison: "fa fa-adjust",
};
export const GROUPABLE_TYPES = [
"boolean",
"char",
"date",
"datetime",
"integer",
"many2one",
"many2many",
"selection",
];

View file

@ -0,0 +1,91 @@
/** @odoo-module **/
import { useBus, useService } from "@web/core/utils/hooks";
import { SearchModel } from "@web/search/search_model";
import { CallbackRecorder, useSetupAction } from "@web/webclient/actions/action_hook";
import { Component, onWillStart, onWillUpdateProps, toRaw, useSubEnv } from "@odoo/owl";
export const SEARCH_KEYS = ["comparison", "context", "domain", "groupBy", "orderBy"];
export class WithSearch extends Component {
setup() {
if (!this.env.__getContext__) {
useSubEnv({ __getContext__: new CallbackRecorder() });
}
if (!this.env.__getOrderBy__) {
useSubEnv({ __getOrderBy__: new CallbackRecorder() });
}
const SearchModelClass = this.props.SearchModel || SearchModel;
this.searchModel = new SearchModelClass(this.env, {
user: useService("user"),
orm: useService("orm"),
view: useService("view"),
});
useSubEnv({ searchModel: this.searchModel });
useBus(this.searchModel, "update", this.render);
useSetupAction({
getGlobalState: () => {
return {
searchModel: JSON.stringify(this.searchModel.exportState()),
};
},
});
onWillStart(async () => {
const config = { ...toRaw(this.props) };
if (config.globalState && config.globalState.searchModel) {
config.state = JSON.parse(config.globalState.searchModel);
delete config.globalState;
}
await this.searchModel.load(config);
});
onWillUpdateProps(async (nextProps) => {
const config = {};
for (const key of SEARCH_KEYS) {
if (nextProps[key] !== undefined) {
config[key] = nextProps[key];
}
}
await this.searchModel.reload(config);
});
}
}
WithSearch.template = "web.WithSearch";
WithSearch.props = {
slots: Object,
SearchModel: { type: Function, optional: true },
resModel: String,
globalState: { type: Object, optional: true },
display: { type: Object, optional: true },
// search query elements
comparison: { validate: () => true, optional: true }, // fix problem with validation with type: [Object, null]
// Issue OWL: https://github.com/odoo/owl/issues/910
context: { type: Object, optional: true },
domain: { type: Array, element: [String, Array], optional: true },
groupBy: { type: Array, element: String, optional: true },
orderBy: { type: Array, element: Object, optional: true },
// search view description
searchViewArch: { type: String, optional: true },
searchViewFields: { type: Object, optional: true },
searchViewId: { type: [Number, Boolean], optional: true },
irFilters: { type: Array, element: Object, optional: true },
loadIrFilters: { type: Boolean, optional: true },
// extra options
activateFavorite: { type: Boolean, optional: true },
dynamicFilters: { type: Array, element: Object, optional: true },
hideCustomGroupBy: { type: Boolean, optional: true },
searchMenuTypes: { type: Array, element: String, optional: true },
};

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.WithSearch" owl="1">
<t t-slot="default"
context="searchModel.context"
domain="searchModel.domain"
groupBy="searchModel.groupBy"
orderBy="searchModel.orderBy"
comparison="searchModel.comparison"
display="searchModel.display"/>
</t>
</templates>