mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-20 11:12:05 +02:00
Initial commit: Core packages
This commit is contained in:
commit
12c29a983b
9512 changed files with 8379910 additions and 0 deletions
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
$o-control-panel-background-color: $o-view-background-color !default;
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 }
|
||||
);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
51
odoo-bringout-oca-ocb-web/web/static/src/search/layout.js
Normal file
51
odoo-bringout-oca-ocb-web/web/static/src/search/layout.js
Normal 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: {},
|
||||
};
|
||||
16
odoo-bringout-oca-ocb-web/web/static/src/search/layout.xml
Normal file
16
odoo-bringout-oca-ocb-web/web/static/src/search/layout.xml
Normal 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>
|
||||
|
|
@ -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 });
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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});
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
2132
odoo-bringout-oca-ocb-web/web/static/src/search/search_model.js
Normal file
2132
odoo-bringout-oca-ocb-web/web/static/src/search/search_model.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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";
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 && 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>
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
431
odoo-bringout-oca-ocb-web/web/static/src/search/utils/dates.js
Normal file
431
odoo-bringout-oca-ocb-web/web/static/src/search/utils/dates.js
Normal 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));
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
];
|
||||
|
|
@ -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 },
|
||||
};
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue