[REF] web: Control panel Owl
Refactoring of the control panel with the Owl framework.
The MVC structure of the control panel has been changed to a 'component <-> model'
relationship. As such:
- Removed the old control panel: view/model/controller/renderer
- Created a new Owl Component (ControlPanel)
- Created a new class: the Model (an Owl Store did not fit here) and a newly
designed ControlPanelModel extending it
Refactored all inner subcomponents, including:
- Pager
- ActionMenus (peviously SideBar)
- Filter menu
> Custom filter item
- Group by menu
> Custom group by item
> Removal of groupby_menu_mixin (and refactored graph controller in sibling commit)
- Time range menu
- Favorite menu
> Custom favorite item
> Moved the favorite menu registry to the favorite_menu file
- Search bar
> Removed and splitted auto-completion dropdown logic between the new SearchBar
Component (descriptions) and to the new ControlPanelModel (domains).
The templates (all in web/base.xml) have been adapted to the new standards. Most
of the DOM structure remains unchanged with some occasional improvements (replace
hiding with not rendering, removed pointless wrapper divs, etc.).
Part of task 2196029
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
This commit is contained in:
co-authored by
Mathieu Duckerts-Antoine
parent
0a129284e4
commit
fbf347498f
@@ -1,276 +0,0 @@
|
||||
odoo.define('web.Pager', function (require) {
|
||||
"use strict";
|
||||
|
||||
var utils = require('web.utils');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
var direction = {
|
||||
previous: -1,
|
||||
next: 1,
|
||||
};
|
||||
|
||||
var Pager = Widget.extend({
|
||||
template: "Pager",
|
||||
events: {
|
||||
'click .o_pager_next': '_onNext',
|
||||
'click .o_pager_previous': '_onPrevious',
|
||||
'click .o_pager_value': '_onEdit',
|
||||
},
|
||||
/**
|
||||
* The pager goes from 1 to size (included).
|
||||
* The current value is current_min if limit === 1
|
||||
* or the interval [current_min, current_min + limit[ if limit > 1
|
||||
*
|
||||
* @param {Widget} [parent] the parent widget
|
||||
* @param {int} [size] the total number of elements
|
||||
* @param {int} [current_min] the first element of the current_page
|
||||
* @param {int} [limit] the number of elements per page
|
||||
* @param {boolean} [options.can_edit] editable feature of the pager
|
||||
* @param {boolean} [options.single_page_hidden] (not) to display the pager
|
||||
* if only one page
|
||||
* @param {function} [options.validate] callback returning a Promise to
|
||||
* validate changes
|
||||
*/
|
||||
init: function (parent, size, current_min, limit, options) {
|
||||
this.state = {
|
||||
size: size,
|
||||
current_min: current_min,
|
||||
limit: limit,
|
||||
};
|
||||
Object.defineProperty(this.state, 'current_max', {
|
||||
get: function() {
|
||||
return Math.min(this.current_min + this.limit - 1, this.size);
|
||||
}
|
||||
});
|
||||
this.options = _.defaults({}, options, {
|
||||
can_edit: true, // editable
|
||||
single_page_hidden: false, // displayed even if there is a single page
|
||||
validate: function() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
withAccessKey: true, // can be disabled, for example, for x2m widgets
|
||||
});
|
||||
this._super(parent);
|
||||
},
|
||||
/**
|
||||
* Renders the pager
|
||||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
start: function () {
|
||||
this.$value = this.$('.o_pager_value');
|
||||
this.$limit = this.$('.o_pager_limit');
|
||||
this._render();
|
||||
return this._super();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Disables the pager's arrows and the edition
|
||||
*/
|
||||
disable: function () {
|
||||
this.disabled = true;
|
||||
this._updateArrows();
|
||||
},
|
||||
/**
|
||||
* Enables the pager's arrows and the edition
|
||||
*/
|
||||
enable: function () {
|
||||
this.disabled = false;
|
||||
this._updateArrows();
|
||||
},
|
||||
/**
|
||||
* Executes the next action on the pager
|
||||
*/
|
||||
next: function () {
|
||||
this._changeSelection(direction.next);
|
||||
},
|
||||
/**
|
||||
* Executes the previous action on the pager
|
||||
*/
|
||||
previous: function () {
|
||||
this._changeSelection(direction.previous);
|
||||
},
|
||||
/**
|
||||
* Sets the state of the pager and renders it
|
||||
* @param {Object} [state] the values to update (size, current_min and limit)
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.notifyChange] set to true to make the pager
|
||||
* notify the environment that its state changed
|
||||
*/
|
||||
updateState: function (state, options) {
|
||||
_.extend(this.state, state);
|
||||
this._render();
|
||||
if (options && options.notifyChange) {
|
||||
this.trigger('pager_changed', _.clone(this.state));
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Private function that updates the pager's state according to a pager action
|
||||
*
|
||||
* @param {int} [direction] the action (previous or next) on the pager
|
||||
*/
|
||||
_changeSelection: function (direction) {
|
||||
var self = this;
|
||||
this.options.validate().then(function() {
|
||||
var size = self.state.size;
|
||||
var current_min = self.state.current_min;
|
||||
var limit = self.state.limit;
|
||||
|
||||
// Compute the new current_min
|
||||
current_min = (current_min + limit*direction);
|
||||
if (current_min > size) {
|
||||
current_min = 1;
|
||||
} else if ((current_min < 1) && (limit === 1)) {
|
||||
current_min = size;
|
||||
} else if ((current_min < 1) && (limit > 1)) {
|
||||
current_min = size - ((size % limit) || limit) + 1;
|
||||
}
|
||||
|
||||
self.state.current_min = current_min;
|
||||
// The re-rendering of the pager must be done before the trigger of
|
||||
// event 'pager_changed' as the rendering may enable the pager
|
||||
// (and a common use is to disable the pager when this event is
|
||||
// triggered, and to re-enable it when the data have been reloaded)
|
||||
self._render();
|
||||
self.trigger('pager_changed', _.clone(self.state));
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Private function that displays an input to edit the pager's state
|
||||
*/
|
||||
_edit: function () {
|
||||
if (this.options.can_edit) {
|
||||
var self = this;
|
||||
var $input = $('<input>', {class: 'o_input', type: 'text', value: this.$value.html()});
|
||||
|
||||
this.$value.html($input);
|
||||
$input.focus();
|
||||
|
||||
// Event handlers
|
||||
$input.click(function(ev) {
|
||||
ev.stopPropagation(); // ignore clicks on the input
|
||||
});
|
||||
$input.blur(function(ev) {
|
||||
self._save($(ev.target)); // save the state when leaving the input
|
||||
});
|
||||
$input.on('keydown', function (ev) {
|
||||
ev.stopPropagation();
|
||||
if (ev.which === $.ui.keyCode.ENTER) {
|
||||
self._save($(ev.target)); // save on enter
|
||||
} else if (ev.which === $.ui.keyCode.ESCAPE) {
|
||||
self._render(); // leave on escape
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Private function that renders the pager's state
|
||||
*/
|
||||
_render: function () {
|
||||
var size = this.state.size;
|
||||
var current_min = this.state.current_min;
|
||||
var current_max = this.state.current_max;
|
||||
|
||||
if (size === 0 || (this.options.single_page_hidden && this._singlePage())) {
|
||||
this.do_hide();
|
||||
} else {
|
||||
this.do_show();
|
||||
this._updateArrows();
|
||||
|
||||
var value = "" + current_min;
|
||||
if (this.state.limit > 1) {
|
||||
value += "-" + current_max;
|
||||
}
|
||||
this.$value.html(value);
|
||||
this.$limit.html(size);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Private function that saves the state from the content of the input
|
||||
*
|
||||
* @param {jQuery} [$input] the jQuery element containing the new state
|
||||
*/
|
||||
_save: function ($input) {
|
||||
var self = this;
|
||||
this.options.validate().then(function() {
|
||||
var value = $input.val().split("-");
|
||||
var min = utils.confine(parseInt(value[0], 10), 1, self.state.size);
|
||||
var max = utils.confine(parseInt(value[1], 10), 1, self.state.size);
|
||||
|
||||
if (!isNaN(min)) {
|
||||
self.state.current_min = min;
|
||||
if (!isNaN(max)) {
|
||||
self.state.limit = utils.confine(max-min+1, 1, self.state.size);
|
||||
} else {
|
||||
// The state has been given as a single value -> set the limit to 1
|
||||
self.state.limit = 1;
|
||||
}
|
||||
self.trigger('pager_changed', _.clone(self.state));
|
||||
}
|
||||
// Render the pager's new state (removes the input)
|
||||
self._render();
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {boolean} true iff there is only one page
|
||||
*/
|
||||
_singlePage: function () {
|
||||
var state = this.state;
|
||||
return (1 === state.current_min) && (state.current_max === state.size);
|
||||
},
|
||||
/**
|
||||
* Updates the arrows' disable attribute: true iff the pager is disabed or
|
||||
* if there is only one page
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_updateArrows: function () {
|
||||
var disabled = this.disabled || this._singlePage();
|
||||
this.$('button').prop('disabled', disabled);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onEdit: function (event) {
|
||||
event.stopPropagation();
|
||||
if (!this.disabled) {
|
||||
this._edit();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onNext: function (event) {
|
||||
event.stopPropagation();
|
||||
this.next();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onPrevious: function (event) {
|
||||
event.stopPropagation();
|
||||
this.previous();
|
||||
},
|
||||
});
|
||||
|
||||
return Pager;
|
||||
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
odoo.define('web.Sidebar', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Context = require('web.Context');
|
||||
var core = require('web.core');
|
||||
var pyUtils = require('web.py_utils');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
var QWeb = core.qweb;
|
||||
var _t = core._t;
|
||||
|
||||
var Sidebar = Widget.extend({
|
||||
events: {
|
||||
"click a.dropdown-item": "_onDropdownClicked"
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {Object} options.items
|
||||
* @param {Object} options.sections
|
||||
* @param {Object} options.env
|
||||
* @param {Object} options.actions
|
||||
*
|
||||
*/
|
||||
init: function (parent, options) {
|
||||
this._super.apply(this, arguments);
|
||||
this.options = _.defaults(options || {}, {
|
||||
'editable': true
|
||||
});
|
||||
this.env = options.env;
|
||||
this.sections = options.sections || [
|
||||
{name: 'print', label: _t('Print')},
|
||||
{name: 'other', label: _t('Action')},
|
||||
];
|
||||
this.items = options.items || {
|
||||
print: [],
|
||||
other: [],
|
||||
};
|
||||
if (options.actions) {
|
||||
this._addToolbarActions(options.actions);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this.$el.addClass('btn-group');
|
||||
this._redraw();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update the env for the sidebar then rerender it.
|
||||
*
|
||||
* @param {Object} env
|
||||
*/
|
||||
updateEnv: function (env) {
|
||||
this.env = env;
|
||||
this._redraw();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* For each item added to the section:
|
||||
*
|
||||
* ``label``
|
||||
* will be used as the item's name in the sidebar, can be html
|
||||
*
|
||||
* ``action``
|
||||
* descriptor for the action which will be executed, ``action`` and
|
||||
* ``callback`` should be exclusive
|
||||
*
|
||||
* ``callback``
|
||||
* function to call when the item is clicked in the sidebar, called
|
||||
* with the item descriptor as its first argument (so information
|
||||
* can be stored as additional keys on the object passed to
|
||||
* ``_addItems``)
|
||||
*
|
||||
* ``classname`` (optional)
|
||||
* ``@class`` set on the sidebar serialization of the item
|
||||
*
|
||||
* ``title`` (optional)
|
||||
* will be set as the item's ``@title`` (tooltip)
|
||||
*
|
||||
* @private
|
||||
* @param {String} sectionCode
|
||||
* @param {Array<{label, action | callback[, classname][, title]}>} items
|
||||
*/
|
||||
_addItems: function (sectionCode, items) {
|
||||
if (items) {
|
||||
this.items[sectionCode].unshift.apply(this.items[sectionCode], items);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Method that will add the custom actions to the toolbar
|
||||
*
|
||||
* @private
|
||||
* @param {Object} toolbarActions
|
||||
*/
|
||||
_addToolbarActions: function (toolbarActions) {
|
||||
var self = this;
|
||||
_.each(['print','action','relate'], function (type) {
|
||||
if (type in toolbarActions) {
|
||||
var actions = toolbarActions[type];
|
||||
if (actions && actions.length) {
|
||||
var items = _.map(actions, function (action) {
|
||||
return {
|
||||
label: action.name,
|
||||
action: action,
|
||||
};
|
||||
});
|
||||
self._addItems(type === 'print' ? 'print' : 'other', items);
|
||||
}
|
||||
}
|
||||
});
|
||||
if ('other' in toolbarActions) {
|
||||
this._addItems('other', toolbarActions.other);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Performs the action for the item clicked after getting the data
|
||||
* necessary with a trigger up
|
||||
*
|
||||
* @private
|
||||
* @param {Object} item
|
||||
*/
|
||||
_onItemActionClicked: function (item) {
|
||||
var self = this;
|
||||
this.trigger_up('sidebar_data_asked', {
|
||||
callback: function (env) {
|
||||
self.env = env;
|
||||
var activeIdsContext = {
|
||||
active_id: env.activeIds[0],
|
||||
active_ids: env.activeIds,
|
||||
active_model: env.model,
|
||||
select_all: !!env.selectAll,
|
||||
};
|
||||
if (env.domain) {
|
||||
activeIdsContext.active_domain = env.domain;
|
||||
}
|
||||
|
||||
var context = pyUtils.eval('context', new Context(env.context, activeIdsContext));
|
||||
self._rpc({
|
||||
route: '/web/action/load',
|
||||
params: {
|
||||
action_id: item.action.id,
|
||||
context: context,
|
||||
},
|
||||
}).then(function (result) {
|
||||
result.context = new Context(
|
||||
result.context || {}, activeIdsContext)
|
||||
.set_eval_context(context);
|
||||
result.flags = result.flags || {};
|
||||
result.flags.new_window = true;
|
||||
self.do_action(result, {
|
||||
on_close: function () {
|
||||
self.trigger_up('reload');
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Method that renders the sidebar when there is a data update
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_redraw: function () {
|
||||
this.$el.html(QWeb.render('Sidebar', {widget: this}));
|
||||
|
||||
// Hides Sidebar sections when item list is empty
|
||||
_.each(this.$('.o_dropdown'), function (el) {
|
||||
var $dropdown = $(el);
|
||||
if (!$dropdown.find('.dropdown-item').length) {
|
||||
$dropdown.hide();
|
||||
}
|
||||
});
|
||||
this.$("[title]").tooltip({
|
||||
delay: { show: 500, hide: 0}
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Method triggered when the user clicks on a toolbar dropdown
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onDropdownClicked: function (event) {
|
||||
var section = $(event.currentTarget).data('section');
|
||||
var index = $(event.currentTarget).data('index');
|
||||
var item = this.items[section][index];
|
||||
if (item.callback) {
|
||||
item.callback.apply(this, [item]);
|
||||
} else if (item.action) {
|
||||
this._onItemActionClicked(item);
|
||||
} else if (item.url) {
|
||||
return true;
|
||||
}
|
||||
event.preventDefault();
|
||||
},
|
||||
});
|
||||
|
||||
return Sidebar;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
odoo.define('web.ActionMenus', function (require) {
|
||||
"use strict";
|
||||
|
||||
const Context = require('web.Context');
|
||||
const DropdownMenu = require('web.DropdownMenu');
|
||||
const pyUtils = require('web.py_utils');
|
||||
const Registry = require('web.Registry');
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
/**
|
||||
* 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 menus: Print and Action.
|
||||
*
|
||||
* This component also provides a registry to use custom components in the ActionMenus's
|
||||
* Action menu.
|
||||
* @extends Component
|
||||
*/
|
||||
class ActionMenus extends Component {
|
||||
|
||||
mounted() {
|
||||
this._addTooltips();
|
||||
}
|
||||
|
||||
patched() {
|
||||
this._addTooltips();
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
get actionItems() {
|
||||
// Callback based actions
|
||||
const callbackActions = (this.props.items.other || []).map(
|
||||
action => Object.assign({ key: `action-${action.description}` }, action)
|
||||
);
|
||||
// Action based actions
|
||||
const actionActions = this.props.items.action || [];
|
||||
const relateActions = this.props.items.relate || [];
|
||||
const formattedActions = [...actionActions, ...relateActions].map(
|
||||
action => ({ action, description: action.name, key: action.id })
|
||||
);
|
||||
// ActionMenus action registry components
|
||||
const registryActions = this.constructor.registry.values().map(
|
||||
({ Component, getProps }, index) => ({
|
||||
key: `registry-action-${index}`,
|
||||
Component,
|
||||
props: getProps(this.props),
|
||||
})
|
||||
);
|
||||
return [...callbackActions, ...formattedActions, ...registryActions];
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
get printItems() {
|
||||
const printActions = this.props.items.print || [];
|
||||
const printItems = printActions.map(
|
||||
action => ({ action, description: action.name, key: action.id })
|
||||
);
|
||||
return printItems;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add teh tooltips to the items
|
||||
* @private
|
||||
*/
|
||||
_addTooltips() {
|
||||
$(this.el.querySelectorAll('[title]')).tooltip({
|
||||
delay: { show: 500, hide: 0 }
|
||||
});
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Perform the action for the item clicked after getting the data
|
||||
* necessary with a trigger.
|
||||
* @private
|
||||
* @param {OwlEvent} ev
|
||||
*/
|
||||
async _executeAction(action) {
|
||||
const activeIdsContext = {
|
||||
active_id: this.props.activeIds[0],
|
||||
active_ids: this.props.activeIds,
|
||||
active_model: this.env.action.res_model,
|
||||
select_all: this.props.selectAll,
|
||||
};
|
||||
if (this.props.domain) {
|
||||
activeIdsContext.active_domain = this.props.domain;
|
||||
}
|
||||
|
||||
const context = pyUtils.eval('context', new Context(this.props.context, activeIdsContext));
|
||||
const result = await this.rpc({
|
||||
route: '/web/action/load',
|
||||
params: {
|
||||
action_id: action.id,
|
||||
context: context,
|
||||
},
|
||||
});
|
||||
result.context = new Context(result.context || {}, activeIdsContext)
|
||||
.set_eval_context(context);
|
||||
result.flags = result.flags || {};
|
||||
result.flags.new_window = true;
|
||||
this.trigger('do-action', {
|
||||
action: result,
|
||||
options: {
|
||||
on_close: () => this.trigger('reload'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {OwlEvent} ev
|
||||
*/
|
||||
_onItemSelected(ev) {
|
||||
ev.stopPropagation();
|
||||
const { item } = ev.detail;
|
||||
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.
|
||||
this.env.services.navigate(item.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ActionMenus.registry = new Registry();
|
||||
|
||||
ActionMenus.components = { DropdownMenu };
|
||||
ActionMenus.props = {
|
||||
activeIds: { type: Array, element: [Number|String] }, // virtual IDs are strings.
|
||||
context: Object,
|
||||
domain: { type: Array, optional: 1 },
|
||||
items: {
|
||||
type: Object,
|
||||
shape: {
|
||||
action: { type: Array, optional: 1 },
|
||||
print: { type: Array, optional: 1 },
|
||||
other: { type: Array, optional: 1 },
|
||||
},
|
||||
},
|
||||
selectAll: { type: Boolean, optional: 1 },
|
||||
};
|
||||
ActionMenus.template = 'web.ActionMenus';
|
||||
|
||||
return ActionMenus;
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
odoo.define('web.DatePickerOwl', function (require) {
|
||||
"use strict";
|
||||
|
||||
const field_utils = require('web.field_utils');
|
||||
const time = require('web.time');
|
||||
const { useAutofocus } = require('web.custom_hooks');
|
||||
|
||||
const { Component, hooks } = owl;
|
||||
const { useExternalListener, useRef, useState } = hooks;
|
||||
|
||||
let datePickerId = 0;
|
||||
|
||||
/**
|
||||
* Date picker
|
||||
*
|
||||
* This component exposes the API of the tempusdominus datepicker library.
|
||||
* As such, its template is a simple input that will open the TD datepicker
|
||||
* when clicked on. The component will also synchronize any user-input value
|
||||
* with the library widget and vice-vera.
|
||||
*
|
||||
* For further details regarding the implementation of the picker itself, please
|
||||
* refer to the official tempusdominus documentation (note: all props given
|
||||
* to this component will be passed as arguments to instantiate the picker widget).
|
||||
* @extends Component
|
||||
*/
|
||||
class DatePicker extends Component {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.inputRef = useRef('input');
|
||||
this.state = useState({ warning: false });
|
||||
|
||||
this.datePickerId = `o_datepicker_${datePickerId++}`;
|
||||
this.typeOfDate = 'date';
|
||||
|
||||
useAutofocus();
|
||||
useExternalListener(window, 'scroll', this._onWindowScroll);
|
||||
}
|
||||
|
||||
mounted() {
|
||||
$(this.el).on('show.datetimepicker', this._onDateTimePickerShow.bind(this));
|
||||
$(this.el).on('hide.datetimepicker', this._onDateTimePickerHide.bind(this));
|
||||
$(this.el).on('error.datetimepicker', () => false);
|
||||
|
||||
this._datetimepicker(this.props);
|
||||
this.inputRef.el.value = this._formatDate(this.props.date);
|
||||
}
|
||||
|
||||
willUnmount() {
|
||||
this._datetimepicker('destroy');
|
||||
}
|
||||
|
||||
willUpdateProps(nextProps) {
|
||||
if (nextProps.date) {
|
||||
this._datetimepicker('date', nextProps.date);
|
||||
this.inputRef.el.value = this._formatDate(nextProps.date);
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handle bootstrap datetimepicker calls.
|
||||
* @private
|
||||
* @param {...any} args anything that will be passed to the datetimepicker function.
|
||||
*/
|
||||
_datetimepicker(...args) {
|
||||
$(this.el).datetimepicker(...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {moment} date
|
||||
* @returns {string}
|
||||
*/
|
||||
_formatDate(date) {
|
||||
try {
|
||||
return field_utils.format[this.typeOfDate](date, null, { timezone: false });
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string|false} value
|
||||
* @returns {moment}
|
||||
*/
|
||||
_parseInput(inputValue) {
|
||||
try {
|
||||
return field_utils.parse[this.typeOfDate](inputValue, null, { timezone: false });
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reacts to the datetimepicker being hidden
|
||||
* Used to unbind the scroll event from the datetimepicker
|
||||
* @private
|
||||
*/
|
||||
_onDateTimePickerHide() {
|
||||
const date = this._parseInput(this.inputRef.el.value);
|
||||
this.state.warning = date.format('YYYY-MM-DD') > moment().format('YYYY-MM-DD');
|
||||
this.trigger('datetime-changed', { date });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reacts to the datetimepicker being shown
|
||||
* Could set/verify our widget value
|
||||
* And subsequently update the datetimepicker
|
||||
* @private
|
||||
*/
|
||||
_onDateTimePickerShow() {
|
||||
this.inputRef.el.select();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onInputClick() {
|
||||
this._datetimepicker('toggle');
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onInputChange() {
|
||||
const date = this._parseInput(this.inputRef.el.value);
|
||||
if (date) {
|
||||
this.state.warning = date.format('YYYY-MM-DD') > moment().format('YYYY-MM-DD');
|
||||
this.trigger('datetime-changed', { date });
|
||||
} else {
|
||||
this.inputRef.el.value = this._formatDate(this.props.date);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onWindowScroll(ev) {
|
||||
if (ev.target !== this.inputRef.el) {
|
||||
this._datetimepicker('hide');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DatePicker.defaultProps = {
|
||||
calendarWeeks: true,
|
||||
format: time.getLangDateFormat(),
|
||||
icons: {
|
||||
clear: 'fa fa-delete',
|
||||
close: 'fa fa-check primary',
|
||||
date: 'fa fa-calendar',
|
||||
down: 'fa fa-chevron-down',
|
||||
next: 'fa fa-chevron-right',
|
||||
previous: 'fa fa-chevron-left',
|
||||
time: 'fa fa-clock-o',
|
||||
today: 'fa fa-calendar-check-o',
|
||||
up: 'fa fa-chevron-up',
|
||||
},
|
||||
locale: moment.locale(),
|
||||
maxDate: moment({ y: 9999, M: 11, d: 31 }),
|
||||
minDate: moment({ y: 1900 }),
|
||||
useCurrent: false,
|
||||
widgetParent: 'body',
|
||||
};
|
||||
DatePicker.props = {
|
||||
// Actual date value
|
||||
date: moment,
|
||||
// Other props
|
||||
buttons: {
|
||||
type: Object,
|
||||
shape: {
|
||||
showClear: Boolean,
|
||||
showClose: Boolean,
|
||||
showToday: Boolean,
|
||||
},
|
||||
optional: 1,
|
||||
},
|
||||
calendarWeeks: Boolean,
|
||||
format: String,
|
||||
icons: {
|
||||
type: Object,
|
||||
shape: {
|
||||
clear: String,
|
||||
close: String,
|
||||
date: String,
|
||||
down: String,
|
||||
next: String,
|
||||
previous: String,
|
||||
time: String,
|
||||
today: String,
|
||||
up: String,
|
||||
},
|
||||
},
|
||||
keyBinds: { validate: kb => typeof kb === 'object' || kb === null, optional: 1 },
|
||||
locale: String,
|
||||
maxDate: moment,
|
||||
minDate: moment,
|
||||
readonly: { type: Boolean, optional: 1 },
|
||||
useCurrent: Boolean,
|
||||
widgetParent: String,
|
||||
};
|
||||
DatePicker.template = 'web.DatePicker';
|
||||
|
||||
/**
|
||||
* Date/time picker
|
||||
*
|
||||
* Similar to the DatePicker component, adding the handling of more specific
|
||||
* time values: hour-minute-second.
|
||||
*
|
||||
* Once again, refer to the tempusdominus documentation for implementation
|
||||
* details.
|
||||
* @extends DatePicker
|
||||
*/
|
||||
class DateTimePicker extends DatePicker {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.typeOfDate = 'datetime';
|
||||
}
|
||||
}
|
||||
|
||||
DateTimePicker.defaultProps = Object.assign({}, DatePicker.defaultProps, {
|
||||
buttons: {
|
||||
showClear: false,
|
||||
showClose: true,
|
||||
showToday: false,
|
||||
},
|
||||
format: time.getLangDatetimeFormat(),
|
||||
});
|
||||
|
||||
return {
|
||||
DatePicker,
|
||||
DateTimePicker,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
odoo.define('web.DropdownMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
const DropdownMenuItem = require('web.DropdownMenuItem');
|
||||
|
||||
const { Component, hooks } = owl;
|
||||
const { useExternalListener, useRef, useState } = hooks;
|
||||
|
||||
/**
|
||||
* Dropdown menu
|
||||
*
|
||||
* Generic component used to generate a list of interactive items. It uses some
|
||||
* bootstrap classes but most interactions are handled in here or in the dropdown
|
||||
* menu item class definition, including some keyboard navigation and escaping
|
||||
* system (click outside to close the dropdown).
|
||||
*
|
||||
* The layout of a dropdown menu is as following:
|
||||
* > a Button (always rendered) with a `title` and an optional `icon`;
|
||||
* > a Dropdown (rendered when open) containing a collection of given items.
|
||||
* These items must be objects and can have two shapes:
|
||||
* 1. item.Component & item.props > will instantiate the given Component with
|
||||
* the given props. Any additional key will be useless.
|
||||
* 2. any other shape > will instantiate a DropdownMenuItem with the item
|
||||
* object being its props. There is no props validation as this object
|
||||
* will be passed as-is when `selected` and can contain additional meta-keys
|
||||
* that will not affect the displayed item. For more information regarding
|
||||
* the behaviour of these items, @see DropdownMenuItem.
|
||||
* @extends Component
|
||||
*/
|
||||
class DropdownMenu extends Component {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.dropdownMenu = useRef('dropdown');
|
||||
this.state = useState({ open: false });
|
||||
|
||||
this.symbol = this.env.device.isMobile ? 'fa fa-chevron-right float-right mt4' : false;
|
||||
|
||||
useExternalListener(window, 'click', this._onWindowClick);
|
||||
useExternalListener(window, 'keydown', this._onWindowKeydown);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Overriden in case we want to keep the caret style on the button in mobile.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get displayCaret() {
|
||||
return !this.env.device.isMobile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Meant to be overriden to provide the list of items to display.
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
get items() {
|
||||
return this.props.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
get title() {
|
||||
return this.props.title;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
_onButtonKeydown(ev) {
|
||||
switch (ev.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowRight':
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
const firstItem = this.el.querySelector('.dropdown-item');
|
||||
if (firstItem) {
|
||||
ev.preventDefault();
|
||||
firstItem.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {OwlEvent} ev
|
||||
*/
|
||||
_onItemSelected(ev) { }
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onWindowClick(ev) {
|
||||
if (this.state.open && !this.el.contains(ev.target)) {
|
||||
this.state.open = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
_onWindowKeydown(ev) {
|
||||
if (this.state.open && ev.key === 'Escape') {
|
||||
this.state.open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu.components = { DropdownMenuItem };
|
||||
DropdownMenu.defaultProps = { items: [] };
|
||||
DropdownMenu.props = {
|
||||
icon: { type: String, optional: 1 },
|
||||
items: {
|
||||
type: Array,
|
||||
element: Object,
|
||||
optional: 1,
|
||||
},
|
||||
title: { type: String, optional: 1 },
|
||||
};
|
||||
DropdownMenu.template = 'web.DropdownMenu';
|
||||
|
||||
return DropdownMenu;
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
odoo.define('web.DropdownMenuItem', function (require) {
|
||||
"use strict";
|
||||
|
||||
const { useListener } = require('web.custom_hooks');
|
||||
|
||||
const { Component, hooks } = owl;
|
||||
const { useExternalListener, useRef, useState } = hooks;
|
||||
|
||||
/**
|
||||
* Dropdown menu item
|
||||
*
|
||||
* Generic component instantiated by a dropdown menu (@see DropdownMenu) in
|
||||
* the absence of `Component` and `props` keys in a given item object.
|
||||
*
|
||||
* In its simplest form, a dropdown menu item will be given a description (optional,
|
||||
* but highly recommended) and will trigger a 'select-item' when clicked on.
|
||||
* Additionaly it can receive the following props:
|
||||
* - isActive: will add a `checked` symbol on the left side of the item
|
||||
* - removable: will add a `remove` trash icon on the right side of the item.
|
||||
* when clicked, will trigger a 'remove-item' event.
|
||||
* - options: will change the behaviour of the item ; instead of triggering
|
||||
* an event, the item will act as a nested dropdown menu and display
|
||||
* its given options. These will have the same definition as another
|
||||
* dropdown item but cannot have options of their own.
|
||||
*
|
||||
* It is recommended to extend this class when defining a Component which will
|
||||
* be put inside of a dropdown menu (@see CustomFilterItem as example).
|
||||
* @extends Component
|
||||
*/
|
||||
class DropdownMenuItem extends Component {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.canBeOpened = Boolean(this.props.options && this.props.options.length);
|
||||
|
||||
this.fallbackFocusRef = useRef('fallback-focus');
|
||||
this.state = useState({ open: false });
|
||||
|
||||
useExternalListener(window, 'click', this._onWindowClick);
|
||||
useListener('keydown', this._onKeydown);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
_onKeydown(ev) {
|
||||
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) {
|
||||
return;
|
||||
}
|
||||
switch (ev.key) {
|
||||
case 'ArrowLeft':
|
||||
if (this.canBeOpened && this.state.open) {
|
||||
ev.preventDefault();
|
||||
if (this.fallbackFocusRef.el) {
|
||||
this.fallbackFocusRef.el.focus();
|
||||
}
|
||||
this.state.open = false;
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
if (this.canBeOpened && !this.state.open) {
|
||||
ev.preventDefault();
|
||||
this.state.open = true;
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
ev.target.blur();
|
||||
if (this.canBeOpened && this.state.open) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (this.fallbackFocusRef.el) {
|
||||
this.fallbackFocusRef.el.focus();
|
||||
}
|
||||
this.state.open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onWindowClick(ev) {
|
||||
if (this.state.open && !this.el.contains(ev.target)) {
|
||||
this.state.open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenuItem.template = 'web.DropdownMenuItem';
|
||||
|
||||
return DropdownMenuItem;
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
odoo.define('web.Pager', function (require) {
|
||||
"use strict";
|
||||
|
||||
const { useAutofocus } = require('web.custom_hooks');
|
||||
|
||||
const { Component, hooks } = owl;
|
||||
const { useState } = hooks;
|
||||
|
||||
/**
|
||||
* Pager
|
||||
*
|
||||
* The pager goes from 1 to size (included).
|
||||
* The current value is currentMinimum if limit === 1 or the interval:
|
||||
* [currentMinimum, currentMinimum + limit[ if limit > 1].
|
||||
* The value can be manually changed by clicking on the pager value and giving
|
||||
* an input matching the pattern: min[,max] (in which the comma can be a dash
|
||||
* or a semicolon).
|
||||
* The pager also provides two buttons to quickly change the current page (next
|
||||
* or previous).
|
||||
* @extends Component
|
||||
*/
|
||||
class Pager extends Component {
|
||||
/**
|
||||
* @param {Object} [props]
|
||||
* @param {int} [props.size] the total number of elements
|
||||
* @param {int} [props.currentMinimum] the first element of the current_page
|
||||
* @param {int} [props.limit] the number of elements per page
|
||||
* @param {boolean} [props.editable] editable feature of the pager
|
||||
* @param {function} [props.validate] callback returning a Promise to
|
||||
* validate changes
|
||||
* @param {boolean} [props.withAccessKey] can be disabled, for example,
|
||||
* for x2m widgets
|
||||
*/
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.state = useState({
|
||||
disabled: false,
|
||||
editing: false,
|
||||
});
|
||||
|
||||
useAutofocus();
|
||||
}
|
||||
|
||||
async willUpdateProps(nextProps) {
|
||||
this.state.editing = false;
|
||||
if (
|
||||
this.props.currentMinimum !== nextProps.currentMinimum ||
|
||||
this.props.limit !== nextProps.limit ||
|
||||
this.props.size !== nextProps.size
|
||||
) {
|
||||
this.state.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
get maximum() {
|
||||
return Math.min(this.props.currentMinimum + this.props.limit - 1, this.props.size);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean} true iff there is only one page
|
||||
*/
|
||||
get singlePage() {
|
||||
const { currentMinimum, size } = this.props;
|
||||
return (1 === currentMinimum) && (this.maximum === size);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
get value() {
|
||||
return this.props.currentMinimum + (this.props.limit > 1 ? `-${this.maximum}` : '');
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update the pager's state according to a pager action
|
||||
* @private
|
||||
* @param {number} [direction] the action (previous or next) on the pager
|
||||
*/
|
||||
async _changeSelection(direction) {
|
||||
try {
|
||||
await this.props.validate();
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
const { limit, size } = this.props;
|
||||
|
||||
// Compute the new currentMinimum
|
||||
let currentMinimum = (this.props.currentMinimum + limit * direction);
|
||||
if (currentMinimum > size) {
|
||||
currentMinimum = 1;
|
||||
} else if ((currentMinimum < 1) && (limit === 1)) {
|
||||
currentMinimum = size;
|
||||
} else if ((currentMinimum < 1) && (limit > 1)) {
|
||||
currentMinimum = size - ((size % limit) || limit) + 1;
|
||||
}
|
||||
|
||||
// The re-rendering of the pager must be done before the trigger of
|
||||
// event 'pager-changed' as the rendering may enable the pager
|
||||
// (and a common use is to disable the pager when this event is
|
||||
// triggered, and to re-enable it when the data have been reloaded).
|
||||
this._updateAndDisable(currentMinimum, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the state from the content of the input
|
||||
* @private
|
||||
* @param {string} value the new raw pager value
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async _saveValue(value) {
|
||||
try {
|
||||
await this.props.validate();
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
const [min, max] = value.trim().split(/\s*[\-\s,;]\s*/);
|
||||
|
||||
let currentMinimum = Math.max(Math.min(parseInt(min, 10), this.props.size), 1);
|
||||
let maximum = max ? Math.max(Math.min(parseInt(max, 10), this.props.size), 1) : min;
|
||||
|
||||
if (
|
||||
!isNaN(currentMinimum) &&
|
||||
!isNaN(maximum) &&
|
||||
currentMinimum <= maximum
|
||||
) {
|
||||
const limit = Math.max(maximum - currentMinimum) + 1;
|
||||
this._updateAndDisable(currentMinimum, limit);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} payload
|
||||
*/
|
||||
_updateAndDisable(currentMinimum, limit) {
|
||||
if (
|
||||
currentMinimum !== this.props.currentMinimum ||
|
||||
limit !== this.props.limit
|
||||
) {
|
||||
this.state.disabled = true;
|
||||
this.trigger('pager-changed', { currentMinimum, limit });
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onEdit() {
|
||||
if (
|
||||
this.props.editable && // editable
|
||||
!this.state.editing && // not already editing
|
||||
!this.state.disabled // not being changed already
|
||||
) {
|
||||
this.state.editing = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {InputEvent} ev
|
||||
*/
|
||||
_onValueChange(ev) {
|
||||
this._saveValue(ev.currentTarget.value);
|
||||
if (!this.state.disabled) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
_onValueKeydown(ev) {
|
||||
switch (ev.key) {
|
||||
case 'Enter':
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this._saveValue(ev.currentTarget.value);
|
||||
break;
|
||||
case 'Escape':
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.state.editing = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Pager.defaultProps = {
|
||||
editable: true,
|
||||
validate: async () => { },
|
||||
withAccessKey: true,
|
||||
};
|
||||
Pager.props = {
|
||||
currentMinimum: { type: Number, optional: 1 },
|
||||
editable: Boolean,
|
||||
limit: { validate: l => !isNaN(l), optional: 1 },
|
||||
size: { type: Number, optional: 1 },
|
||||
validate: Function,
|
||||
withAccessKey: Boolean,
|
||||
};
|
||||
Pager.template = 'web.Pager';
|
||||
|
||||
return Pager;
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
odoo.define('web.ControlPanel', function (require) {
|
||||
"use strict";
|
||||
|
||||
const ActionMenus = require('web.ActionMenus');
|
||||
const ControlPanelModel = require('web.ControlPanelModel');
|
||||
const FavoriteMenu = require('web.FavoriteMenu');
|
||||
const FilterMenu = require('web.FilterMenu');
|
||||
const GroupByMenu = require('web.GroupByMenu');
|
||||
const Pager = require('web.Pager');
|
||||
const SearchBar = require('web.SearchBar');
|
||||
const TimeRangeMenu = require('web.TimeRangeMenu');
|
||||
const { useModel } = require('web.model');
|
||||
|
||||
const { Component, hooks } = owl;
|
||||
const { useRef, useState, useSubEnv } = hooks;
|
||||
|
||||
/**
|
||||
* TODO: remove this whole mechanism as soon as `cp_content` is completely removed.
|
||||
* Extract the 'cp_content' key of the given props and return them as well as
|
||||
* the extracted content.
|
||||
* @param {Object} props
|
||||
* @returns {Object}
|
||||
*/
|
||||
function getAdditionalContent(props) {
|
||||
const additionalContent = {};
|
||||
if ('cp_content' in props) {
|
||||
const content = props.cp_content || {};
|
||||
if ('$buttons' in content) {
|
||||
additionalContent.buttons = content.$buttons;
|
||||
}
|
||||
if ('$searchview' in content) {
|
||||
additionalContent.searchView = content.$searchview;
|
||||
}
|
||||
if ('$pager' in content) {
|
||||
additionalContent.pager = content.$pager;
|
||||
}
|
||||
if ('$searchview_buttons' in content) {
|
||||
additionalContent.searchViewButtons = content.$searchview_buttons;
|
||||
}
|
||||
}
|
||||
return additionalContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Control panel
|
||||
*
|
||||
* The control panel of the action|view. In its standard form, it is composed of
|
||||
* several sections/subcomponents. Here is a simplified graph representing the
|
||||
* action|view and its control panel:
|
||||
*
|
||||
* ┌ View Controller | Action ----------------------------------------------------------┐
|
||||
* | ┌ Control Panel ──────────────┬──────────────────────────────────────────────────┐ |
|
||||
* | │ ┌ Breadcrumbs ────────────┐ │ ┌ Search View ─────────────────────────────────┐ │ |
|
||||
* | │ │ [1] / [2] │ │ │ [3] [ ============== 4 =============== ] [5] │ │ |
|
||||
* | │ └─────────────────────────┘ │ └──────────────────────────────────────────────┘ │ |
|
||||
* | ├─────────────────────────────┼──────────────────────────────────────────────────┤ |
|
||||
* | │ ┌ Buttons ┐ ┌ ActionMenus ┐ │ ┌ Search Menus ─────┐ ┌ Pager ┐┌ View switcher ┐ │ |
|
||||
* | │ │ [6] │ │ [7] │ │ │ [8] [9] [10] [11] │ │ [12] ││ [13] │ │ |
|
||||
* | │ └─────────┘ └─────────────┘ │ └───────────────────┘ └───────┘└───────────────┘ │ |
|
||||
* | └─────────────────────────────┴──────────────────────────────────────────────────┘ |
|
||||
* | ┌ View Renderer | Action content ────────────────────────────────────────────────┐ |
|
||||
* | │ │ |
|
||||
* | │ ... │ |
|
||||
* | │ │ |
|
||||
* | │ │ |
|
||||
* | │ │ |
|
||||
* | └────────────────────────────────────────────────────────────────────────────────┘ |
|
||||
* └------------------------------------------------------------------------------------┘
|
||||
*
|
||||
* 1. Breadcrumbs: list of links composed by the `props.breadcrumbs` collection.
|
||||
* 2. Title: the title of the action|view. Can be empty and will yield 'Unnamed'.
|
||||
* 3. Search facets: a collection of facet components generated by the `ControlPanelModel`
|
||||
* and handled by the `SearchBar` component. @see SearchFacet
|
||||
* 4. SearchBar: @see SearchBar
|
||||
* 5. Advanced search: small buttons used to toggle the [Search Menus] section.
|
||||
* Will not be displayed if the allowed `props.searchMenuTypes` list is empty.
|
||||
* 6. Buttons: section in which the action|controller is meant to inject its control
|
||||
* buttons. The template provides a slot for this purpose.
|
||||
* 7. Action menus: @see ActionMenus
|
||||
* 8. Filter menu: @see FilterMenu
|
||||
* 9. Group by menu: @see GroupByMenu
|
||||
* 10. Time range menu: @see TimeRangeMenu
|
||||
* 11. Favorite menu: @see FavoriteMenu
|
||||
* 12. Pager: @see Pager
|
||||
* 13. View switcher buttons: list of buttons composed by the `props.views` collection.
|
||||
*
|
||||
* Subcomponents (especially in the [Search Menus] section) will call
|
||||
* the ControlPanelModel to get processed information about the current view|action.
|
||||
* @see ControlPanelModel for more details.
|
||||
*
|
||||
* Note: an additional temporary (and ugly) mechanic allows to inject a jQuery element
|
||||
* given in `props.cp_content` in a related section:
|
||||
* $buttons -> [Buttons]
|
||||
* $searchview -> [Search View]
|
||||
* $searchview_buttons -> [Search Menus]
|
||||
* $pager -> [Pager]
|
||||
* This system must be replaced by proper slot usage and the static template
|
||||
* inheritance mechanism when converting the views/actions.
|
||||
* @extends Component
|
||||
*/
|
||||
class ControlPanel extends Component {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.additionalContent = getAdditionalContent(this.props);
|
||||
|
||||
useSubEnv({
|
||||
action: this.props.action,
|
||||
controlPanelModel: this.props.controlPanelModel,
|
||||
view: this.props.view,
|
||||
});
|
||||
|
||||
// Connect to the model
|
||||
// TODO: move this in enterprise whenever possible
|
||||
if (this.env.controlPanelModel) {
|
||||
this.model = useModel('controlPanelModel');
|
||||
}
|
||||
this.state = useState({
|
||||
displayDropdowns: true,
|
||||
});
|
||||
|
||||
// Reference hooks
|
||||
this.contentRefs = {
|
||||
buttons: useRef('buttons'),
|
||||
pager: useRef('pager'),
|
||||
searchView: useRef('searchView'),
|
||||
searchViewButtons: useRef('searchViewButtons'),
|
||||
};
|
||||
|
||||
this.fields = this._formatFields(this.props.fields);
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this._attachAdditionalContent();
|
||||
}
|
||||
|
||||
patched() {
|
||||
this._attachAdditionalContent();
|
||||
}
|
||||
|
||||
async willUpdateProps(nextProps) {
|
||||
// Note: action and controlPanelModel are not likely to change during
|
||||
// the lifespan of a ControlPanel instance, so we only need to update
|
||||
// the view information.
|
||||
if ('view' in nextProps) {
|
||||
this.env.view = nextProps.view;
|
||||
}
|
||||
if ('fields' in nextProps) {
|
||||
this.fields = this._formatFields(nextProps.fields);
|
||||
}
|
||||
this.additionalContent = getAdditionalContent(nextProps);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Attach additional content extracted from the props 'cp_content' key, if any.
|
||||
* @private
|
||||
*/
|
||||
_attachAdditionalContent() {
|
||||
for (const key in this.additionalContent) {
|
||||
if (this.additionalContent[key] && this.additionalContent[key].length) {
|
||||
const target = this.contentRefs[key].el;
|
||||
if (target) {
|
||||
target.innerHTML = "";
|
||||
target.append(...this.additionalContent[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Give `name` and `description` keys to the fields given to the control
|
||||
* panel.
|
||||
* @private
|
||||
* @param {Object} fields
|
||||
* @returns {Object}
|
||||
*/
|
||||
_formatFields(fields) {
|
||||
const formattedFields = {};
|
||||
for (const fieldName in fields) {
|
||||
formattedFields[fieldName] = Object.assign({
|
||||
description: fields[fieldName].string,
|
||||
name: fieldName,
|
||||
}, fields[fieldName]);
|
||||
}
|
||||
return formattedFields;
|
||||
}
|
||||
}
|
||||
|
||||
ControlPanel.components = {
|
||||
SearchBar,
|
||||
ActionMenus, Pager,
|
||||
FilterMenu, GroupByMenu, TimeRangeMenu, FavoriteMenu,
|
||||
};
|
||||
ControlPanel.defaultProps = {
|
||||
breadcrumbs: [],
|
||||
fields: {},
|
||||
searchMenuTypes: [],
|
||||
views: [],
|
||||
withBreadcrumbs: true,
|
||||
withSearchBar: true,
|
||||
};
|
||||
ControlPanel.props = {
|
||||
action: Object,
|
||||
breadcrumbs: Array,
|
||||
controlPanelModel: ControlPanelModel,
|
||||
cp_content: { type: Object, optional: 1 },
|
||||
fields: Object,
|
||||
pager: { validate: p => typeof p === 'object' || p === null, optional: 1 },
|
||||
searchMenuTypes: Array,
|
||||
actionMenus: { validate: s => typeof s === 'object' || s === null, optional: 1 },
|
||||
title: { type: String, optional: 1 },
|
||||
view: { type: Object, optional: 1 },
|
||||
views: Array,
|
||||
withBreadcrumbs: Boolean,
|
||||
withSearchBar: Boolean,
|
||||
};
|
||||
|
||||
ControlPanel.template = 'web.ControlPanel';
|
||||
|
||||
return ControlPanel;
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
odoo.define('web.ControlPanelX2Many', function (require) {
|
||||
|
||||
const ControlPanel = require('web.ControlPanel');
|
||||
|
||||
/**
|
||||
* Control panel (adaptation for x2many fields)
|
||||
*
|
||||
* Smaller version of the control panel with an abridged template (buttons and
|
||||
* pager only). We still extend the main version for the injection of `cp_content`
|
||||
* keys.
|
||||
* The pager of this control panel is only displayed if the amount of records
|
||||
* cannot be displayed in a single page.
|
||||
* @extends ControlPanel
|
||||
*/
|
||||
class ControlPanelX2Many extends ControlPanel {
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_shouldShowPager() {
|
||||
if (!this.props.pager || !this.props.pager.limit) {
|
||||
return false;
|
||||
}
|
||||
const { currentMinimum, limit, size } = this.props.pager;
|
||||
const maximum = Math.min(currentMinimum + limit - 1, size);
|
||||
const singlePage = (1 === currentMinimum) && (maximum === size);
|
||||
return !singlePage;
|
||||
}
|
||||
}
|
||||
|
||||
ControlPanelX2Many.defaultProps = {};
|
||||
ControlPanelX2Many.props = {
|
||||
cp_content: { type: Object, optional: 1 },
|
||||
pager: Object,
|
||||
};
|
||||
ControlPanelX2Many.template = 'web.ControlPanelX2Many';
|
||||
|
||||
return ControlPanelX2Many;
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
odoo.define('web.CustomFavoriteItem', function (require) {
|
||||
"use strict";
|
||||
|
||||
const DropdownMenuItem = require('web.DropdownMenuItem');
|
||||
const FavoriteMenu = require('web.FavoriteMenu');
|
||||
const { useAutofocus } = require('web.custom_hooks');
|
||||
const { useModel } = require('web.model');
|
||||
|
||||
const { useRef } = owl.hooks;
|
||||
|
||||
let favoriteId = 0;
|
||||
|
||||
/**
|
||||
* Favorite generator menu
|
||||
*
|
||||
* This component is used to add a new favorite linked which will take every
|
||||
* information out of the current context and save it to a new `ir.filter`.
|
||||
*
|
||||
* There are 3 additional inputs to modify the filter:
|
||||
* - a text input (mandatory): the name of the favorite (must be unique)
|
||||
* - 'use by default' checkbox: if checked, the favorite will be the default
|
||||
* filter of the current model (and will bypass
|
||||
* any existing default filter). Cannot be checked
|
||||
* along with 'share with all users' checkbox.
|
||||
* - 'share with all users' checkbox: if checked, the favorite will be available
|
||||
* with all users instead of the current
|
||||
* one.Cannot be checked along with 'use
|
||||
* by default' checkbox.
|
||||
* Finally, there is a 'Save' button used to apply the current configuration
|
||||
* and save the context to a new filter.
|
||||
* @extends DropdownMenuItem
|
||||
*/
|
||||
class CustomFavoriteItem extends DropdownMenuItem {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
const favId = favoriteId++;
|
||||
this.useByDefaultId = `o_favorite_use_by_default_${favId}`;
|
||||
this.shareAllUsersId = `o_favorite_share_all_users_${favId}`;
|
||||
|
||||
this.descriptionRef = useRef('description');
|
||||
this.model = useModel('controlPanelModel');
|
||||
this.interactive = true;
|
||||
Object.assign(this.state, {
|
||||
description: this.env.action.name || "",
|
||||
isDefault: false,
|
||||
isShared: false,
|
||||
});
|
||||
|
||||
useAutofocus();
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_saveFavorite() {
|
||||
if (!this.state.description.length) {
|
||||
this.env.services.notification.notify({
|
||||
title: this.env._t("Error"),
|
||||
message: this.env._t("A name for your favorite filter is required."),
|
||||
type: 'danger',
|
||||
});
|
||||
return this.descriptionRef.el.focus();
|
||||
}
|
||||
const favorites = this.model.getFiltersOfType('favorite');
|
||||
if (favorites.some(f => f.description === this.state.description)) {
|
||||
this.env.services.notification.notify({
|
||||
title: this.env._t("Error"),
|
||||
message: this.env._t("Filter with same name already exists."),
|
||||
type: 'danger',
|
||||
});
|
||||
return this.descriptionRef.el.focus();
|
||||
}
|
||||
this.model.dispatch('createNewFavorite', {
|
||||
type: 'favorite',
|
||||
description: this.state.description,
|
||||
isDefault: this.state.isDefault,
|
||||
isShared: this.state.isShared,
|
||||
});
|
||||
// Reset state
|
||||
Object.assign(this.state, {
|
||||
description: this.env.action.name || "",
|
||||
isDefault: false,
|
||||
isShared: false,
|
||||
open: false,
|
||||
});
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Event} ev change Event
|
||||
*/
|
||||
_onCheckboxChange(ev) {
|
||||
const { checked, id } = ev.target;
|
||||
if (this.useByDefaultId === id) {
|
||||
this.state.isDefault = checked;
|
||||
if (checked) {
|
||||
this.state.isShared = false;
|
||||
}
|
||||
} else {
|
||||
this.state.isShared = checked;
|
||||
if (checked) {
|
||||
this.state.isDefault = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {jQueryEvent} ev
|
||||
*/
|
||||
_onInputKeydown(ev) {
|
||||
switch (ev.key) {
|
||||
case 'Enter':
|
||||
ev.preventDefault();
|
||||
this._saveFavorite();
|
||||
break;
|
||||
case 'Escape':
|
||||
// Gives the focus back to the component.
|
||||
ev.preventDefault();
|
||||
ev.target.blur();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Static
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Object} env
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static shouldBeDisplayed(env) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
CustomFavoriteItem.props = {};
|
||||
CustomFavoriteItem.template = 'web.CustomFavoriteItem';
|
||||
|
||||
FavoriteMenu.registry.add('favorite-generator-menu', CustomFavoriteItem, 0);
|
||||
|
||||
return CustomFavoriteItem;
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
odoo.define('web.CustomFilterItem', function (require) {
|
||||
"use strict";
|
||||
|
||||
const { DatePicker, DateTimePicker } = require('web.DatePickerOwl');
|
||||
const Domain = require('web.Domain');
|
||||
const DropdownMenuItem = require('web.DropdownMenuItem');
|
||||
const { FIELD_OPERATORS, FIELD_TYPES } = require('web.searchUtils');
|
||||
const field_utils = require('web.field_utils');
|
||||
const { useModel } = require('web.model');
|
||||
|
||||
/**
|
||||
* Filter generator menu
|
||||
*
|
||||
* Component which purpose is to generate new filters from a set of inputs.
|
||||
* It is made of one or several `condition` objects that will be used to generate
|
||||
* filters which will be added in the filter menu. Each condition is composed
|
||||
* of 2, 3 or 4 inputs:
|
||||
*
|
||||
* 1. FIELD (select): the field used to form the base of the domain condition;
|
||||
*
|
||||
* 2. OPERATOR (select): the symbol determining the operator(s) of the domain
|
||||
* condition, linking the field to one or several value(s).
|
||||
* Some operators can have pre-defined values that will replace user inputs.
|
||||
* @see searchUtils for the list of operators.
|
||||
*
|
||||
* 3. [VALUE] (input|select): the value of the domain condition. it will be parsed
|
||||
* according to the selected field's type. Note that
|
||||
* it is optional as some operators have defined values.
|
||||
* The generated condition domain will be as following:
|
||||
* [
|
||||
* [field, operator, (operator_value|input_value)]
|
||||
* ]
|
||||
*
|
||||
* 4. [VALUE] (input): for now, only date-typed fields with the 'between' operator
|
||||
* allow for a second value. The given input values will then
|
||||
* be taken as the borders of the date range (between x and y)
|
||||
* and will be translated as the following domain form:
|
||||
* [
|
||||
* [date_field, '>=', x],
|
||||
* [date_field, '<=', y],
|
||||
* ]
|
||||
* @extends DropdownMenuItem
|
||||
*/
|
||||
class CustomFilterItem extends DropdownMenuItem {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.model = useModel('controlPanelModel');
|
||||
|
||||
this.canBeOpened = true;
|
||||
this.state.conditions = [];
|
||||
// Format, filter and sort the fields props
|
||||
this.fields = Object.values(this.props.fields)
|
||||
.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.DECIMAL_POINT = this.env._t.database.parameters.decimal_point;
|
||||
this.OPERATORS = FIELD_OPERATORS;
|
||||
this.FIELD_TYPES = FIELD_TYPES;
|
||||
|
||||
// Add default empty condition
|
||||
this._addDefaultCondition();
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Populate the conditions list with a default condition having as properties:
|
||||
* - the first available field
|
||||
* - the first available operator
|
||||
* - a null or empty array value
|
||||
* @private
|
||||
*/
|
||||
_addDefaultCondition() {
|
||||
const condition = {
|
||||
field: 0,
|
||||
operator: 0,
|
||||
};
|
||||
this._setDefaultValue(condition);
|
||||
this.state.conditions.push(condition);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} field
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_validateField(field) {
|
||||
return !field.deprecated &&
|
||||
field.searchable &&
|
||||
FIELD_TYPES[field.type] &&
|
||||
field.name !== 'id';
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} condition
|
||||
*/
|
||||
_setDefaultValue(condition) {
|
||||
const fieldType = this.fields[condition.field].type;
|
||||
const genericType = FIELD_TYPES[fieldType];
|
||||
const operator = FIELD_OPERATORS[genericType][condition.operator];
|
||||
switch (genericType) {
|
||||
case 'id':
|
||||
case 'number':
|
||||
condition.value = 0;
|
||||
break;
|
||||
case 'date':
|
||||
condition.value = [moment()];
|
||||
if (operator.symbol === 'between') {
|
||||
condition.value.push(moment());
|
||||
}
|
||||
break;
|
||||
case 'datetime':
|
||||
condition.value = [moment('00:00:00', 'hh:mm:ss')];
|
||||
if (operator.symbol === 'between') {
|
||||
condition.value.push(moment('23:59:59', 'hh:mm:ss'));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
condition.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert all conditions to prefilters.
|
||||
* @private
|
||||
*/
|
||||
_onApply() {
|
||||
const preFilters = this.state.conditions.map(condition => {
|
||||
const field = this.fields[condition.field];
|
||||
const type = this.FIELD_TYPES[field.type];
|
||||
const operator = this.OPERATORS[type][condition.operator];
|
||||
const descriptionArray = [field.string, operator.description];
|
||||
const domainArray = [];
|
||||
let domainValue;
|
||||
// Field type specifics
|
||||
if ('value' in operator) {
|
||||
domainValue = [operator.value];
|
||||
// No description to push here
|
||||
} else if (['date', 'datetime'].includes(type)) {
|
||||
domainValue = condition.value.map(
|
||||
val => field_utils.parse[type](val, { type }, { timezone: true })
|
||||
);
|
||||
const dateValue = condition.value.map(
|
||||
val => field_utils.format[type](val, { type }, { timezone: false })
|
||||
);
|
||||
descriptionArray.push(`"${dateValue.join(" " + this.env._t("and") + " ")}"`);
|
||||
} else {
|
||||
domainValue = [condition.value];
|
||||
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: Domain.prototype.arrayToString(domainArray),
|
||||
type: 'filter',
|
||||
};
|
||||
return preFilter;
|
||||
});
|
||||
|
||||
this.model.dispatch('createNewFilters', preFilters);
|
||||
|
||||
// Reset state
|
||||
this.state.open = false;
|
||||
this.state.conditions = [];
|
||||
this._addDefaultCondition();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} condition
|
||||
* @param {number} valueIndex
|
||||
* @param {OwlEvent} ev
|
||||
*/
|
||||
_onDateChanged(condition, valueIndex, ev) {
|
||||
condition.value[valueIndex] = ev.detail.date;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} condition
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onFieldSelect(condition, ev) {
|
||||
Object.assign(condition, {
|
||||
field: ev.target.selectedIndex,
|
||||
operator: 0,
|
||||
});
|
||||
this._setDefaultValue(condition);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} condition
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onOperatorSelect(condition, ev) {
|
||||
condition.operator = ev.target.selectedIndex;
|
||||
this._setDefaultValue(condition);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} condition
|
||||
*/
|
||||
_onRemoveCondition(conditionIndex) {
|
||||
this.state.conditions.splice(conditionIndex, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} condition
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onValueInput(condition, ev) {
|
||||
const type = this.fields[condition.field].type;
|
||||
if (['float', 'integer', 'id'].includes(type)) {
|
||||
const previousValue = condition.value;
|
||||
const parser = field_utils.parse[type === 'float' ? 'float' : 'integer'];
|
||||
try {
|
||||
const parsed = parser(ev.target.value || 0);
|
||||
// Force parsed value in the input.
|
||||
ev.target.value = condition.value = (parsed || 0);
|
||||
} catch (err) {
|
||||
// Force previous value if non-parseable.
|
||||
ev.target.value = previousValue || 0;
|
||||
}
|
||||
} else {
|
||||
condition.value = ev.target.value || "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomFilterItem.components = { DatePicker, DateTimePicker };
|
||||
CustomFilterItem.props = {
|
||||
fields: Object,
|
||||
};
|
||||
CustomFilterItem.template = 'web.CustomFilterItem';
|
||||
|
||||
return CustomFilterItem;
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
odoo.define('web.CustomGroupByItem', function (require) {
|
||||
"use strict";
|
||||
|
||||
const DropdownMenuItem = require('web.DropdownMenuItem');
|
||||
const { useModel } = require('web.model');
|
||||
|
||||
/**
|
||||
* Group by generator menu
|
||||
*
|
||||
* Component used to generate new filters of type 'groupBy'. It is composed
|
||||
* of a button (used to toggle the rendering of the rest of the component) and
|
||||
* an input (select) used to choose a new field name which will be used as a
|
||||
* new groupBy value.
|
||||
* @extends DropdownMenuItem
|
||||
*/
|
||||
class CustomGroupByItem extends DropdownMenuItem {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.canBeOpened = true;
|
||||
this.state.fieldName = this.props.fields[0].name;
|
||||
|
||||
this.model = useModel('controlPanelModel');
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onApply() {
|
||||
const field = this.props.fields.find(f => f.name === this.state.fieldName);
|
||||
this.model.dispatch('createNewGroupBy', field);
|
||||
this.state.open = false;
|
||||
}
|
||||
}
|
||||
|
||||
CustomGroupByItem.template = 'web.CustomGroupByItem';
|
||||
CustomGroupByItem.props = {
|
||||
fields: Array,
|
||||
};
|
||||
|
||||
return CustomGroupByItem;
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
odoo.define('web.FavoriteMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
const Dialog = require('web.OwlDialog');
|
||||
const DropdownMenu = require('web.DropdownMenu');
|
||||
const Registry = require('web.Registry');
|
||||
const { useModel } = require('web.model');
|
||||
|
||||
/**
|
||||
* 'Favorites' menu
|
||||
*
|
||||
* Simple rendering of the filters of type `favorites` given by the control panel
|
||||
* model. It uses most of the behaviours implemented by the dropdown menu Component,
|
||||
* with the addition of a submenu registry used to display additional components.
|
||||
* Only the favorite generator (@see CustomFavoriteItem) is registered in
|
||||
* the `web` module.
|
||||
* @see DropdownMenu for additional details.
|
||||
* @extends DropdownMenu
|
||||
*/
|
||||
class FavoriteMenu extends DropdownMenu {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.model = useModel('controlPanelModel');
|
||||
this.state.deletedFavorite = false;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get items() {
|
||||
const favorites = this.model.getFiltersOfType('favorite');
|
||||
const registryMenus = this.constructor.registry.values().reduce(
|
||||
(menus, Component) => {
|
||||
if (Component.shouldBeDisplayed(this.env)) {
|
||||
menus.push({
|
||||
key: Component.name,
|
||||
Component,
|
||||
});
|
||||
}
|
||||
return menus;
|
||||
},
|
||||
[]
|
||||
);
|
||||
return [...favorites, ...registryMenus];
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get title() {
|
||||
return this.env._t("Favorites");
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {OwlEvent} ev
|
||||
*/
|
||||
_onItemRemoved(ev) {
|
||||
const favorite = this.items.find(fav => fav.id === ev.detail.item.id);
|
||||
this.state.deletedFavorite = favorite;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {OwlEvent} ev
|
||||
*/
|
||||
_onItemSelected(ev) {
|
||||
ev.stopPropagation();
|
||||
this.model.dispatch('toggleFilter', ev.detail.item.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
async _onRemoveFavorite() {
|
||||
await this.model.dispatch('deleteFavorite', this.state.deletedFavorite.id);
|
||||
this.state.deletedFavorite = false;
|
||||
}
|
||||
}
|
||||
|
||||
FavoriteMenu.registry = new Registry();
|
||||
|
||||
FavoriteMenu.components = Object.assign({}, DropdownMenu.components, {
|
||||
Dialog,
|
||||
});
|
||||
FavoriteMenu.defaultProps = Object.assign({}, DropdownMenu.defaultProps, {
|
||||
icon: 'fa fa-star',
|
||||
});
|
||||
FavoriteMenu.template = 'web.FavoriteMenu';
|
||||
|
||||
return FavoriteMenu;
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
odoo.define('web.FilterMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
const DropdownMenu = require('web.DropdownMenu');
|
||||
const CustomFilterItem = require('web.CustomFilterItem');
|
||||
const { useModel } = require('web.model');
|
||||
|
||||
/**
|
||||
* 'Filters' menu
|
||||
*
|
||||
* Simple rendering of the filters of type `filter` given by the control panel
|
||||
* model. It uses most of the behaviours implemented by the dropdown menu Component,
|
||||
* with the addition of a filter generator (@see CustomFilterItem).
|
||||
* @see DropdownMenu for additional details.
|
||||
* @extends DropdownMenu
|
||||
*/
|
||||
class FilterMenu extends DropdownMenu {
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.model = useModel('controlPanelModel');
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get items() {
|
||||
return this.model.getFiltersOfType('filter');
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get title() {
|
||||
return this.env._t("Filters");
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {OwlEvent} ev
|
||||
*/
|
||||
_onItemSelected(ev) {
|
||||
ev.stopPropagation();
|
||||
const { item, option } = ev.detail;
|
||||
if (option) {
|
||||
this.model.dispatch('toggleFilterWithOptions', item.id, option.id);
|
||||
} else {
|
||||
this.model.dispatch('toggleFilter', item.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FilterMenu.components = Object.assign({}, DropdownMenu.components, {
|
||||
CustomFilterItem,
|
||||
});
|
||||
FilterMenu.defaultProps = Object.assign({}, DropdownMenu.defaultProps, {
|
||||
icon: 'fa fa-filter',
|
||||
});
|
||||
FilterMenu.props = Object.assign({}, DropdownMenu.props, {
|
||||
fields: Object,
|
||||
});
|
||||
FilterMenu.template = 'web.FilterMenu';
|
||||
|
||||
return FilterMenu;
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
odoo.define('web.GroupByMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
const { GROUPABLE_TYPES } = require('web.searchUtils');
|
||||
const DropdownMenu = require('web.DropdownMenu');
|
||||
const CustomGroupByItem = require('web.CustomGroupByItem');
|
||||
const { useModel } = require('web.model');
|
||||
|
||||
/**
|
||||
* 'Group by' menu
|
||||
*
|
||||
* Simple rendering of the filters of type `groupBy` given by the control panel
|
||||
* model. It uses most of the behaviours implemented by the dropdown menu Component,
|
||||
* with the addition of a groupBy filter generator (@see CustomGroupByItem).
|
||||
* @see DropdownMenu for additional details.
|
||||
* @extends DropdownMenu
|
||||
*/
|
||||
class GroupByMenu extends DropdownMenu {
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.fields = Object.values(this.props.fields)
|
||||
.filter(field => this._validateField(field))
|
||||
.sort(({ string: a }, { string: b }) => a > b ? 1 : a < b ? -1 : 0);
|
||||
|
||||
this.model = useModel('controlPanelModel');
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get items() {
|
||||
return this.model.getFiltersOfType('groupBy');
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get title() {
|
||||
return this.env._t("Group By");
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} field
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_validateField(field) {
|
||||
return field.sortable &&
|
||||
field.name !== "id" &&
|
||||
GROUPABLE_TYPES.includes(field.type);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {OwlEvent} ev
|
||||
*/
|
||||
_onItemSelected(ev) {
|
||||
ev.stopPropagation();
|
||||
const { item, option } = ev.detail;
|
||||
if (option) {
|
||||
this.model.dispatch('toggleFilterWithOptions', item.id, option.id);
|
||||
} else {
|
||||
this.model.dispatch('toggleFilter', item.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GroupByMenu.components = Object.assign({}, DropdownMenu.components, {
|
||||
CustomGroupByItem,
|
||||
});
|
||||
GroupByMenu.defaultProps = Object.assign({}, DropdownMenu.defaultProps, {
|
||||
icon: 'fa fa-bars',
|
||||
});
|
||||
GroupByMenu.props = Object.assign({}, DropdownMenu.props, {
|
||||
fields: Object,
|
||||
});
|
||||
GroupByMenu.template = 'web.GroupByMenu';
|
||||
|
||||
return GroupByMenu;
|
||||
});
|
||||
@@ -0,0 +1,480 @@
|
||||
odoo.define('web.SearchBar', function (require) {
|
||||
"use strict";
|
||||
|
||||
const Domain = require('web.Domain');
|
||||
const field_utils = require('web.field_utils');
|
||||
const { useAutofocus } = require('web.custom_hooks');
|
||||
const { useModel } = require('web.model');
|
||||
|
||||
const CHAR_FIELDS = ['char', 'html', 'many2many', 'many2one', 'one2many', 'text'];
|
||||
const { Component, hooks } = owl;
|
||||
const { useExternalListener, useRef, useState } = hooks;
|
||||
|
||||
let sourceId = 0;
|
||||
|
||||
/**
|
||||
* Search bar
|
||||
*
|
||||
* This component has two main roles:
|
||||
* 1) Display the current search facets
|
||||
* 2) Create new search filters using an input and an autocompletion values
|
||||
* generator.
|
||||
*
|
||||
* For the first bit, the core logic can be found in the XML template of this
|
||||
* component, searchfacet components or in the ControlPanelModel itself.
|
||||
*
|
||||
* The autocompletion mechanic works with transient subobjects called 'sources'.
|
||||
* Sources contain the information that will be used to generate new search facets.
|
||||
* A source is generated either:
|
||||
* a. From an undetermined user input: the user will give a string and select
|
||||
* a field from the autocompletion dropdown > this will search the selected
|
||||
* field records with the given pattern (with an 'ilike' operator);
|
||||
* b. From a given selection: when given an input by the user, the searchbar
|
||||
* will pre-fetch 'many2one' field records matching the input value and filter
|
||||
* 'select' fields with the same value. If the user clicks on one of these
|
||||
* fetched/filtered values, it will generate a matching search facet targeting
|
||||
* records having this exact value.
|
||||
* @extends Component
|
||||
*/
|
||||
class SearchBar extends Component {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.focusOnUpdate = useAutofocus();
|
||||
this.inputRef = useRef('search-input');
|
||||
this.model = useModel('controlPanelModel');
|
||||
this.state = useState({
|
||||
sources: [],
|
||||
focusedItem: 0,
|
||||
inputValue: "",
|
||||
});
|
||||
|
||||
this.autoCompleteSources = this.model.getFiltersOfType('field').map(
|
||||
filter => this._createSource(filter)
|
||||
);
|
||||
this.noResultItem = [null, this.env._t("(no result)")];
|
||||
|
||||
useExternalListener(window, 'click', this._onWindowClick);
|
||||
useExternalListener(window, 'keydown', this._onWindowKeydown);
|
||||
}
|
||||
|
||||
mounted() {
|
||||
// 'search' will always patch the search bar, 'focus' will never.
|
||||
this.env.controlPanelModel.on('search', this, this.focusOnUpdate);
|
||||
this.env.controlPanelModel.on('focus-control-panel', this, () => {
|
||||
this.inputRef.el.focus();
|
||||
});
|
||||
}
|
||||
|
||||
willUnmount() {
|
||||
this.env.controlPanelModel.off('search', this);
|
||||
this.env.controlPanelModel.off('focus-control-panel', this);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_closeAutoComplete() {
|
||||
this.state.sources = [];
|
||||
this.state.focusedItem = 0;
|
||||
this.focusOnUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} filter
|
||||
* @returns {Object}
|
||||
*/
|
||||
_createSource(filter) {
|
||||
const source = {
|
||||
active: true,
|
||||
description: filter.description,
|
||||
fieldName: filter.fieldName,
|
||||
filterId: filter.id,
|
||||
filterOperator: filter.operator,
|
||||
id: sourceId ++,
|
||||
operator: CHAR_FIELDS.includes(filter.fieldType) ? 'ilike' : '=',
|
||||
parent: false,
|
||||
};
|
||||
switch (filter.fieldType) {
|
||||
case 'selection':
|
||||
source.active = false;
|
||||
source.selection = this.props.fields[filter.fieldName].selection;
|
||||
break;
|
||||
case 'boolean':
|
||||
source.active = false;
|
||||
source.selection = [
|
||||
[true, this.env._t("Yes")],
|
||||
[false, this.env._t("No")],
|
||||
];
|
||||
break;
|
||||
case 'many2one':
|
||||
source.expand = true;
|
||||
source.expanded = false;
|
||||
if (filter.domain) {
|
||||
source.domain = filter.domain;
|
||||
}
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} source
|
||||
* @param {[any, string]} values
|
||||
* @param {boolean} [active=true]
|
||||
*/
|
||||
_createSubSource(source, [value, label], active = true) {
|
||||
const subSource = {
|
||||
active,
|
||||
fieldName: source.fieldName,
|
||||
filterId: source.filterId,
|
||||
filterOperator: source.filterOperator,
|
||||
id: sourceId ++,
|
||||
label,
|
||||
operator: '=',
|
||||
parent: source,
|
||||
value,
|
||||
};
|
||||
return subSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} source
|
||||
* @param {boolean} shouldExpand
|
||||
*/
|
||||
async _expandSource(source, shouldExpand) {
|
||||
source.expanded = shouldExpand;
|
||||
if (shouldExpand) {
|
||||
let args = source.domain;
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
args = Domain.prototype.stringToArray(args);
|
||||
} catch (err) {
|
||||
args = [];
|
||||
}
|
||||
}
|
||||
const { context, relation } = this.props.fields[source.fieldName];
|
||||
const results = await this.rpc({
|
||||
kwargs: {
|
||||
args,
|
||||
context,
|
||||
limit: 8,
|
||||
name: this.state.inputValue.trim(),
|
||||
},
|
||||
method: 'name_search',
|
||||
model: relation,
|
||||
});
|
||||
const options = results.map(result => this._createSubSource(source, result));
|
||||
const parentIndex = this.state.sources.indexOf(source);
|
||||
if (!options.length) {
|
||||
options.push(this._createSubSource(source, this.noResultItem, false));
|
||||
}
|
||||
this.state.sources.splice(parentIndex + 1, 0, ...options);
|
||||
} else {
|
||||
this.state.sources = this.state.sources.filter(src => src.parent !== source);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} query
|
||||
*/
|
||||
_filterSources(query) {
|
||||
return this.autoCompleteSources.reduce(
|
||||
(sources, source) => {
|
||||
// Field selection or boolean.
|
||||
if (source.selection) {
|
||||
const options = [];
|
||||
source.selection.forEach(result => {
|
||||
if (fuzzy.test(query, result[1].toLowerCase())) {
|
||||
options.push(this._createSubSource(source, result));
|
||||
}
|
||||
});
|
||||
if (options.length) {
|
||||
sources.push(source, ...options);
|
||||
}
|
||||
// Any other type.
|
||||
} else if (this._validateSource(query, source)) {
|
||||
sources.push(source);
|
||||
}
|
||||
// Fold any expanded item.
|
||||
if (source.expanded) {
|
||||
source.expanded = false;
|
||||
}
|
||||
return sources;
|
||||
},
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the search facet at the designated index if any.
|
||||
* @private
|
||||
*/
|
||||
_focusFacet(index) {
|
||||
const facets = this.el.getElementsByClassName('o_searchview_facet');
|
||||
if (facets.length) {
|
||||
facets[index].focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse the given rawValue according to the type of the given
|
||||
* source field type. The returned formatted value is the one that will
|
||||
* supposedly be sent to the server.
|
||||
* @private
|
||||
* @param {string} rawValue
|
||||
* @param {Object} source
|
||||
* @returns {string}
|
||||
*/
|
||||
_parseWithSource(rawValue, source) {
|
||||
const { type } = this.props.fields[source.fieldName];
|
||||
const parser = field_utils.parse[type];
|
||||
let parsedValue;
|
||||
if (['date', 'datetime'].includes(type)) {
|
||||
const parsedDate = parser(rawValue, { type }, { timezone: true });
|
||||
const dateFormat = type === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD';
|
||||
const momentValue = moment(parsedDate, dateFormat);
|
||||
if (!momentValue.isValid()) {
|
||||
throw new Error('Invalid date');
|
||||
}
|
||||
parsedValue = parsedDate.toJSON();
|
||||
} else {
|
||||
parsedValue = parser(rawValue);
|
||||
}
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} source
|
||||
*/
|
||||
_selectSource(source) {
|
||||
// Inactive sources are:
|
||||
// - Selection sources
|
||||
// - "no result" items
|
||||
if (source.active) {
|
||||
const labelValue = source.label || this.state.inputValue;
|
||||
this.model.dispatch('addAutoCompletionValues', {
|
||||
filterId: source.filterId,
|
||||
value: source.value || this._parseWithSource(labelValue, source),
|
||||
label: labelValue,
|
||||
operator: source.filterOperator || source.operator,
|
||||
});
|
||||
}
|
||||
this.state.inputValue = "";
|
||||
this.inputRef.el.value = "";
|
||||
this._closeAutoComplete();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} query
|
||||
* @param {Object} source
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_validateSource(query, source) {
|
||||
try {
|
||||
this._parseWithSource(query, source);
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @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.el.getElementsByClassName('o_searchview_facet');
|
||||
if (facetIndex === facets.length - 1) {
|
||||
this.inputRef.el.focus();
|
||||
} else {
|
||||
this._focusFacet(facetIndex + 1);
|
||||
}
|
||||
break;
|
||||
case 'Backspace':
|
||||
this._onFacetRemove(facet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} facet
|
||||
*/
|
||||
_onFacetRemove(facet) {
|
||||
this.model.dispatch('deactivateGroup', facet.groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @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 currentItem = this.state.sources[this.state.focusedItem] || {};
|
||||
switch (ev.key) {
|
||||
case 'ArrowDown':
|
||||
ev.preventDefault();
|
||||
if (Object.keys(this.state.sources).length) {
|
||||
let nextIndex = this.state.focusedItem + 1;
|
||||
if (nextIndex >= this.state.sources.length) {
|
||||
nextIndex = 0;
|
||||
}
|
||||
this.state.focusedItem = nextIndex;
|
||||
} else {
|
||||
this.env.bus.trigger('focus-view');
|
||||
}
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
if (currentItem.expanded) {
|
||||
// Priority 1: fold expanded item.
|
||||
ev.preventDefault();
|
||||
this._expandSource(currentItem, false);
|
||||
} else if (currentItem.parent) {
|
||||
// Priority 2: focus parent item.
|
||||
ev.preventDefault();
|
||||
this.state.focusedItem = this.state.sources.indexOf(currentItem.parent);
|
||||
// Priority 3: Do nothing (navigation inside text).
|
||||
} else if (ev.target.selectionStart === 0) {
|
||||
const facets = this.model.getFacets();
|
||||
// Priority 4: navigate to rightmost facet.
|
||||
this._focusFacet(facets.length - 1);
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
if (ev.target.selectionStart === this.state.inputValue.length) {
|
||||
// Priority 1: Do nothing (navigation inside text).
|
||||
if (currentItem.expand) {
|
||||
// Priority 2: go to first child or expand item.
|
||||
ev.preventDefault();
|
||||
if (currentItem.expanded) {
|
||||
this.state.focusedItem ++;
|
||||
} else {
|
||||
this._expandSource(currentItem, true);
|
||||
}
|
||||
} else if (ev.target.selectionStart === this.state.inputValue.length) {
|
||||
// Priority 3: navigate to leftmost facet.
|
||||
this._focusFacet(0);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
ev.preventDefault();
|
||||
let previousIndex = this.state.focusedItem - 1;
|
||||
if (previousIndex < 0) {
|
||||
previousIndex = this.state.sources.length - 1;
|
||||
}
|
||||
this.state.focusedItem = previousIndex;
|
||||
break;
|
||||
case 'Backspace':
|
||||
if (!this.state.inputValue.length) {
|
||||
const facets = this.model.getFacets();
|
||||
if (facets.length) {
|
||||
this._onFacetRemove(facets[facets.length - 1]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'Enter':
|
||||
if (!this.state.inputValue.length) {
|
||||
this.model.dispatch('search');
|
||||
break;
|
||||
}
|
||||
/* falls through */
|
||||
case 'Tab':
|
||||
if (this.state.inputValue.length) {
|
||||
this._selectSource(currentItem);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
if (this.state.sources.length) {
|
||||
this._closeAutoComplete();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {InputEvent} ev
|
||||
*/
|
||||
_onSearchInput(ev) {
|
||||
this.state.inputValue = ev.target.value;
|
||||
const wasVisible = this.state.sources.length;
|
||||
const query = this.state.inputValue.trim().toLowerCase();
|
||||
if (query.length) {
|
||||
this.state.sources = this._filterSources(query);
|
||||
} else if (wasVisible) {
|
||||
this._closeAutoComplete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only handled if the user has moved its cursor at least once after the
|
||||
* results are loaded and displayed.
|
||||
* @private
|
||||
* @param {number} resultIndex
|
||||
*/
|
||||
_onSourceMousemove(resultIndex) {
|
||||
this.state.focusedItem = resultIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onWindowClick(ev) {
|
||||
if (this.state.sources.length && !this.el.contains(ev.target)) {
|
||||
this._closeAutoComplete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
_onWindowKeydown(ev) {
|
||||
if (ev.key === 'Escape' && this.state.sources.length) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this._closeAutoComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SearchBar.defaultProps = {
|
||||
fields: {},
|
||||
};
|
||||
SearchBar.props = {
|
||||
fields: Object,
|
||||
};
|
||||
SearchBar.template = 'web.SearchBar';
|
||||
|
||||
return SearchBar;
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
odoo.define('web.searchUtils', function (require) {
|
||||
"use strict";
|
||||
|
||||
const { _lt } = require('web.core');
|
||||
|
||||
// Filter menu parameters
|
||||
const FIELD_OPERATORS = {
|
||||
boolean: [
|
||||
{ symbol: "=", description: _lt("is true"), value: true },
|
||||
{ symbol: "!=", description: _lt("is false"), 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 },
|
||||
],
|
||||
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 },
|
||||
],
|
||||
};
|
||||
const FIELD_TYPES = {
|
||||
boolean: 'boolean',
|
||||
char: 'char',
|
||||
date: 'date',
|
||||
datetime: 'datetime',
|
||||
float: 'number',
|
||||
id: 'id',
|
||||
integer: 'number',
|
||||
html: 'char',
|
||||
many2many: 'char',
|
||||
many2one: 'char',
|
||||
monetary: 'number',
|
||||
one2many: 'char',
|
||||
text: 'char',
|
||||
selection: 'selection',
|
||||
};
|
||||
const DEFAULT_PERIOD = 'this_month';
|
||||
const MONTH_OPTIONS = {
|
||||
this_month: { id: 'this_month', groupNumber: 1, format: 'MMMM', addParam: {}, setParam: {}, granularity: 'month' },
|
||||
last_month: { id: 'last_month', groupNumber: 1, format: 'MMMM', addParam: { months: -1 }, setParam: {}, granularity: 'month' },
|
||||
antepenultimate_month: { id: 'antepenultimate_month', groupNumber: 1, format: 'MMMM', addParam: { months: -2 }, setParam: {}, granularity: 'month' },
|
||||
};
|
||||
const QUARTER_OPTIONS = {
|
||||
fourth_quarter: { id: 'fourth_quarter', groupNumber: 1, description: _lt("Q4"), addParam: {}, setParam: { quarter: 4 }, granularity: 'quarter' },
|
||||
third_quarter: { id: 'third_quarter', groupNumber: 1, description: _lt("Q3"), addParam: {}, setParam: { quarter: 3 }, granularity: 'quarter' },
|
||||
second_quarter: { id: 'second_quarter', groupNumber: 1, description: _lt("Q2"), addParam: {}, setParam: { quarter: 2 }, granularity: 'quarter' },
|
||||
first_quarter: { id: 'first_quarter', groupNumber: 1, description: _lt("Q1"), addParam: {}, setParam: { quarter: 1 }, granularity: 'quarter' },
|
||||
};
|
||||
const YEAR_OPTIONS = {
|
||||
this_year: { id: 'this_year', groupNumber: 2, format: 'YYYY', addParam: {}, setParam: {}, granularity: 'year' },
|
||||
last_year: { id: 'last_year', groupNumber: 2, format: 'YYYY', addParam: { years: -1 }, setParam: {}, granularity: 'year' },
|
||||
antepenultimate_year: { id: 'antepenultimate_year', groupNumber: 2, format: 'YYYY', addParam: { years: -2 }, setParam: {}, granularity: 'year' },
|
||||
};
|
||||
const OPTION_GENERATORS = Object.assign({}, MONTH_OPTIONS, QUARTER_OPTIONS, YEAR_OPTIONS);
|
||||
|
||||
function rankPeriod(oId) {
|
||||
return Object.keys(OPTION_GENERATORS).indexOf(oId);
|
||||
}
|
||||
|
||||
// GroupBy menu parameters
|
||||
const GROUPABLE_TYPES = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime', 'integer'];
|
||||
const DEFAULT_INTERVAL = 'month';
|
||||
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 }
|
||||
};
|
||||
function rankInterval(oId) {
|
||||
return Object.keys(INTERVAL_OPTIONS).indexOf(oId);
|
||||
}
|
||||
|
||||
// TimeRange menu parameters
|
||||
const TIME_RANGE_OPTIONS = {
|
||||
last_7_days: { description: _lt("Last 7 Days"), id: 'last_7_days', groupNumber: 1 },
|
||||
last_30_days: { description: _lt("Last 30 Days"), id: 'last_30_days', groupNumber: 1 },
|
||||
last_365_days: { description: _lt("Last 365 Days"), id: 'last_365_days', groupNumber: 1 },
|
||||
last_5_years: { description: _lt("Last 5 Years"), id: 'last_5_years', groupNumber: 1 },
|
||||
today: { description: _lt("Today"), id: 'today', groupNumber: 2 },
|
||||
this_week: { description: _lt("This Week"), id: 'this_week', groupNumber: 2 },
|
||||
this_month: { description: _lt("This Month"), id: 'this_month', groupNumber: 2 },
|
||||
this_quarter: { description: _lt("This Quarter"), id: 'this_quarter', groupNumber: 2 },
|
||||
this_year: { description: _lt("This Year"), id: 'this_year', groupNumber: 2 },
|
||||
yesterday: { description: _lt("Yesterday"), id: 'yesterday', groupNumber: 3 },
|
||||
last_week: { description: _lt("Last Week"), id: 'last_week', groupNumber: 3 },
|
||||
last_month: { description: _lt("Last Month"), id: 'last_month', groupNumber: 3 },
|
||||
last_quarter: { description: _lt("Last Quarter"), id: 'last_quarter', groupNumber: 3 },
|
||||
last_year: { description: _lt("Last Year"), id: 'last_year', groupNumber: 3 },
|
||||
};
|
||||
const COMPARISON_TIME_RANGE_OPTIONS = {
|
||||
previous_period: { description: _lt("Previous Period"), id: 'previous_period' },
|
||||
previous_year: { description: _lt("Previous Year"), id: 'previous_year' },
|
||||
};
|
||||
|
||||
// Search bar
|
||||
const FACET_ICONS = {
|
||||
filter: 'fa-filter',
|
||||
groupBy: 'fa-bars',
|
||||
favorite: 'fa-star',
|
||||
timeRange: 'fa-calendar',
|
||||
};
|
||||
|
||||
return {
|
||||
COMPARISON_TIME_RANGE_OPTIONS,
|
||||
DEFAULT_INTERVAL,
|
||||
DEFAULT_PERIOD,
|
||||
FACET_ICONS,
|
||||
FIELD_OPERATORS,
|
||||
FIELD_TYPES,
|
||||
GROUPABLE_TYPES,
|
||||
INTERVAL_OPTIONS,
|
||||
OPTION_GENERATORS,
|
||||
TIME_RANGE_OPTIONS,
|
||||
YEAR_OPTIONS,
|
||||
|
||||
rankInterval,
|
||||
rankPeriod,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
odoo.define('web.TimeRangeMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
const { COMPARISON_TIME_RANGE_OPTIONS, DEFAULT_PERIOD, TIME_RANGE_OPTIONS } = require('web.searchUtils');
|
||||
const DropdownMenu = require('web.DropdownMenu');
|
||||
const { useModel } = require('web.model');
|
||||
|
||||
// Used to provide unique ids to its template elements.
|
||||
let timeRangeMenuId = 0;
|
||||
|
||||
/**
|
||||
* 'Time ranges' menu
|
||||
*
|
||||
* Component used to create a time range from a field, a given range and optionally
|
||||
* another field to make a comparison.
|
||||
*
|
||||
* The component template overrides the dropdownmenu to keep the basic behaviours
|
||||
* (opening/closing, layout). The template itself iis a set of labels/inputs
|
||||
* used to select the field and range. There is also a checkbox used to determine
|
||||
* whether to render the comparison range field selection (input).
|
||||
* @extends DropdownMenu
|
||||
*/
|
||||
class TimeRangeMenu extends DropdownMenu {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.model = useModel('controlPanelModel');
|
||||
|
||||
this.domId = timeRangeMenuId++;
|
||||
this.fields = Object.values(this.props.fields)
|
||||
.filter(field => this._validateField(field));
|
||||
this.periodOptions = TIME_RANGE_OPTIONS;
|
||||
this.comparisonTimeRangeOptions = COMPARISON_TIME_RANGE_OPTIONS;
|
||||
this.periodGroups = Object.values(this.periodOptions).reduce((acc, o) => {
|
||||
if (!acc.includes(o.groupNumber)) {
|
||||
acc.push(o.groupNumber);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
const activeTimeRange = this.model.getFiltersOfType('timeRange').find(
|
||||
timeRange => timeRange.isActive
|
||||
);
|
||||
Object.assign(this.state, {
|
||||
fieldName: this.fields[0] && this.fields[0].name,
|
||||
rangeId: DEFAULT_PERIOD,
|
||||
}, activeTimeRange);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get title() {
|
||||
return this.env._t("Time Ranges");
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} field
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_validateField(field) {
|
||||
return field.sortable &&
|
||||
['date', 'datetime'].includes(field.type);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Handlers
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onApply() {
|
||||
this.model.dispatch('activateTimeRange',
|
||||
this.state.fieldName,
|
||||
this.state.rangeId,
|
||||
this.state.comparisonRangeId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onCheckboxClick() {
|
||||
if (!this.state.comparisonRangeId) {
|
||||
this.state.comparisonRangeId = 'previous_period'; // default
|
||||
} else {
|
||||
delete this.state.comparisonRangeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TimeRangeMenu.defaultProps = Object.assign({}, DropdownMenu.defaultProps, {
|
||||
icon: 'fa fa-calendar',
|
||||
});
|
||||
TimeRangeMenu.props = Object.assign({}, DropdownMenu.props, {
|
||||
fields: Object,
|
||||
});
|
||||
TimeRangeMenu.template = 'web.TimeRangeMenu';
|
||||
|
||||
return TimeRangeMenu;
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
odoo.define('web.model', function () {
|
||||
"use strict";
|
||||
|
||||
const { Component, core } = owl;
|
||||
const { EventBus, Observer } = core;
|
||||
|
||||
// TODO: remove this if made available in owl.utils
|
||||
function partitionBy(arr, fn) {
|
||||
let lastGroup = false;
|
||||
let lastValue;
|
||||
return arr.reduce((acc, cur) => {
|
||||
let curVal = fn(cur);
|
||||
if (lastGroup) {
|
||||
if (curVal === lastValue) {
|
||||
lastGroup.push(cur);
|
||||
}
|
||||
else {
|
||||
lastGroup = false;
|
||||
}
|
||||
}
|
||||
if (!lastGroup) {
|
||||
lastGroup = [cur];
|
||||
acc.push(lastGroup);
|
||||
}
|
||||
lastValue = curVal;
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Model
|
||||
*
|
||||
* The purpose of the class Model and the associated hook useModel
|
||||
* is to offer something similar to an owl store but with no automatic
|
||||
* notification (and rendering) of components when the 'state' used in the model
|
||||
* would change. Instead, one should call the __notifyComponents function whenever
|
||||
* it is useful to alert registered component. Nevertheless,
|
||||
* when calling a method throught the dispatch method, a notifcation
|
||||
* does take place automatically, and registered components (via useModel) are rendered.
|
||||
*
|
||||
* it is highly expected that this class will change in a near future. We don't have
|
||||
* the necessary hindsight to be sure its actual form is good.
|
||||
* @extends EventBus
|
||||
*/
|
||||
class Model extends EventBus {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.rev = 1;
|
||||
this.mapping = {}; // could be a weak map
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Public
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Call the base model method with given name with the arguments
|
||||
* determined by the dispatch extra arguments.
|
||||
*
|
||||
* @param {string} action
|
||||
* @param {...any} args
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
async dispatch(action, ...args) {
|
||||
const result = await this[action](...args);
|
||||
// TODO try to put it in promise.resolve()
|
||||
this._dispatch(...arguments);
|
||||
let rev = this.rev;
|
||||
await Promise.resolve();
|
||||
if (rev === this.rev) {
|
||||
await this._notifyComponents();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} action
|
||||
* @param {...any} args
|
||||
*/
|
||||
_dispatch(action, ...args) { }
|
||||
|
||||
/**
|
||||
* see Context method in owl.js for explanation
|
||||
* @private
|
||||
*/
|
||||
async _notifyComponents() {
|
||||
const rev = ++this.rev;
|
||||
const subscriptions = this.subscriptions.update;
|
||||
const groups = partitionBy(subscriptions, s => (s.owner ? s.owner.__owl__.depth : -1));
|
||||
for (let group of groups) {
|
||||
const proms = group.map(sub => sub.callback.call(sub.owner, rev));
|
||||
Component.scheduler.flush();
|
||||
await Promise.all(proms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is more or less the hook 'useContextWithCB' from owl only slightly simplified.
|
||||
*
|
||||
* @param {string} modelName
|
||||
*/
|
||||
function useModel(modelName) {
|
||||
const component = Component.current;
|
||||
const model = component.env[modelName];
|
||||
if (!(model instanceof Model)) {
|
||||
throw new Error(`No Model found when connecting '${component.constructor.name}'`);
|
||||
}
|
||||
|
||||
const mapping = model.mapping;
|
||||
const __owl__ = component.__owl__;
|
||||
const componentId = __owl__.id;
|
||||
if (!__owl__.observer) {
|
||||
__owl__.observer = new Observer();
|
||||
__owl__.observer.notifyCB = component.render.bind(component);
|
||||
}
|
||||
const currentCB = __owl__.observer.notifyCB;
|
||||
__owl__.observer.notifyCB = function () {
|
||||
if (model.rev > mapping[componentId]) {
|
||||
return;
|
||||
}
|
||||
currentCB();
|
||||
};
|
||||
mapping[componentId] = 0;
|
||||
const renderFn = __owl__.renderFn;
|
||||
__owl__.renderFn = function (comp, params) {
|
||||
mapping[componentId] = model.rev;
|
||||
return renderFn(comp, params);
|
||||
};
|
||||
|
||||
model.on('update', component, async modelRev => {
|
||||
if (mapping[componentId] < modelRev) {
|
||||
mapping[componentId] = modelRev;
|
||||
await component.render();
|
||||
}
|
||||
});
|
||||
|
||||
const __destroy = component.__destroy;
|
||||
component.__destroy = parent => {
|
||||
model.off("update", component);
|
||||
__destroy.call(component, parent);
|
||||
};
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
return {
|
||||
Model,
|
||||
useModel,
|
||||
};
|
||||
});
|
||||
@@ -1,269 +0,0 @@
|
||||
odoo.define('web.ControlPanelController', function (require) {
|
||||
"use strict";
|
||||
|
||||
var mvc = require('web.mvc');
|
||||
|
||||
var ControlPanelController = mvc.Controller.extend({
|
||||
className: 'o_cp_controller',
|
||||
custom_events: {
|
||||
facet_removed: '_onFacetRemoved',
|
||||
get_search_query: '_onGetSearchQuery',
|
||||
item_option_clicked: '_onItemOptionClicked',
|
||||
item_trashed: '_onItemTrashed',
|
||||
menu_item_clicked: '_onMenuItemClicked',
|
||||
new_favorite: '_onNewFavorite',
|
||||
new_filters: '_onNewFilters',
|
||||
new_groupBy: '_onNewGroupBy',
|
||||
activate_time_range: '_onActivateTimeRange',
|
||||
autocompletion_filter: '_onAutoCompletionFilter',
|
||||
reload: '_onReload',
|
||||
reset: '_onReset',
|
||||
},
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} params
|
||||
* @param {string} params.modelName
|
||||
*/
|
||||
init: function (parent, model, renderer, params) {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
this.modelName = params.modelName;
|
||||
},
|
||||
/**
|
||||
* Called when the control panel is inserted into the DOM.
|
||||
*/
|
||||
on_attach_callback: function () {
|
||||
this.renderer.on_attach_callback();
|
||||
},
|
||||
/**
|
||||
* Called when the control panel is remove form the DOM.
|
||||
*/
|
||||
on_detach_callback: function () {
|
||||
this.renderer.on_detach_callback();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @see ControlPanelModel (exportState)
|
||||
* @returns {Object}
|
||||
*/
|
||||
exportState: function () {
|
||||
return this.model.exportState();
|
||||
},
|
||||
/**
|
||||
* Called by the abstract controller to give focus to the searchbar
|
||||
*/
|
||||
focusSearchBar: function () {
|
||||
if (this.renderer.searchBar) {
|
||||
this.renderer.searchBar.focus();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Compute the search related values that will be used to fetch data.
|
||||
*
|
||||
* @returns {Object} object with keys 'context', 'domain', 'groupBy'
|
||||
*/
|
||||
getSearchQuery: function () {
|
||||
return this.model.getQuery();
|
||||
},
|
||||
/**
|
||||
* @param {Object} state a ControlPanelModel state
|
||||
* @returns {Promise<Object>} the result of `getSearchState`
|
||||
*/
|
||||
importState: function (state) {
|
||||
var defs = [];
|
||||
this.model.importState(state);
|
||||
defs.push(this.getSearchQuery());
|
||||
defs.push(this.renderer.updateState(this.model.get()));
|
||||
return Promise.all(defs).then(function (defsResults) {
|
||||
return defsResults[0];
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Called at each switch view. This is required until the control panel is
|
||||
* shared between controllers of an action.
|
||||
*
|
||||
* @param {string} controllerID
|
||||
*/
|
||||
setControllerID: function (controllerID) {
|
||||
this.controllerID = controllerID;
|
||||
},
|
||||
/**
|
||||
* Update the content and displays the ControlPanel.
|
||||
*
|
||||
* @see ControlPanelRenderer (updateContents)
|
||||
* @param {Object} status
|
||||
* @param {Object} [options]
|
||||
*/
|
||||
updateContents: function (status, options) {
|
||||
this.renderer.updateContents(status, options);
|
||||
},
|
||||
/**
|
||||
* Update the domain of the search view by adding and/or removing filters.
|
||||
*
|
||||
* @todo: the way it is done could be improved, but the actual state of the
|
||||
* searchview doesn't allow to do much better.
|
||||
|
||||
* @param {Object[]} newFilters list of filters to add, described by
|
||||
* objects with keys domain (the domain as an Array), description (the text
|
||||
* to display in the facet) and type with value 'filter'.
|
||||
* @param {string[]} filtersToRemove list of filter ids to remove
|
||||
* (previously added ones)
|
||||
* @returns {string[]} list of added filters (to pass as filtersToRemove
|
||||
* for a further call to this function)
|
||||
*/
|
||||
updateFilters: function (newFilters, filtersToRemove) {
|
||||
var newFilterIDS = this.model.createNewFilters(newFilters);
|
||||
this.model.deactivateFilters(filtersToRemove);
|
||||
this._reportNewQueryAndRender();
|
||||
return newFilterIDS;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {jQuery}
|
||||
*/
|
||||
_getSubMenus: function () {
|
||||
return this.renderer.$subMenus;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_reportNewQueryAndRender: function () {
|
||||
this.trigger_up('search', this.model.getQuery());
|
||||
var state = this.model.get();
|
||||
return this.renderer.updateState(state);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onActivateTimeRange: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this.model.activateTimeRange(
|
||||
ev.data.id,
|
||||
ev.data.timeRangeId,
|
||||
ev.data.comparisonTimeRangeId
|
||||
);
|
||||
this._reportNewQueryAndRender();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onAutoCompletionFilter: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this.model.toggleAutoCompletionFilter(ev.data);
|
||||
this._reportNewQueryAndRender();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onFacetRemoved: function (ev) {
|
||||
ev.stopPropagation();
|
||||
var group = ev.data.group || this.renderer.getLastFacet();
|
||||
if (group) {
|
||||
this.model.deactivateGroup(group.id);
|
||||
this._reportNewQueryAndRender();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onGetSearchQuery: function (ev) {
|
||||
ev.stopPropagation();
|
||||
var query = this.getSearchQuery();
|
||||
ev.data.callback(query);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onItemOptionClicked: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this.model.toggleFilterWithOptions(ev.data.id, ev.data.optionId);
|
||||
this._reportNewQueryAndRender();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onItemTrashed: function (ev) {
|
||||
ev.stopPropagation();
|
||||
var def = this.model.deleteFilterEverywhere(ev.data.id);
|
||||
def.then(this._reportNewQueryAndRender.bind(this));
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onMenuItemClicked: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this.model.toggleFilter(ev.data.id);
|
||||
this._reportNewQueryAndRender();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onNewFavorite: function (ev) {
|
||||
ev.stopPropagation();
|
||||
var def = this.model.createNewFavorite(ev.data);
|
||||
def.then(this._reportNewQueryAndRender.bind(this));
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onNewFilters: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this.model.createNewFilters(ev.data.filters);
|
||||
this._reportNewQueryAndRender();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onNewGroupBy: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this.model.createNewGroupBy(ev.data);
|
||||
this._reportNewQueryAndRender();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onReload: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this.trigger_up('search', this.model.getQuery());
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onReset: function (ev) {
|
||||
ev.stopPropagation();
|
||||
var state = this.model.get();
|
||||
this.renderer.updateState(state);
|
||||
},
|
||||
});
|
||||
|
||||
return ControlPanelController;
|
||||
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,387 +0,0 @@
|
||||
odoo.define('web.ControlPanelRenderer', function (require) {
|
||||
"use strict";
|
||||
|
||||
var config = require('web.config');
|
||||
var data = require('web.data');
|
||||
var FavoriteMenu = require('web.FavoriteMenu');
|
||||
var FilterMenu = require('web.FilterMenu');
|
||||
var GroupByMenu = require('web.GroupByMenu');
|
||||
var mvc = require('web.mvc');
|
||||
var SearchBar = require('web.SearchBar');
|
||||
var TimeRangeMenu = require('web.TimeRangeMenu');
|
||||
|
||||
var Renderer = mvc.Renderer;
|
||||
|
||||
var ControlPanelRenderer = Renderer.extend({
|
||||
template: 'ControlPanel',
|
||||
custom_events: {
|
||||
get_action_info: '_onGetActionInfo',
|
||||
},
|
||||
events: _.extend({}, Renderer.prototype.events, {
|
||||
'click .o_searchview_more': '_onMore',
|
||||
}),
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} [params.action] current action if any
|
||||
* @param {Object} [params.context]
|
||||
* @param {Object[]} [params.breadcrumbs=[]] list of breadcrumbs elements
|
||||
* @param {boolean} [params.withBreadcrumbs=false] if false, breadcrumbs
|
||||
* won't be rendered
|
||||
* @param {boolean} [params.withSearchBar=false] if false, no search bar
|
||||
* is rendered
|
||||
* @param {string[]} [params.searchMenuTypes=[]] determines the search menus
|
||||
* that are displayed.
|
||||
* @param {String} [params.template] the QWeb template to render the
|
||||
* ControlPanel. By default, the template 'ControlPanel' will be used.
|
||||
* @param {string} [params.title=''] the title visible in control panel
|
||||
*/
|
||||
init: function (parent, state, params) {
|
||||
this._super.apply(this, arguments);
|
||||
this._breadcrumbs = params.breadcrumbs || [];
|
||||
this._title = params.title || '';
|
||||
this.withBreadcrumbs = params.withBreadcrumbs;
|
||||
this.withSearchBar = params.withSearchBar;
|
||||
if (params.template) {
|
||||
this.template = params.template;
|
||||
}
|
||||
this.context = params.context;
|
||||
|
||||
this.$subMenus = null;
|
||||
this.action = params.action;
|
||||
this.displaySearchMenu = true;
|
||||
this.isMobile = config.device.isMobile;
|
||||
this.menusSetup = false;
|
||||
this.searchMenuTypes = params.searchMenuTypes || [];
|
||||
this.subMenus = {};
|
||||
},
|
||||
/**
|
||||
* Render the control panel and create a dictionnary of its exposed elements.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
var self = this;
|
||||
|
||||
// exposed jQuery nodesets
|
||||
this.nodes = {
|
||||
$buttons: this.$('.o_cp_buttons'),
|
||||
$pager: this.$('.o_cp_pager'),
|
||||
$sidebar: this.$('.o_cp_sidebar'),
|
||||
$switch_buttons: this.$('.o_cp_switch_buttons'),
|
||||
};
|
||||
|
||||
// if we don't use the default search bar and buttons, we expose the
|
||||
// corresponding areas for custom content
|
||||
if (!this.withSearchBar) {
|
||||
this.nodes.$searchview = this.$('.o_cp_searchview');
|
||||
}
|
||||
if (this.searchMenuTypes.length === 0) {
|
||||
this.nodes.$searchview_buttons = this.$('.o_search_options');
|
||||
}
|
||||
|
||||
if (this.withBreadcrumbs) {
|
||||
this._renderBreadcrumbs();
|
||||
}
|
||||
|
||||
var superDef = this._super.apply(this, arguments);
|
||||
var searchDef = this._renderSearch();
|
||||
return Promise.all([superDef, searchDef]).then(function () {
|
||||
self._setSearchMenusVisibility();
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
on_attach_callback: function () {
|
||||
this._focusSearchInput();
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
on_detach_callback: function () {
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @returns {Object|undefined}
|
||||
*/
|
||||
getLastFacet: function () {
|
||||
return this.state.facets.slice(-1)[0];
|
||||
},
|
||||
/**
|
||||
* This function is called when actions call 'updateControlPanel' with
|
||||
* custom contents to insert in the exposed areas.
|
||||
*
|
||||
* @param {Object} status
|
||||
* @param {Object} [status.cp_content] dictionnary containing the jQuery
|
||||
* elements to insert in the exposed areas
|
||||
* @param {string} [status.breadcrumbs] the breadcrumbs to display before
|
||||
* the current controller
|
||||
* @param {string} [status.title] the title of the current controller, to
|
||||
* display at the end of the breadcrumbs
|
||||
* @param {Object} [options]
|
||||
* @param {Boolean} [options.clear=true] set to false to keep control panel
|
||||
* elements that are not in status.cp_content (useful for partial updates)
|
||||
*/
|
||||
updateContents: function (status, options) {
|
||||
var new_cp_content = status.cp_content || {};
|
||||
var clear = 'clear' in (options || {}) ? options.clear : true;
|
||||
|
||||
if (this.withBreadcrumbs) {
|
||||
this._breadcrumbs = status.breadcrumbs || this._breadcrumbs;
|
||||
this._title = status.title || this._title;
|
||||
this._renderBreadcrumbs();
|
||||
}
|
||||
|
||||
if (clear) {
|
||||
this._detachContent(this.nodes);
|
||||
} else {
|
||||
this._detachContent(_.pick(this.nodes, _.keys(new_cp_content)));
|
||||
}
|
||||
this._attachContent(new_cp_content);
|
||||
},
|
||||
/**
|
||||
* Update the state of the renderer state. It retriggers a full rerendering.
|
||||
*
|
||||
* @param {Object} state
|
||||
* @returns {Promise}
|
||||
*/
|
||||
updateState: function (state) {
|
||||
this.state = state;
|
||||
return this._renderSearch();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} content dictionnary of jQuery elements to attach, whose
|
||||
* keys are jQuery nodes identifiers in this.nodes
|
||||
*/
|
||||
_attachContent: function (content) {
|
||||
for (var $element in content) {
|
||||
var $nodeset = content[$element];
|
||||
if ($nodeset && this.nodes[$element]) {
|
||||
this.nodes[$element].append($nodeset);
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} content subset of this.nodes to detach
|
||||
*/
|
||||
_detachContent: function (content) {
|
||||
for (var $element in content) {
|
||||
content[$element].contents().detach();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_focusSearchInput: function () {
|
||||
if (this.withSearchBar && !config.device.isMobileDevice) {
|
||||
// in mobile mode, we would rather not focus manually the
|
||||
// input, because it opens up the integrated keyboard, which is
|
||||
// not what you expect when you just selected a filter.
|
||||
this.searchBar.focus();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {string} menuType
|
||||
* @returns {Objects[]} menuItems
|
||||
*/
|
||||
_getMenuItems: function (menuType) {
|
||||
var menuItems;
|
||||
if (menuType === 'filter') {
|
||||
menuItems = this.state.filters;
|
||||
}
|
||||
if (menuType === 'groupBy') {
|
||||
menuItems = this.state.groupBys;
|
||||
}
|
||||
if (menuType === 'timeRange') {
|
||||
menuItems = this.state.timeRanges;
|
||||
}
|
||||
if (menuType === 'favorite') {
|
||||
menuItems = this.state.favorites;
|
||||
}
|
||||
return menuItems;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {jQueryElement}
|
||||
*/
|
||||
_getSubMenusPlace: function () {
|
||||
return $('<div>').appendTo(this.$('.o_search_options'));
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_renderBreadcrumbs: function () {
|
||||
var self = this;
|
||||
var breadcrumbsDescriptors = this._breadcrumbs.concat({title: this._title});
|
||||
var breadcrumbs = breadcrumbsDescriptors.map(function (bc, index) {
|
||||
return self._renderBreadcrumbsItem(bc, index, breadcrumbsDescriptors.length);
|
||||
});
|
||||
this.$('.breadcrumb').html(breadcrumbs);
|
||||
},
|
||||
/**
|
||||
* Render a breadcrumbs' li jQuery element.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} bc
|
||||
* @param {string} bc.title
|
||||
* @param {string} bc.controllerID
|
||||
* @param {integer} index
|
||||
* @param {integer} length
|
||||
* @returns {jQueryElement} $bc
|
||||
*/
|
||||
_renderBreadcrumbsItem: function (bc, index, length) {
|
||||
var self = this;
|
||||
var is_last = (index === length-1);
|
||||
var li_content = bc.title && _.escape(bc.title.trim()) || data.noDisplayContent;
|
||||
var $bc = $('<li>', {class: 'breadcrumb-item'})
|
||||
.append(is_last ? li_content : $('<a>', {href: '#'}).html(li_content))
|
||||
.toggleClass('active', is_last);
|
||||
if (!is_last) {
|
||||
$bc.click(function (ev) {
|
||||
ev.preventDefault();
|
||||
self.trigger_up('breadcrumb_clicked', {controllerID: bc.controllerID});
|
||||
});
|
||||
}
|
||||
|
||||
var secondLast = index === length - 2;
|
||||
if (secondLast) {
|
||||
$bc.attr('accessKey', 'b');
|
||||
}
|
||||
|
||||
return $bc;
|
||||
},
|
||||
/**
|
||||
* Renderer the search bar and the search menus
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_renderSearch: function () {
|
||||
var defs = [];
|
||||
if (this.menusSetup) {
|
||||
this._updateMenus();
|
||||
} else {
|
||||
this.menusSetup = true;
|
||||
defs = defs.concat(this._setupMenus());
|
||||
}
|
||||
if (this.withSearchBar) {
|
||||
defs.push(this._renderSearchBar());
|
||||
}
|
||||
return Promise.all(defs).then(this._focusSearchInput.bind(this));
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_renderSearchBar: function () {
|
||||
// TODO: might need a reload instead of a destroy/instantiate
|
||||
var oldSearchBar = this.searchBar;
|
||||
this.searchBar = new SearchBar(this, {
|
||||
context: this.context,
|
||||
facets: this.state.facets,
|
||||
fields: this.state.fields,
|
||||
filterFields: this.state.filterFields,
|
||||
});
|
||||
return this.searchBar.appendTo(this.$('.o_searchview')).then(function () {
|
||||
if (oldSearchBar) {
|
||||
oldSearchBar.destroy();
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Hide or show the search menus according to this.displaySearchMenu.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_setSearchMenusVisibility: function () {
|
||||
this.$('.o_searchview_more')
|
||||
.toggleClass('fa-search-plus', !this.displaySearchMenu)
|
||||
.toggleClass('fa-search-minus', this.displaySearchMenu);
|
||||
this.$('.o_search_options')
|
||||
.toggleClass('o_hidden', !this.displaySearchMenu);
|
||||
},
|
||||
/**
|
||||
* Create a new menu of the given type and append it to this.$subMenus.
|
||||
* This menu is also added to this.subMenus.
|
||||
*
|
||||
* @private
|
||||
* @param {string} menuType
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_setupMenu: function (menuType) {
|
||||
var Menu;
|
||||
var menu;
|
||||
if (menuType === 'filter') {
|
||||
Menu = FilterMenu;
|
||||
}
|
||||
if (menuType === 'groupBy') {
|
||||
Menu = GroupByMenu;
|
||||
}
|
||||
if (menuType === 'timeRange') {
|
||||
Menu = TimeRangeMenu;
|
||||
}
|
||||
if (menuType === 'favorite') {
|
||||
Menu = FavoriteMenu;
|
||||
}
|
||||
if (_.contains(['filter', 'groupBy', 'timeRange'], menuType)) {
|
||||
menu = new Menu(this, this._getMenuItems(menuType), this.state.fields);
|
||||
}
|
||||
if (menuType === 'favorite') {
|
||||
menu = new Menu(this, this._getMenuItems(menuType), this.action);
|
||||
}
|
||||
this.subMenus[menuType] = menu;
|
||||
return menu.appendTo(this.$subMenus);
|
||||
},
|
||||
/**
|
||||
* Instantiate the search menu determined by this.searchMenuTypes.
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise[]}
|
||||
*/
|
||||
_setupMenus: function () {
|
||||
this.$subMenus = this._getSubMenusPlace();
|
||||
return this.searchMenuTypes.map(this._setupMenu.bind(this));
|
||||
},
|
||||
/**
|
||||
* Update the search menus.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_updateMenus: function () {
|
||||
var self = this;
|
||||
this.searchMenuTypes.forEach(function (menuType) {
|
||||
self.subMenus[menuType].update(self._getMenuItems(menuType));
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Toggle the search menus visibility.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onMore: function () {
|
||||
this.displaySearchMenu = !this.displaySearchMenu;
|
||||
this._setSearchMenusVisibility();
|
||||
},
|
||||
});
|
||||
|
||||
return ControlPanelRenderer;
|
||||
|
||||
});
|
||||
@@ -1,343 +0,0 @@
|
||||
odoo.define('web.ControlPanelView', function (require) {
|
||||
"use strict";
|
||||
|
||||
var ControlPanelController = require('web.ControlPanelController');
|
||||
var ControlPanelModel = require('web.ControlPanelModel');
|
||||
var ControlPanelRenderer = require('web.ControlPanelRenderer');
|
||||
var controlPanelViewParameters = require('web.controlPanelViewParameters');
|
||||
var mvc = require('web.mvc');
|
||||
var pyUtils = require('web.py_utils');
|
||||
var viewUtils = require('web.viewUtils');
|
||||
var Domain = require('web.Domain');
|
||||
|
||||
var DEFAULT_INTERVAL = controlPanelViewParameters.DEFAULT_INTERVAL;
|
||||
var DEFAULT_PERIOD = controlPanelViewParameters.DEFAULT_PERIOD;
|
||||
var INTERVAL_OPTIONS = controlPanelViewParameters.INTERVAL_OPTIONS;
|
||||
const OPTION_GENERATORS = controlPanelViewParameters.OPTION_GENERATORS;
|
||||
|
||||
var Factory = mvc.Factory;
|
||||
|
||||
var ControlPanelView = Factory.extend({
|
||||
config: _.extend({}, Factory.prototype.config, {
|
||||
Controller: ControlPanelController,
|
||||
Model: ControlPanelModel,
|
||||
Renderer: ControlPanelRenderer,
|
||||
}),
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} [params={}]
|
||||
* @param {Object} [params.action={}]
|
||||
* @param {Object} [params.context={}]
|
||||
* @param {string} [params.domain=[]]
|
||||
* @param {string} [params.modelName]
|
||||
* @param {string[]} [params.searchMenuTypes=[]]
|
||||
* determines search menus displayed.
|
||||
* @param {Object} [params.state] used to determine the control panel model
|
||||
* essential content at load. For instance, state can be the state of an
|
||||
* other control panel model that we want to use.
|
||||
* @param {string} [params.title] the name of the action, to display in the
|
||||
* breadcrumb
|
||||
* @param {string} [params.template] the QWeb template to render
|
||||
* @param {Object} [params.viewInfo={arch: '<search/>', fields: {}}] a
|
||||
* search fieldsview
|
||||
* @param {string} [params.viewInfo.arch]
|
||||
* @param {boolean} [params.withBreadcrumbs=true] if set to false,
|
||||
* breadcrumbs won't be rendered
|
||||
* @param {boolean} [params.withSearchBar=true] if set to false, no default
|
||||
* search bar will be rendered
|
||||
* @param {Object[]} [params.dynamicFilters=[]] filters to add to the
|
||||
* search (in addition to those described in the arch), each filter being
|
||||
* an object with keys 'description' (what is displayed in the searchbar)
|
||||
* and 'domain'
|
||||
*/
|
||||
init: function (params) {
|
||||
var self = this;
|
||||
this._super();
|
||||
params = params || {};
|
||||
var viewInfo = params.viewInfo || {arch: '<search/>', fields: {}};
|
||||
var context = _.extend({}, params.context);
|
||||
var domain = params.domain || [];
|
||||
var action = params.action || {};
|
||||
|
||||
this.searchDefaults = {};
|
||||
Object.keys(context).forEach(function (key) {
|
||||
var match = /^search_default_(.*)$/.exec(key);
|
||||
if (match) {
|
||||
self.searchDefaults[match[1]] = context[key];
|
||||
delete context[key];
|
||||
}
|
||||
});
|
||||
|
||||
this.arch = viewUtils.parseArch(viewInfo.arch);
|
||||
this.fields = viewInfo.fields;
|
||||
|
||||
this.referenceMoment = moment();
|
||||
|
||||
const setDescriptions = (options) => {
|
||||
return options.map(o => {
|
||||
const oClone = JSON.parse(JSON.stringify(o));
|
||||
const description = o.description ?
|
||||
o.description.toString () :
|
||||
this.referenceMoment.clone().add(o.addParam).format(o.format);
|
||||
return _.extend(oClone, {description: description});
|
||||
});
|
||||
};
|
||||
const process = (options) => {
|
||||
return options.map(o => {
|
||||
const date = this.referenceMoment.clone().set(o.setParam).add(o.addParam);
|
||||
delete o.addParam;
|
||||
o.setParam[o.granularity] = date[o.granularity]();
|
||||
o.defaultYear = date.year();
|
||||
return o;
|
||||
});
|
||||
}
|
||||
|
||||
this.optionGenerators = process(setDescriptions(OPTION_GENERATORS));
|
||||
this.intervalOptions = setDescriptions(INTERVAL_OPTIONS);
|
||||
|
||||
this.controllerParams.modelName = params.modelName;
|
||||
|
||||
this.modelParams.context = context;
|
||||
this.modelParams.domain = domain;
|
||||
this.modelParams.modelName = params.modelName;
|
||||
this.modelParams.actionId = action.id;
|
||||
this.modelParams.fields = this.fields;
|
||||
|
||||
this.rendererParams.action = action;
|
||||
this.rendererParams.breadcrumbs = params.breadcrumbs;
|
||||
this.rendererParams.context = context;
|
||||
this.rendererParams.searchMenuTypes = params.searchMenuTypes || [];
|
||||
this.rendererParams.template = params.template;
|
||||
this.rendererParams.title = params.title;
|
||||
this.rendererParams.withBreadcrumbs = params.withBreadcrumbs !== false;
|
||||
this.rendererParams.withSearchBar = 'withSearchBar' in params ? params.withSearchBar : true;
|
||||
|
||||
this.loadParams.withSearchBar = 'withSearchBar' in params ? params.withSearchBar : true;
|
||||
this.loadParams.searchMenuTypes = params.searchMenuTypes || [];
|
||||
this.loadParams.activateDefaultFavorite = params.activateDefaultFavorite;
|
||||
if (this.loadParams.withSearchBar) {
|
||||
if (params.state) {
|
||||
this.loadParams.initialState = params.state;
|
||||
} else {
|
||||
// groups are determined in _parseSearchArch
|
||||
this.loadParams.groups = [];
|
||||
this.loadParams.timeRanges = context.time_ranges;
|
||||
this._parseSearchArch(this.arch);
|
||||
}
|
||||
}
|
||||
|
||||
// add a filter group with the dynamic filters, if any
|
||||
if (params.dynamicFilters && params.dynamicFilters.length) {
|
||||
var dynamicFiltersGroup = params.dynamicFilters.map(function (filter) {
|
||||
return {
|
||||
description: filter.description,
|
||||
domain: JSON.stringify(filter.domain),
|
||||
isDefault: true,
|
||||
type: 'filter',
|
||||
};
|
||||
});
|
||||
this.loadParams.groups.unshift(dynamicFiltersGroup);
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} child parsed arch node
|
||||
* @returns {Object}
|
||||
*/
|
||||
_evalArchChild: function (child) {
|
||||
if (child.attrs.context) {
|
||||
try {
|
||||
var context = pyUtils.eval('context', child.attrs.context);
|
||||
if (context.group_by) {
|
||||
// let us extract basic data since we just evaluated context
|
||||
// and use a correct tag!
|
||||
child.tag = 'groupBy';
|
||||
child.attrs.fieldName = context.group_by.split(':')[0];
|
||||
child.attrs.defaultInterval = context.group_by.split(':')[1];
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
return child;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} filter
|
||||
* @param {Object} attrs
|
||||
*/
|
||||
_extractAttributes: function (filter, attrs) {
|
||||
filter.isDefault = this.searchDefaults[attrs.name] ? true : false;
|
||||
filter.description = attrs.string ||
|
||||
attrs.help ||
|
||||
attrs.name ||
|
||||
attrs.domain ||
|
||||
'Ω';
|
||||
if (attrs.invisible) {
|
||||
filter.invisible = true;
|
||||
}
|
||||
if (filter.type === 'filter') {
|
||||
if (filter.isDefault) {
|
||||
filter.defaultRank = -5;
|
||||
}
|
||||
filter.domain = attrs.domain;
|
||||
filter.context = pyUtils.eval('context', attrs.context);
|
||||
if (attrs.date) {
|
||||
filter.fieldName = attrs.date;
|
||||
filter.fieldType = this.fields[attrs.date].type;
|
||||
filter.hasOptions = true;
|
||||
filter.options = this.optionGenerators;
|
||||
filter.defaultOptionId = attrs.default_period ||
|
||||
DEFAULT_PERIOD;
|
||||
filter.currentOptionIds = new Set();
|
||||
filter.basicDomains = this._getDateFilterBasicDomains(filter);
|
||||
}
|
||||
} else if (filter.type === 'groupBy') {
|
||||
if (filter.isDefault) {
|
||||
const val = this.searchDefaults[attrs.name];
|
||||
filter.defaultRank = typeof val === 'number' ? val : 100;
|
||||
}
|
||||
filter.fieldName = attrs.fieldName;
|
||||
filter.fieldType = this.fields[attrs.fieldName].type;
|
||||
if (_.contains(['date', 'datetime'], filter.fieldType)) {
|
||||
filter.hasOptions = true;
|
||||
filter.options = this.intervalOptions;
|
||||
filter.defaultOptionId = attrs.defaultInterval ||
|
||||
DEFAULT_INTERVAL;
|
||||
filter.currentOptionIds = new Set();
|
||||
}
|
||||
} else if (filter.type === 'field') {
|
||||
if (filter.isDefault) {
|
||||
filter.defaultRank = -10;
|
||||
}
|
||||
var field = this.fields[attrs.name];
|
||||
filter.attrs = attrs;
|
||||
filter.autoCompleteValues = [];
|
||||
if (filter.isDefault) {
|
||||
// on field, default can be used with a value
|
||||
filter.defaultValue = this.searchDefaults[filter.attrs.name];
|
||||
}
|
||||
if (!attrs.string) {
|
||||
attrs.string = field.string;
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Constructs an object containing various domains based on this.referenceMoment and
|
||||
* the field associated with the provided date filter.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} filter
|
||||
* @returns {Object}
|
||||
*/
|
||||
_getDateFilterBasicDomains: function (filter) {
|
||||
const _constructBasicDomain = (y, o) => {
|
||||
const setParam = Object.assign({}, y.setParam, o ? o.setParam : {});
|
||||
const granularity = o ? o.granularity : y.granularity;
|
||||
const date = this.referenceMoment.clone().set(setParam);
|
||||
let leftBound = date.clone().startOf(granularity).locale('en');
|
||||
let rightBound = date.clone().endOf(granularity).locale('en');
|
||||
|
||||
if (filter.fieldType === 'date') {
|
||||
leftBound = leftBound.format("YYYY-MM-DD");
|
||||
rightBound = rightBound.format("YYYY-MM-DD");
|
||||
} else {
|
||||
leftBound = leftBound.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
rightBound = rightBound.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
}
|
||||
const domain = Domain.prototype.arrayToString([
|
||||
'&',
|
||||
[filter.fieldName, ">=", leftBound],
|
||||
[filter.fieldName, "<=", rightBound]
|
||||
]);
|
||||
|
||||
let description;
|
||||
if (o) {
|
||||
description = o.description + " " + y.description;
|
||||
} else {
|
||||
description = y.description;
|
||||
}
|
||||
|
||||
return { domain, description };
|
||||
};
|
||||
|
||||
const domains = {};
|
||||
this.optionGenerators.filter(y => y.groupId === 2).forEach(y => {
|
||||
domains[y.optionId] = _constructBasicDomain(y);
|
||||
this.optionGenerators.filter(y => y.groupId === 1).forEach(o => {
|
||||
domains[y.optionId + "__" + o.optionId] = _constructBasicDomain(y, o);
|
||||
});
|
||||
});
|
||||
return domains;
|
||||
},
|
||||
/**
|
||||
* Parse the arch of a 'search' view.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} arch arch with root node <search>
|
||||
*/
|
||||
_parseSearchArch: function (arch) {
|
||||
var self = this;
|
||||
// a searchview arch may contain a 'searchpanel' node, but this isn't
|
||||
// the concern of the ControlPanelView (the SearchPanel will handle it).
|
||||
// Ideally, this code should whitelist the tags to take into account
|
||||
// instead of blacklisting the others, but with the current (messy)
|
||||
// structure of a searchview arch, it's way simpler to do it that way.
|
||||
var children = arch.children.filter(function (child) {
|
||||
return child.tag !== 'searchpanel';
|
||||
});
|
||||
var preFilters = _.flatten(children.map(function (child) {
|
||||
return child.tag !== 'group' ?
|
||||
self._evalArchChild(child) :
|
||||
child.children.map(self._evalArchChild);
|
||||
}));
|
||||
preFilters.push({tag: 'separator'});
|
||||
|
||||
var filter;
|
||||
var currentTag;
|
||||
var currentGroup = [];
|
||||
var groupOfGroupBys = [];
|
||||
var groupNumber = 1;
|
||||
|
||||
_.each(preFilters, function (preFilter) {
|
||||
if (preFilter.tag !== currentTag || _.contains(['separator', 'field'], preFilter.tag)) {
|
||||
if (currentGroup.length) {
|
||||
if (currentTag === 'groupBy') {
|
||||
groupOfGroupBys = groupOfGroupBys.concat(currentGroup);
|
||||
} else {
|
||||
self.loadParams.groups.push(currentGroup);
|
||||
}
|
||||
}
|
||||
currentTag = preFilter.tag;
|
||||
currentGroup = [];
|
||||
groupNumber++;
|
||||
}
|
||||
if (preFilter.tag !== 'separator') {
|
||||
filter = {
|
||||
type: preFilter.tag,
|
||||
// we need to codify here what we want to keep from attrs
|
||||
// and how, for now I put everything.
|
||||
// In some sence, some filter are active (totally determined, given)
|
||||
// and others are passive (require input(s) to become determined)
|
||||
// What is the right place to process the attrs?
|
||||
};
|
||||
if (filter.type === 'filter' || filter.type === 'groupBy') {
|
||||
filter.groupNumber = groupNumber;
|
||||
}
|
||||
self._extractAttributes(filter, preFilter.attrs);
|
||||
currentGroup.push(filter);
|
||||
}
|
||||
});
|
||||
if (groupOfGroupBys.length) {
|
||||
this.loadParams.groups.push(groupOfGroupBys);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return ControlPanelView;
|
||||
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
odoo.define('web.AddNewFavoriteMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
var core = require('web.core');
|
||||
var favoritesSubmenusRegistry = require('web.favorites_submenus_registry');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
var AddNewFavoriteMenu = Widget.extend({
|
||||
template: 'AddNewFavoriteMenu',
|
||||
events: _.extend({}, Widget.prototype.events, {
|
||||
'click .o_save_favorite': '_onSaveFavoriteClick',
|
||||
'click .o_add_favorite.o_menu_header': '_onMenuHeaderClick',
|
||||
'click input[type="checkbox"]': '_onCheckboxClick',
|
||||
'keyup .o_save_name input': '_onKeyUp',
|
||||
}),
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} params
|
||||
* @param {Object} params.action
|
||||
* @param {Object} params.favorites
|
||||
*/
|
||||
init: function (parent, params) {
|
||||
this._super(parent);
|
||||
this.favorites = params.favorites;
|
||||
this.action = params.action;
|
||||
this.isOpen = false;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
this._render();
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
*/
|
||||
update: function (params) {
|
||||
this.favorites = params.favorites;
|
||||
},
|
||||
/**
|
||||
* Close the menu and re-render the widget.
|
||||
*/
|
||||
closeMenu: function () {
|
||||
this.isOpen = false;
|
||||
this._render();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_render: function () {
|
||||
this.renderElement();
|
||||
if (this.isOpen) {
|
||||
var $input = this.$('.o_favorite_name input.o_input');
|
||||
$input.val(this.action.name);
|
||||
$input.focus();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_saveFavorite: function () {
|
||||
var self = this;
|
||||
var $inputs = this.$('input');
|
||||
var description = $inputs[0].value;
|
||||
var isDefault = $inputs[1].checked;
|
||||
var isShared = $inputs[2].checked;
|
||||
|
||||
if (!description.length){
|
||||
this.do_warn(_t("Error"), _t("A name for your favorite is required."));
|
||||
$inputs[0].focus();
|
||||
return;
|
||||
}
|
||||
var descriptionAlreadyExists = !!this.favorites.find(function (favorite) {
|
||||
return favorite.description === description;
|
||||
});
|
||||
if (descriptionAlreadyExists) {
|
||||
this.do_warn(_t("Error"), _t("Filter with same name already exists."));
|
||||
$inputs[0].focus();
|
||||
return;
|
||||
}
|
||||
this.trigger_up('new_favorite', {
|
||||
type: 'favorite',
|
||||
description: description,
|
||||
isDefault: isDefault,
|
||||
isShared: isShared,
|
||||
on_success: function () {self.generatorMenuIsOpen = false;},
|
||||
});
|
||||
this.closeMenu();
|
||||
},
|
||||
/**
|
||||
* Hide and display the submenu which allows adding custom filters.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_toggleMenu: function () {
|
||||
this.isOpen = !this.isOpen;
|
||||
this._render();
|
||||
this.trigger_up('favorite_submenu_toggled');
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Additional behavior when a checkbox is clicked upon
|
||||
* Namely, some checkbox are mutually exclusive
|
||||
* this function allows this
|
||||
*
|
||||
* @private
|
||||
* @param {jQueryEvent} ev
|
||||
*/
|
||||
_onCheckboxClick: function (ev) {
|
||||
function mutuallyExclusive (elem) {
|
||||
if (!elem.id) return false;
|
||||
return ['use_by_default', 'share_all_users'].some(function (str) {
|
||||
return elem.id.indexOf(str);
|
||||
});
|
||||
}
|
||||
var $checkboxes = this.$('input[type="checkbox"]');
|
||||
var currentCheckBox = ev.target;
|
||||
|
||||
if (mutuallyExclusive(currentCheckBox)) {
|
||||
for (var i=0; i < $checkboxes.length; i++) {
|
||||
var checkbox = $checkboxes[i];
|
||||
if (checkbox !== currentCheckBox && mutuallyExclusive(checkbox)) {
|
||||
checkbox.checked = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {jQueryEvent} ev
|
||||
*/
|
||||
_onKeyUp: function (ev) {
|
||||
if (ev.which === $.ui.keyCode.ENTER) {
|
||||
this._saveFavorite();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {jQueryEvent} ev
|
||||
*/
|
||||
_onMenuHeaderClick: function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this._toggleMenu();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {jQueryEvent} ev
|
||||
*/
|
||||
_onSaveFavoriteClick: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this._saveFavorite();
|
||||
},
|
||||
});
|
||||
|
||||
favoritesSubmenusRegistry.add('add_new_favorite_menu', AddNewFavoriteMenu, 0);
|
||||
|
||||
return AddNewFavoriteMenu;
|
||||
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
odoo.define('web.controlPanelViewParameters', function (require) {
|
||||
"use strict";
|
||||
|
||||
var core = require('web.core');
|
||||
|
||||
var _lt = core._lt;
|
||||
|
||||
// for FilterMenu
|
||||
const DEFAULT_PERIOD = 'this_month';
|
||||
const PERIOD_OPTIONS = [
|
||||
{ description: _lt('Last 7 Days'), optionId: 'last_7_days', groupId: 1 },
|
||||
{ description: _lt('Last 30 Days'), optionId: 'last_30_days', groupId: 1 },
|
||||
{ description: _lt('Last 365 Days'), optionId: 'last_365_days', groupId: 1 },
|
||||
{ description: _lt('Last 5 Years'), optionId: 'last_5_years', groupId: 1 },
|
||||
{ description: _lt('Today'), optionId: 'today', groupId: 2 },
|
||||
{ description: _lt('This Week'), optionId: 'this_week', groupId: 2 },
|
||||
{ description: _lt('This Month'), optionId: 'this_month', groupId: 2 },
|
||||
{ description: _lt('This Quarter'), optionId: 'this_quarter', groupId: 2 },
|
||||
{ description: _lt('This Year'), optionId: 'this_year', groupId: 2 },
|
||||
{ description: _lt('Yesterday'), optionId: 'yesterday', groupId: 3 },
|
||||
{ description: _lt('Last Week'), optionId: 'last_week', groupId: 3 },
|
||||
{ description: _lt('Last Month'), optionId: 'last_month', groupId: 3 },
|
||||
{ description: _lt('Last Quarter'), optionId: 'last_quarter', groupId: 3 },
|
||||
{ description: _lt('Last Year'), optionId: 'last_year', groupId: 3 },
|
||||
];
|
||||
const MONTH_OPTIONS = [
|
||||
{ optionId: 'this_month', groupId: 1, format: 'MMMM', addParam: {}, setParam: {}, granularity: 'month' },
|
||||
{ optionId: 'last_month', groupId: 1, format: 'MMMM', addParam: { months: -1 }, setParam: {}, granularity: 'month' },
|
||||
{ optionId: 'antepenultimate_month', groupId: 1, format: 'MMMM', addParam: { months: -2 }, setParam: {}, granularity: 'month' }
|
||||
]
|
||||
const QUARTER_OPTIONS = [
|
||||
{ optionId: 'fourth_quarter', groupId: 1, description: _lt("Q4"), addParam: {}, setParam: { quarter: 4 }, granularity: 'quarter' },
|
||||
{ optionId: 'third_quarter', groupId: 1, description: _lt("Q3"), addParam: {}, setParam: { quarter: 3 }, granularity: 'quarter' },
|
||||
{ optionId: 'second_quarter', groupId: 1, description: _lt("Q2"), addParam: {}, setParam: { quarter: 2 }, granularity: 'quarter' },
|
||||
{ optionId: 'first_quarter', groupId: 1, description: _lt("Q1"), addParam: {}, setParam: { quarter: 1 }, granularity: 'quarter' }
|
||||
]
|
||||
const YEAR_OPTIONS = [
|
||||
{ optionId: 'this_year', groupId: 2, format: 'YYYY', addParam: {}, setParam: {}, granularity: 'year' },
|
||||
{ optionId: 'last_year', groupId: 2, format: 'YYYY', addParam: { years: -1 }, setParam: {}, granularity: 'year' },
|
||||
{ optionId: 'antepenultimate_year', groupId: 2, format: 'YYYY', addParam: { years: -2 }, setParam: {}, granularity: 'year' },
|
||||
];
|
||||
const OPTION_GENERATORS = [...MONTH_OPTIONS, ...QUARTER_OPTIONS, ...YEAR_OPTIONS];
|
||||
|
||||
// for GroupBy menu
|
||||
const GROUPABLE_TYPES = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime', 'integer'];
|
||||
const DEFAULT_INTERVAL = 'month';
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ description: _lt('Year'), optionId: 'year', groupId: 1 },
|
||||
{ description: _lt('Quarter'), optionId: 'quarter', groupId: 1 },
|
||||
{ description: _lt('Month'), optionId: 'month', groupId: 1 },
|
||||
{ description: _lt('Week'), optionId: 'week', groupId: 1 },
|
||||
{ description: _lt('Day'), optionId: 'day', groupId: 1 },
|
||||
];
|
||||
function rank(oId) {
|
||||
return INTERVAL_OPTIONS.findIndex(o => o.optionId === oId);
|
||||
}
|
||||
|
||||
// for TimeRangeMenu
|
||||
const DEFAULT_TIMERANGE = DEFAULT_PERIOD;
|
||||
const TIME_RANGE_OPTIONS = PERIOD_OPTIONS;
|
||||
const COMPARISON_TIME_RANGE_OPTIONS = [
|
||||
{ description: _lt('Previous Period'), optionId: 'previous_period' },
|
||||
{ description: _lt('Previous Year'), optionId: 'previous_year' }
|
||||
];
|
||||
|
||||
return {
|
||||
COMPARISON_TIME_RANGE_OPTIONS: COMPARISON_TIME_RANGE_OPTIONS,
|
||||
DEFAULT_INTERVAL: DEFAULT_INTERVAL,
|
||||
DEFAULT_PERIOD: DEFAULT_PERIOD,
|
||||
DEFAULT_TIMERANGE: DEFAULT_TIMERANGE,
|
||||
GROUPABLE_TYPES: GROUPABLE_TYPES,
|
||||
INTERVAL_OPTIONS: INTERVAL_OPTIONS,
|
||||
OPTION_GENERATORS: OPTION_GENERATORS,
|
||||
PERIOD_OPTIONS: PERIOD_OPTIONS,
|
||||
TIME_RANGE_OPTIONS: TIME_RANGE_OPTIONS,
|
||||
YEAR_OPTIONS: YEAR_OPTIONS,
|
||||
rank: rank,
|
||||
};
|
||||
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
odoo.define('web.FavoriteMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
var Dialog = require('web.Dialog');
|
||||
var DropdownMenu = require('web.DropdownMenu');
|
||||
var favorites_submenus_registry = require('web.favorites_submenus_registry');
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
var FavoriteMenu = DropdownMenu.extend({
|
||||
custom_events: _.extend({}, DropdownMenu.prototype.custom_events, {
|
||||
favorite_submenu_toggled: '_onSubMenuToggled',
|
||||
}),
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} action
|
||||
*/
|
||||
init: function (parent, favorites, action) {
|
||||
this._super(parent, favorites);
|
||||
this.action = action;
|
||||
this.isMobile = config.device.isMobile;
|
||||
this.dropdownCategory = 'favorite';
|
||||
this.dropdownTitle = _t('Favorites');
|
||||
this.dropdownIcon = 'fa fa-star';
|
||||
this.dropdownSymbol = this.isMobile ? 'fa fa-chevron-right float-right mt4' : false;
|
||||
this.dropdownStyle.mainButton.class = 'o_favorites_menu_button ' +
|
||||
this.dropdownStyle.mainButton.class;
|
||||
|
||||
},
|
||||
/**
|
||||
* Render the template used to register a new favorite and append it
|
||||
* to the basic dropdown menu.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
var self = this;
|
||||
var params = {
|
||||
favorites: this.items,
|
||||
action: this.action,
|
||||
};
|
||||
var superProm = this._super.apply(this, arguments);
|
||||
this.$menu.addClass('o_favorites_menu');
|
||||
this.subMenus = [];
|
||||
favorites_submenus_registry.values().forEach(function (SubMenu) {
|
||||
var subMenu = new SubMenu(self, params);
|
||||
subMenu.appendTo(self.$menu);
|
||||
self.subMenus.push(subMenu);
|
||||
});
|
||||
return superProm;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
update: function (items) {
|
||||
this._super.apply(this, arguments);
|
||||
_.invoke(this.subMenus, 'update', { favorites: this.items });
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_closeSubMenus: function () {
|
||||
_.invoke(this.subMenus, 'closeMenu');
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_onBootstrapClose: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this._closeSubMenus();
|
||||
},
|
||||
/**
|
||||
* Reacts to a submenu being toggled
|
||||
*
|
||||
* When a submenu is toggled, it has changed the position
|
||||
* and size of the Favorite's dropdown. This method
|
||||
* repositions the current dropdown
|
||||
*
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*
|
||||
*/
|
||||
_onSubMenuToggled: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this.$dropdownReference.dropdown('update');
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onTrashButtonClick: function (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
var self = this;
|
||||
var id = $(event.currentTarget).data('id');
|
||||
var favorite = this.items.find(function (favorite) {
|
||||
return favorite.id === id;
|
||||
});
|
||||
var globalWarning = _t("This filter is global and will be removed for everybody if you continue.");
|
||||
var warning = _t("Are you sure that you want to remove this filter?");
|
||||
var message = favorite.userId ? warning : globalWarning;
|
||||
|
||||
Dialog.confirm(self, message, {
|
||||
title: _t("Warning"),
|
||||
confirm_callback: function () {
|
||||
self.trigger_up('item_trashed', {id: id});
|
||||
},
|
||||
});
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
return FavoriteMenu;
|
||||
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
odoo.define('web.favorites_submenus_registry', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Registry = require('web.Registry');
|
||||
|
||||
return new Registry();
|
||||
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
odoo.define('web.FilterMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
var Domain = require('web.Domain');
|
||||
var DropdownMenu = require('web.DropdownMenu');
|
||||
var search_filters = require('web.search_filters');
|
||||
|
||||
var _t = core._t;
|
||||
var QWeb = core.qweb;
|
||||
|
||||
var FilterMenu = DropdownMenu.extend({
|
||||
custom_events: {
|
||||
confirm_proposition: '_onConfirmProposition',
|
||||
remove_proposition: '_onRemoveProposition',
|
||||
},
|
||||
events: _.extend({}, DropdownMenu.prototype.events, {
|
||||
'click .o_add_custom_filter': '_onAddCustomFilterClick',
|
||||
'click .o_add_condition': '_onAddCondition',
|
||||
'click .o_apply_filter': '_onApplyClick',
|
||||
}),
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} fields
|
||||
*/
|
||||
init: function (parent, filters, fields) {
|
||||
this._super(parent, filters);
|
||||
|
||||
// determines where the filter menu is displayed and its style
|
||||
this.isMobile = config.device.isMobile;
|
||||
// determines when the 'Add custom filter' submenu is open
|
||||
this.generatorMenuIsOpen = false;
|
||||
this.propositions = [];
|
||||
this.fields = _.pick(fields, function (field, name) {
|
||||
return field.selectable !== false && name !== 'id';
|
||||
});
|
||||
this.fields.id = {string: 'ID', type: 'id', searchable: true};
|
||||
this.dropdownCategory = 'filter';
|
||||
this.dropdownTitle = _t('Filters');
|
||||
this.dropdownIcon = 'fa fa-filter';
|
||||
this.dropdownSymbol = this.isMobile ?
|
||||
'fa fa-chevron-right float-right mt4' :
|
||||
false;
|
||||
this.dropdownStyle.mainButton.class = 'o_filters_menu_button ' +
|
||||
this.dropdownStyle.mainButton.class;
|
||||
},
|
||||
/**
|
||||
* Render the template used to add a new custom filter and append it
|
||||
* to the basic dropdown menu.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
var superProm = this._super.apply(this, arguments);
|
||||
this.$menu.addClass('o_filters_menu');
|
||||
this._renderGeneratorMenu();
|
||||
return superProm;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add a proposition inside the custom filter edition menu.
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_appendProposition: function () {
|
||||
// make modern sear_filters code!!! It works but...
|
||||
var prop = new search_filters.ExtendedSearchProposition(this, this.fields);
|
||||
this.propositions.push(prop);
|
||||
this.$('.o_apply_filter').prop('disabled', false);
|
||||
return prop.insertBefore(this.$addFilterMenu);
|
||||
},
|
||||
/**
|
||||
* Confirm a filter proposition, creates it and add it to the menu.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_commitSearch: function () {
|
||||
var filters = _.invoke(this.propositions, 'get_filter').map(function (preFilter) {
|
||||
return {
|
||||
type: 'filter',
|
||||
description: preFilter.attrs.string,
|
||||
domain: Domain.prototype.arrayToString(preFilter.attrs.domain),
|
||||
};
|
||||
});
|
||||
this.trigger_up('new_filters', {filters: filters});
|
||||
_.invoke(this.propositions, 'destroy');
|
||||
this.propositions = [];
|
||||
this._toggleCustomFilterMenu();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_renderGeneratorMenu: function () {
|
||||
this.$el.find('.o_generator_menu').remove();
|
||||
if (!this.generatorMenuIsOpen) {
|
||||
_.invoke(this.propositions, 'destroy');
|
||||
this.propositions = [];
|
||||
}
|
||||
var $generatorMenu = QWeb.render('FilterMenuGenerator', {widget: this});
|
||||
this.$menu.append($generatorMenu);
|
||||
this.$addFilterMenu = this.$menu.find('.o_add_filter_menu');
|
||||
if (this.generatorMenuIsOpen && !this.propositions.length) {
|
||||
this._appendProposition();
|
||||
}
|
||||
this.$dropdownReference.dropdown('update');
|
||||
},
|
||||
/**
|
||||
* Hide and display the submenu which allows adding custom filters.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_toggleCustomFilterMenu: function () {
|
||||
this.generatorMenuIsOpen = !this.generatorMenuIsOpen;
|
||||
this._renderGeneratorMenu();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onAddCondition: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this._appendProposition();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onAddCustomFilterClick: function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this._toggleCustomFilterMenu();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onApplyClick: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this._commitSearch();
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
* @param {jQueryEvent} ev
|
||||
*/
|
||||
_onBootstrapClose: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this.generatorMenuIsOpen = false;
|
||||
this._renderGeneratorMenu();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onConfirmProposition: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this._commitSearch();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onRemoveProposition: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this.propositions = _.without(this.propositions, ev.target);
|
||||
if (!this.propositions.length) {
|
||||
this.$('.o_apply_filter').prop('disabled', true);
|
||||
}
|
||||
ev.target.destroy();
|
||||
},
|
||||
});
|
||||
|
||||
return FilterMenu;
|
||||
|
||||
});
|
||||
@@ -1,206 +0,0 @@
|
||||
odoo.define('web.GroupByMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
var config = require('web.config');
|
||||
var controlPanelViewParameters = require('web.controlPanelViewParameters');
|
||||
var core = require('web.core');
|
||||
var DropdownMenu = require('web.DropdownMenu');
|
||||
|
||||
var _t = core._t;
|
||||
var QWeb = core.qweb;
|
||||
|
||||
var DEFAULT_INTERVAL = controlPanelViewParameters.DEFAULT_INTERVAL;
|
||||
var GROUPABLE_TYPES = controlPanelViewParameters.GROUPABLE_TYPES;
|
||||
var INTERVAL_OPTIONS = controlPanelViewParameters.INTERVAL_OPTIONS;
|
||||
|
||||
var GroupByMenu = DropdownMenu.extend({
|
||||
events: _.extend({}, DropdownMenu.prototype.events, {
|
||||
'click .o_add_custom_group': '_onAddCustomGroupClick',
|
||||
'click button.o_apply_group': '_onButtonApplyClick',
|
||||
'click .o_group_selector': '_onGroupSelectorClick',
|
||||
}),
|
||||
/**
|
||||
* @override
|
||||
* @param {Widget} parent
|
||||
* @param {Object[]} groupBys list of groupBys
|
||||
* }
|
||||
* @param {Object} fields 'field_get' of a model: mapping from field name
|
||||
* to an object with its properties
|
||||
* @param {Object} options
|
||||
* @param {string} options.headerStyle conditions the style of the main button
|
||||
*/
|
||||
init: function (parent, groupBys, fields, options) {
|
||||
var self = this;
|
||||
options = options || {};
|
||||
this._super(parent, groupBys);
|
||||
this.fields = fields;
|
||||
// determines when the 'Add custom groupby' submenu is open
|
||||
this.generatorMenuIsOpen = false;
|
||||
// determines list of options used by groupBys of type 'date'
|
||||
this.groupableFields = [];
|
||||
_.each(fields, function (field, name) {
|
||||
if (field.sortable && name !== "id" && _.contains(GROUPABLE_TYPES, field.type)) {
|
||||
self.groupableFields.push(_.extend({}, field, {
|
||||
name: name,
|
||||
}));
|
||||
}
|
||||
});
|
||||
this.groupableFields = _.sortBy(this.groupableFields, 'string');
|
||||
// determines the list of field names that can be added to the menu
|
||||
// via the 'Add Custom Groupby' menu
|
||||
this.presentedFields = this._setPresentedFields(groupBys);
|
||||
|
||||
// determines where the filter menu is displayed and partly its style
|
||||
this.isMobile = config.device.isMobile;
|
||||
|
||||
this.dropdownCategory = 'groupby';
|
||||
this.dropdownTitle = _t('Group By');
|
||||
this.dropdownIcon = 'fa fa-bars';
|
||||
this.dropdownSymbol = this.isMobile && !options.noSymbol ? 'fa fa-chevron-right float-right mt4' : false;
|
||||
// the default style of the groupby menu can be changed here using the options key 'headerStyle'
|
||||
if (options.headerStyle === 'primary') {
|
||||
this.dropdownStyle = {
|
||||
el: {class: 'btn-group o_group_by_menu o_dropdown', attrs: {'role': 'group'}},
|
||||
mainButton: {class: 'btn btn-primary dropdown-toggle'},
|
||||
};
|
||||
}
|
||||
INTERVAL_OPTIONS = INTERVAL_OPTIONS.map(function (option) {
|
||||
return _.extend(option, {description: option.description.toString()});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Render the template used to add a new custom groupby and append it
|
||||
* to the basic dropdown menu.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
var superProm = this._super.apply(this, arguments);
|
||||
this.$menu.addClass('o_group_by_menu');
|
||||
this._renderGeneratorMenu();
|
||||
return superProm;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {Object[]} groupBys
|
||||
*/
|
||||
update: function (groupBys) {
|
||||
this._super.apply(this, arguments);
|
||||
this.presentedFields = this._setPresentedFields(groupBys);
|
||||
this._renderGeneratorMenu();
|
||||
},
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Method called via the 'Add Custom Groupby' menu to create a new groupby
|
||||
* and add it (activated) to the menu. In case the field is of type date, it
|
||||
* is activated using the option determined by the parameter
|
||||
* 'DEFAULT_INTERVAL'.
|
||||
*
|
||||
* @private
|
||||
* @param {string} fieldName
|
||||
*/
|
||||
_addGroupby: function (fieldName) {
|
||||
var field = this.presentedFields.find(function (field) {
|
||||
return field.name === fieldName;
|
||||
});
|
||||
var groupBy = {
|
||||
type: 'groupBy',
|
||||
description: field.string,
|
||||
fieldName: fieldName,
|
||||
fieldType: field.type,
|
||||
};
|
||||
if (_.contains(['date', 'datetime'], field.type)) {
|
||||
groupBy.hasOptions = true;
|
||||
groupBy.options = INTERVAL_OPTIONS;
|
||||
groupBy.defaultOptionId = DEFAULT_INTERVAL;
|
||||
groupBy.currentOptionIds = new Set([]);
|
||||
}
|
||||
this.trigger_up('new_groupBy', groupBy);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_renderGeneratorMenu: function () {
|
||||
this.$el.find('.o_generator_menu').remove();
|
||||
var $generatorMenu = QWeb.render('GroupByMenuGenerator', {widget: this});
|
||||
this.$menu.append($generatorMenu);
|
||||
this.$addCustomGroup = this.$menu.find('.o_add_custom_group');
|
||||
this.$groupSelector = this.$menu.find('.o_group_selector');
|
||||
this.$dropdownReference.dropdown('update');
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_setPresentedFields: function (groupBys) {
|
||||
return this.groupableFields.filter(function (field) {
|
||||
var groupByFields = _.pluck(groupBys, 'fieldName');
|
||||
return !_.contains(groupByFields, field.name);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_toggleCustomGroupMenu: function () {
|
||||
this.generatorMenuIsOpen = !this.generatorMenuIsOpen;
|
||||
this._renderGeneratorMenu();
|
||||
if (this.generatorMenuIsOpen) {
|
||||
this.$groupSelector.focus();
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Toggle the 'Add Custom Group'.
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onAddCustomGroupClick: function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this._toggleCustomGroupMenu();
|
||||
},
|
||||
/*
|
||||
* @override
|
||||
* @private
|
||||
* @param {jQueryEvent} ev
|
||||
*/
|
||||
_onBootstrapClose: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this.generatorMenuIsOpen = false;
|
||||
this._renderGeneratorMenu();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onButtonApplyClick: function (ev) {
|
||||
ev.stopPropagation();
|
||||
var fieldName = this.$groupSelector.val();
|
||||
this.generatorMenuIsOpen = false;
|
||||
this._addGroupby(fieldName);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onGroupSelectorClick: function (ev) {
|
||||
ev.stopPropagation();
|
||||
},
|
||||
});
|
||||
|
||||
return GroupByMenu;
|
||||
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
odoo.define('web.GroupByMenuMixin', function (require) {
|
||||
"use strict";
|
||||
|
||||
var GroupByMenu = require('web.GroupByMenu');
|
||||
var controlPanelViewParameters = require('web.controlPanelViewParameters');
|
||||
|
||||
/**
|
||||
* The aim of this mixin is to facilitate the interaction between
|
||||
* a view controller and a dropdown menu with its control panel
|
||||
* TO DO: the pivot subview has two types of groupbys so that it will not
|
||||
* understand the current implementation of this mixin
|
||||
*
|
||||
* @mixin
|
||||
* @name GroupByMenuMixin
|
||||
*/
|
||||
var GroupByMenuMixin = {
|
||||
init: function () {
|
||||
this.custom_events = _.extend({}, this.custom_events, {
|
||||
menu_item_clicked: '_onMenuItemClicked',
|
||||
item_option_clicked: '_onItemOptionClicked',
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Instantiate a widget GroupByMenu and incorporate it to the control panel
|
||||
* of an embedded view. This is used for instance in the dashboard view in
|
||||
* enterprise where there is no GroupBy menu in the search view because it
|
||||
* would not make sense to have one at the global level.
|
||||
* This function is called by renderButtons when the parameter
|
||||
* 'this.isEmbedded' is set to true.
|
||||
*
|
||||
* private
|
||||
* @param {jQuery} $node
|
||||
* @param {Object} groupableFields
|
||||
* @param {Promise}
|
||||
*/
|
||||
_addGroupByMenu: function ($node, groupableFields) {
|
||||
this.sortedFieldNames = Object.keys(groupableFields).sort();
|
||||
this.groupableFields = groupableFields;
|
||||
const groupBys = this._getGroupBys(this.model.get().groupBy);
|
||||
this.groupByMenu = new GroupByMenu(this, groupBys, this.groupableFields, {noSymbol: true});
|
||||
return this.groupByMenu.insertAfter($node.find('div:first'));
|
||||
},
|
||||
/**
|
||||
* This method puts the active groupBys in a convenient form.
|
||||
*
|
||||
* @private
|
||||
* @param {string[]} activeGroupBys
|
||||
* returns {Object[]} groupBysNormalized
|
||||
*/
|
||||
_normalizeActiveGroupBys: function (activeGroupBys) {
|
||||
return activeGroupBys.map(gb => {
|
||||
const fieldName = gb.split(':')[0];
|
||||
const field = this.groupableFields[fieldName];
|
||||
const ngb = {fieldName: fieldName};
|
||||
if (_.contains(['date', 'datetime'], field.type)) {
|
||||
ngb.interval = gb.split(':')[1] || controlPanelViewParameters.DEFAULT_INTERVAL;
|
||||
}
|
||||
return ngb;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* This method has to be implemented by the view controller that needs to
|
||||
* interpret the click in an appropriate manner.
|
||||
*
|
||||
* @private
|
||||
* @param {string[]} groupBys
|
||||
*/
|
||||
_setGroupby: function (groupBys) {},
|
||||
/**
|
||||
* Return the list of groupBys in a form suitable for the groupByMenu. We do
|
||||
* this each time because we want to be synchronized with the view model.
|
||||
*
|
||||
* @private
|
||||
* @param {string[]} activeGroupBys
|
||||
* returns {Object[]} groupBys
|
||||
*/
|
||||
_getGroupBys: function (activeGroupBys) {
|
||||
const groupBysNormalized = this._normalizeActiveGroupBys(activeGroupBys);
|
||||
return this.sortedFieldNames.map(fieldName => {
|
||||
const field = this.groupableFields[fieldName];
|
||||
const groupByActivity = groupBysNormalized.filter(gb => (gb.fieldName === fieldName));
|
||||
const groupBy = {
|
||||
id: fieldName,
|
||||
isActive: groupByActivity.length ? true : false,
|
||||
description: field.string,
|
||||
};
|
||||
if (_.contains(['date', 'datetime'], field.type)) {
|
||||
groupBy.hasOptions = true;
|
||||
groupBy.options = controlPanelViewParameters.INTERVAL_OPTIONS;
|
||||
groupBy.currentOptionIds = groupByActivity.length ?
|
||||
new Set(groupByActivity.map(gb => gb.interval)) :
|
||||
new Set([]);
|
||||
}
|
||||
return groupBy;
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onItemOptionClicked: function (ev) {
|
||||
const fieldName = ev.data.id;
|
||||
const optionId = ev.data.optionId;
|
||||
const activeGroupBys = this.model.get().groupBy;
|
||||
const groupBysNormalized = this._normalizeActiveGroupBys(activeGroupBys);
|
||||
const index = groupBysNormalized.findIndex(ngb =>
|
||||
ngb.fieldName === fieldName && ngb.interval === optionId);
|
||||
if (index === -1) {
|
||||
activeGroupBys.push(fieldName + ':' + optionId);
|
||||
} else {
|
||||
activeGroupBys.splice(index, 1);
|
||||
}
|
||||
this._setGroupby(activeGroupBys);
|
||||
this.groupByMenu.update(this._getGroupBys(activeGroupBys));
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onMenuItemClicked: function (ev) {
|
||||
const fieldName = ev.data.id;
|
||||
const activeGroupBys = this.model.get().groupBy;
|
||||
const groupByFieldNames = activeGroupBys.map(gb => gb.split(':')[0]);
|
||||
const indexOfGroupby = groupByFieldNames.indexOf(fieldName);
|
||||
if (indexOfGroupby === -1) {
|
||||
activeGroupBys.push(fieldName);
|
||||
} else {
|
||||
activeGroupBys.splice(indexOfGroupby, 1);
|
||||
}
|
||||
this._setGroupby(activeGroupBys);
|
||||
this.groupByMenu.update(this._getGroupBys(activeGroupBys));
|
||||
},
|
||||
};
|
||||
|
||||
return GroupByMenuMixin;
|
||||
|
||||
});
|
||||
@@ -1,260 +0,0 @@
|
||||
odoo.define('web.SearchBar', function (require) {
|
||||
"use strict";
|
||||
|
||||
var AutoComplete = require('web.AutoComplete');
|
||||
var searchBarAutocompleteRegistry = require('web.search_bar_autocomplete_sources_registry');
|
||||
var SearchFacet = require('web.SearchFacet');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
var SearchBar = Widget.extend({
|
||||
template: 'SearchView.SearchBar',
|
||||
events: _.extend({}, Widget.prototype.events, {
|
||||
'compositionend .o_searchview_input': '_onCompositionendInput',
|
||||
'compositionstart .o_searchview_input': '_onCompositionstartInput',
|
||||
'keydown': '_onKeydown',
|
||||
}),
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} [params]
|
||||
* @param {Object} [params.context]
|
||||
* @param {Object[]} [params.facets]
|
||||
* @param {Object} [params.fields]
|
||||
* @param {Object[]} [params.filterFields]
|
||||
* @param {Object[]} [params.filters]
|
||||
* @param {Object[]} [params.groupBys]
|
||||
*/
|
||||
init: function (parent, params) {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
this.context = params.context;
|
||||
|
||||
this.facets = params.facets;
|
||||
this.fields = params.fields;
|
||||
this.filterFields = params.filterFields;
|
||||
|
||||
this.autoCompleteSources = [];
|
||||
this.searchFacets = [];
|
||||
this._isInputComposing = false;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
this.$input = this.$('input');
|
||||
var self = this;
|
||||
var defs = [this._super.apply(this, arguments)];
|
||||
_.each(this.facets, function (facet) {
|
||||
defs.push(self._renderFacet(facet));
|
||||
});
|
||||
defs.push(this._setupAutoCompletion());
|
||||
return Promise.all(defs);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Focus the searchbar.
|
||||
*/
|
||||
focus: function () {
|
||||
this.$input.focus();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_focusFollowing: function () {
|
||||
var focusedIndex = this._getFocusedFacetIndex();
|
||||
var $toFocus;
|
||||
if (focusedIndex === this.searchFacets.length - 1) {
|
||||
$toFocus = this.$input;
|
||||
} else {
|
||||
$toFocus = this.searchFacets.length && this.searchFacets[focusedIndex + 1].$el;
|
||||
}
|
||||
|
||||
if ($toFocus.length) {
|
||||
$toFocus.focus();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_focusPreceding: function () {
|
||||
var focusedIndex = this._getFocusedFacetIndex();
|
||||
var $toFocus;
|
||||
if (focusedIndex === -1) {
|
||||
$toFocus = this.searchFacets.length && _.last(this.searchFacets).$el;
|
||||
} else if (focusedIndex === 0) {
|
||||
$toFocus = this.$input;
|
||||
} else {
|
||||
$toFocus = this.searchFacets.length && this.searchFacets[focusedIndex - 1].$el;
|
||||
}
|
||||
|
||||
if ($toFocus.length) {
|
||||
$toFocus.focus();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {integer}
|
||||
*/
|
||||
_getFocusedFacetIndex: function () {
|
||||
return _.findIndex(this.searchFacets, function (searchFacet) {
|
||||
return searchFacet.$el[0] === document.activeElement;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Provide auto-completion result for req.term.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} req request to complete
|
||||
* @param {String} req.term searched term to complete
|
||||
* @param {Function} callback
|
||||
*/
|
||||
_getAutoCompleteSources: function (req, callback) {
|
||||
var defs = this.autoCompleteSources.map(function (source) {
|
||||
return source.getAutocompletionValues(req.term);
|
||||
});
|
||||
Promise.all(defs).then(function (result) {
|
||||
var resultCleaned = _(result).chain()
|
||||
.compact()
|
||||
.flatten(true)
|
||||
.value();
|
||||
callback(resultCleaned);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} facet
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_renderFacet: function (facet) {
|
||||
var searchFacet = new SearchFacet(this, facet);
|
||||
this.searchFacets.push(searchFacet);
|
||||
return searchFacet.insertBefore(this.$('input'));
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_setupAutoCompletion: function () {
|
||||
var self = this;
|
||||
this._setupAutoCompletionWidgets();
|
||||
this.autoComplete = new AutoComplete(this, {
|
||||
$input: this.$('input'),
|
||||
source: this._getAutoCompleteSources.bind(this),
|
||||
select: this._onAutoCompleteSelected.bind(this),
|
||||
get_search_string: function () {
|
||||
return self.$input.val().trim();
|
||||
},
|
||||
});
|
||||
return this.autoComplete.appendTo(this.$el);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_setupAutoCompletionWidgets: function () {
|
||||
var self = this;
|
||||
var registry = searchBarAutocompleteRegistry;
|
||||
_.each(this.filterFields, function (filter) {
|
||||
var field = self.fields[filter.attrs.name];
|
||||
var Obj = registry.getAny([filter.attrs.widget, field.type]);
|
||||
if (Obj) {
|
||||
self.autoCompleteSources.push(new (Obj) (self, filter, field, self.context));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Event} e
|
||||
* @param {Object} ui
|
||||
* @param {Object} ui.item selected completion item
|
||||
*/
|
||||
_onAutoCompleteSelected: function (e, ui) {
|
||||
e.preventDefault();
|
||||
var facet = ui.item.facet;
|
||||
if (!facet) {
|
||||
// this happens when selecting "(no result)" item
|
||||
this.trigger_up('reset');
|
||||
return;
|
||||
}
|
||||
var filter = facet.filter;
|
||||
if (filter.type === 'field') {
|
||||
var values = filter.autoCompleteValues;
|
||||
values.push(facet.values[0]);
|
||||
this.trigger_up('autocompletion_filter', {
|
||||
filterId: filter.id,
|
||||
autoCompleteValues: values,
|
||||
});
|
||||
} else {
|
||||
this.trigger_up('autocompletion_filter', {
|
||||
filterId: filter.id,
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @rivate
|
||||
* @param {CompositionEvent} ev
|
||||
*/
|
||||
_onCompositionendInput: function () {
|
||||
this._isInputComposing = false;
|
||||
},
|
||||
/**
|
||||
* @rivate
|
||||
* @param {CompositionEvent} ev
|
||||
*/
|
||||
_onCompositionstartInput: function () {
|
||||
this._isInputComposing = true;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {KeyEvent} e
|
||||
*/
|
||||
_onKeydown: function (e) {
|
||||
if (this._isInputComposing) {
|
||||
return;
|
||||
}
|
||||
switch(e.which) {
|
||||
case $.ui.keyCode.LEFT:
|
||||
this._focusPreceding();
|
||||
e.preventDefault();
|
||||
break;
|
||||
case $.ui.keyCode.RIGHT:
|
||||
this._focusFollowing();
|
||||
e.preventDefault();
|
||||
break;
|
||||
case $.ui.keyCode.DOWN:
|
||||
// if the searchbar dropdown is closed, try to focus the renderer
|
||||
const $dropdown = this.$('.o_searchview_autocomplete:visible');
|
||||
if (!$dropdown.length) {
|
||||
this.trigger_up('navigation_move', { direction: 'down' });
|
||||
e.preventDefault();
|
||||
}
|
||||
break;
|
||||
case $.ui.keyCode.BACKSPACE:
|
||||
if (this.$input.val() === '') {
|
||||
this.trigger_up('facet_removed');
|
||||
}
|
||||
break;
|
||||
case $.ui.keyCode.ENTER:
|
||||
if (this.$input.val() === '') {
|
||||
this.trigger_up('reload');
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return SearchBar;
|
||||
|
||||
});
|
||||
-485
@@ -1,485 +0,0 @@
|
||||
odoo.define('web.SearchBarAutoCompleteSources', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Class = require('web.Class');
|
||||
var core = require('web.core');
|
||||
var Domain = require('web.Domain');
|
||||
var field_utils = require('web.field_utils');
|
||||
var mixins = require('web.mixins');
|
||||
var pyUtils = require('web.py_utils');
|
||||
var ServicesMixin = require('web.ServicesMixin');
|
||||
var time = require('web.time');
|
||||
|
||||
var _t = core._t;
|
||||
var _lt = core._lt;
|
||||
|
||||
var FilterInterface = Class.extend(mixins.EventDispatcherMixin, {
|
||||
completion_label: _lt("%s"),
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} filter
|
||||
*/
|
||||
init: function (parent, filter) {
|
||||
mixins.EventDispatcherMixin.init.call(this);
|
||||
this.setParent(parent);
|
||||
|
||||
this.filter = filter;
|
||||
},
|
||||
/**
|
||||
* Fetch auto-completion values for the widget.
|
||||
*
|
||||
* The completion values should be an array of objects with keys facet and
|
||||
* label. They will be used by search_bar in @_onAutoCompleteSelected.
|
||||
*
|
||||
* @param {string} value value to getAutocompletionValues
|
||||
* @returns {Promise<null|Array>}
|
||||
*/
|
||||
getAutocompletionValues: function (value) {
|
||||
var result;
|
||||
value = value.toLowerCase();
|
||||
if (fuzzy.test(value, this.filter.description)) {
|
||||
result = [{
|
||||
label: _.str.sprintf(this.completion_label.toString(),
|
||||
_.escape(this.filter.description)),
|
||||
facet: {
|
||||
filter: this.filter,
|
||||
},
|
||||
}];
|
||||
}
|
||||
return Promise.resolve(result);
|
||||
},
|
||||
});
|
||||
|
||||
var Filter = FilterInterface.extend({
|
||||
completion_label: _lt("Filter on: %s"),
|
||||
});
|
||||
|
||||
var GroupBy = FilterInterface.extend({
|
||||
completion_label: _lt("Group by: %s"),
|
||||
});
|
||||
|
||||
var Field = FilterInterface.extend(ServicesMixin, {
|
||||
default_operator: '=',
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} field
|
||||
* @param {Object} filter
|
||||
* @param {Object} context needed for extra rpc (i.e. m2o)
|
||||
*/
|
||||
init: function (parent, filter, field, context) {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
this.field = field;
|
||||
this.filter = filter;
|
||||
this.attrs = _.extend({}, field, filter.attrs);
|
||||
this.context = context;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
getAutocompletionValues: function (value) {
|
||||
return Promise.resolve([{
|
||||
label: this._getAutocompletionLabel(value),
|
||||
facet: this._getFacetValue(value),
|
||||
}]);
|
||||
},
|
||||
/**
|
||||
* TODO: the domain logic (getDomain, operator, etc.) should not be put here
|
||||
* (this has nothing to do with the autocomplete sources).
|
||||
*
|
||||
* This method is used by the ControlPanelModel when setting the
|
||||
* `filter_domain`.
|
||||
*
|
||||
* @param {Object[]} values
|
||||
* @returns {string}
|
||||
*/
|
||||
getDomain: function (values) {
|
||||
if (!values.length) { return; }
|
||||
|
||||
var valueToDomain;
|
||||
var self = this;
|
||||
var domain = this.attrs.filter_domain;
|
||||
if (domain) {
|
||||
valueToDomain = function (facetValue) {
|
||||
return Domain.prototype.stringToArray(
|
||||
domain,
|
||||
{
|
||||
// these are the values that can be used in search view
|
||||
// fields `filter_domain` attribute
|
||||
self: self._valueFrom(facetValue),
|
||||
raw_value: facetValue.value,
|
||||
}
|
||||
);
|
||||
};
|
||||
} else {
|
||||
valueToDomain = function (facetValue) {
|
||||
return self._makeDomain(
|
||||
self.attrs.name,
|
||||
self.attrs.operator || self.default_operator,
|
||||
facetValue
|
||||
);
|
||||
};
|
||||
}
|
||||
var domains = values.map(valueToDomain);
|
||||
|
||||
domains = domains.map(Domain.prototype.arrayToString);
|
||||
return pyUtils.assembleDomains(domains, 'OR');
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {any} value
|
||||
* @returns {string}
|
||||
*/
|
||||
_getAutocompletionLabel: function (value) {
|
||||
return _.str.sprintf(_.str.escapeHTML(
|
||||
_t("Search %(field)s for: %(value)s")), {
|
||||
field: '<em>' + _.escape(this.attrs.string) + '</em>',
|
||||
value: '<strong>' + _.escape(value) + '</strong>'});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {any} value
|
||||
* @returns {Object}
|
||||
*/
|
||||
_getFacetValue: function (value) {
|
||||
return {
|
||||
filter: this.filter,
|
||||
values: [{label: value, value: value}],
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Function creating the returned domain for the field, override this
|
||||
* methods in children if you only need to customize the field's domain
|
||||
* without more complex alterations or tests (and without the need to
|
||||
* change override the handling of filter_domain)
|
||||
*
|
||||
* @private
|
||||
* @param {String} name the field's name
|
||||
* @param {String} operator the field's operator (either attribute-specified or default operator for the field
|
||||
* @param {Number|String} facet parsed value for the field
|
||||
* @returns {Array<Array>} domain to include in the resulting search
|
||||
*/
|
||||
_makeDomain: function (name, operator, facet) {
|
||||
return [[name, operator, this._valueFrom(facet)]];
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} facetValue
|
||||
* @param {any} facetValue.value
|
||||
* @returns {any}
|
||||
*/
|
||||
_valueFrom: function (facetValue) {
|
||||
return facetValue.value;
|
||||
},
|
||||
});
|
||||
|
||||
var CharField = Field.extend({
|
||||
default_operator: 'ilike',
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
getAutocompletionValues: function (value) {
|
||||
if (_.isEmpty(value)) { return Promise.resolve(null); }
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
var NumberField = Field.extend({
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
getAutocompletionValues: function (value) {
|
||||
var val = this.parse(value);
|
||||
if (isNaN(val)) { return Promise.resolve(); }
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
var IntegerField = NumberField.extend({
|
||||
error_message: _t("not a valid integer"),
|
||||
parse: function (value) {
|
||||
try {
|
||||
return field_utils.parse.integer(value);
|
||||
} catch (e) {
|
||||
return NaN;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
var FloatField = NumberField.extend({
|
||||
error_message: _t("not a valid number"),
|
||||
parse: function (value) {
|
||||
try {
|
||||
return field_utils.parse.float(value);
|
||||
} catch (e) {
|
||||
return NaN;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
var SelectionField = Field.extend({
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
getAutocompletionValues: function (value) {
|
||||
var self = this;
|
||||
var results = _(this.attrs.selection).chain()
|
||||
.filter(function (sel) {
|
||||
var selValue = sel[0], label = sel[1];
|
||||
if (selValue === undefined || !label) { return false; }
|
||||
return label.toLowerCase().indexOf(value.toLowerCase()) !== -1;
|
||||
})
|
||||
.map(function (sel) {
|
||||
return {
|
||||
label: _.escape(sel[1]),
|
||||
indent: true,
|
||||
facet: self._getFacetValue(sel)
|
||||
};
|
||||
}).value();
|
||||
if (_.isEmpty(results)) { return Promise.resolve(null); }
|
||||
return Promise.resolve([{
|
||||
label: _.escape(this.attrs.string)
|
||||
}].concat(results));
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {any} value
|
||||
* @returns {Object}
|
||||
*/
|
||||
_getFacetValue: function (value) {
|
||||
return {
|
||||
filter: this.filter,
|
||||
values: [{label: value[1], value: value[0]}],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
var BooleanField = SelectionField.extend({
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
this.attrs.selection = [
|
||||
[true, _t("Yes")],
|
||||
[false, _t("No")]
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
var DateField = Field.extend({
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
getAutocompletionValues: function (value) {
|
||||
// Make sure the value has a correct format before the creation of the moment object. See
|
||||
// issue https://github.com/moment/moment/issues/1407
|
||||
var t, v;
|
||||
try {
|
||||
t = (this.attrs && this.attrs.type === 'datetime') ? 'datetime' : 'date';
|
||||
v = field_utils.parse[t](value, {type: t}, {timezone: true});
|
||||
} catch (e) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
var m = moment(v, t === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD');
|
||||
if (!m.isValid()) { return Promise.resolve(null); }
|
||||
var dateString = field_utils.format[t](m, {type: t});
|
||||
var label = this._getAutocompletionLabel(dateString);
|
||||
var facet = this._getFacetValue(dateString, m.toDate());
|
||||
return Promise.resolve([{
|
||||
label: label,
|
||||
facet: facet,
|
||||
}]);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_getAutocompletionLabel: function (value) {
|
||||
return _.str.sprintf(_.str.escapeHTML(
|
||||
_t("Search %(field)s at: %(value)s")), {
|
||||
field: '<em>' + _.escape(this.attrs.string) + '</em>',
|
||||
value: '<strong>' + value + '</strong>'});
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @param {any} rawValue
|
||||
*/
|
||||
_getFacetValue: function (value, rawValue) {
|
||||
var facet = this._super.apply(this, arguments);
|
||||
facet.values[0].value = rawValue;
|
||||
return facet;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_valueFrom: function (facetValue) {
|
||||
return time.date_to_str(facetValue.value);
|
||||
},
|
||||
});
|
||||
|
||||
var DateTimeField = DateField.extend({
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_valueFrom: function (facetValue) {
|
||||
return time.datetime_to_str(facetValue.value);
|
||||
},
|
||||
});
|
||||
|
||||
var ManyToOneField = CharField.extend({
|
||||
default_operator: {},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
getAutocompletionValues: function (value) {
|
||||
if (_.isEmpty(value)) { return Promise.resolve(null); }
|
||||
var label = this._getAutocompletionLabel(value);
|
||||
return Promise.resolve([{
|
||||
label: label,
|
||||
facet: this._getFacetValue(value),
|
||||
expand: this._expand.bind(this),
|
||||
}]);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_getExpandedFacetValue: function (value) {
|
||||
return {
|
||||
filter: this.filter,
|
||||
values: [{label: value[1], value: value[0]}],
|
||||
};
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_getFacetValue: function (value) {
|
||||
return {
|
||||
filter: this.filter,
|
||||
values: [{label: value, value: value, operator: 'ilike'}],
|
||||
};
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_expand: function (value) {
|
||||
var self = this;
|
||||
var args = this.attrs.domain;
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
args = Domain.prototype.stringToArray(args);
|
||||
} catch(e) {
|
||||
args = [];
|
||||
}
|
||||
}
|
||||
return this._rpc({
|
||||
model: this.attrs.relation,
|
||||
method: 'name_search',
|
||||
kwargs: {
|
||||
name: value,
|
||||
args: args,
|
||||
limit: 8,
|
||||
context: this.context,
|
||||
},
|
||||
})
|
||||
.then(function (results) {
|
||||
if (_.isEmpty(results)) { return null; }
|
||||
return _(results).map(function (result) {
|
||||
return {
|
||||
label: _.escape(result[1]),
|
||||
facet: self._getExpandedFacetValue(result)
|
||||
};
|
||||
});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_makeDomain: function (name, operator, facetValue) {
|
||||
operator = facetValue.operator || operator;
|
||||
|
||||
switch(operator){
|
||||
case this.default_operator:
|
||||
return [[name, '=', facetValue.value]];
|
||||
case 'ilike':
|
||||
return [[name, 'ilike', facetValue.value]];
|
||||
case 'child_of':
|
||||
return [[name, 'child_of', facetValue.value]];
|
||||
}
|
||||
return this._super(name, operator, facetValue);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_valueFrom: function (facetValue) {
|
||||
return facetValue.label;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
BooleanField: BooleanField,
|
||||
CharField: CharField,
|
||||
DateField: DateField,
|
||||
DateTimeField: DateTimeField,
|
||||
Field: Field,
|
||||
Filter: Filter,
|
||||
FloatField: FloatField,
|
||||
GroupBy: GroupBy,
|
||||
IntegerField: IntegerField,
|
||||
ManyToOneField: ManyToOneField,
|
||||
NumberField: NumberField,
|
||||
SelectionField: SelectionField,
|
||||
};
|
||||
|
||||
});
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
odoo.define('web.search_bar_autocomplete_sources_registry', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Registry = require('web.Registry');
|
||||
|
||||
return new Registry();
|
||||
|
||||
});
|
||||
|
||||
odoo.define('web._search_bar_autocomplete_sources_registry', function(require) {
|
||||
"use strict";
|
||||
|
||||
var registry = require('web.search_bar_autocomplete_sources_registry');
|
||||
var SearchBarAutoCompleteSources = require('web.SearchBarAutoCompleteSources');
|
||||
|
||||
// fields
|
||||
registry
|
||||
.add('char', SearchBarAutoCompleteSources.CharField)
|
||||
.add('text', SearchBarAutoCompleteSources.CharField)
|
||||
.add('html', SearchBarAutoCompleteSources.CharField)
|
||||
.add('boolean', SearchBarAutoCompleteSources.BooleanField)
|
||||
.add('integer', SearchBarAutoCompleteSources.IntegerField)
|
||||
.add('id', SearchBarAutoCompleteSources.IntegerField)
|
||||
.add('float', SearchBarAutoCompleteSources.FloatField)
|
||||
.add('monetary', SearchBarAutoCompleteSources.FloatField)
|
||||
.add('selection', SearchBarAutoCompleteSources.SelectionField)
|
||||
.add('datetime', SearchBarAutoCompleteSources.DateTimeField)
|
||||
.add('date', SearchBarAutoCompleteSources.DateField)
|
||||
.add('many2one', SearchBarAutoCompleteSources.ManyToOneField)
|
||||
.add('many2many', SearchBarAutoCompleteSources.CharField)
|
||||
.add('one2many', SearchBarAutoCompleteSources.CharField);
|
||||
|
||||
// others
|
||||
registry.add('filter', SearchBarAutoCompleteSources.Filter);
|
||||
registry.add('groupby', SearchBarAutoCompleteSources.GroupBy);
|
||||
|
||||
});
|
||||
@@ -1,172 +0,0 @@
|
||||
odoo.define('web.SearchFacet', function (require) {
|
||||
"use strict";
|
||||
|
||||
var core = require('web.core');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
var SearchFacet = Widget.extend({
|
||||
template: 'SearchView.SearchFacet',
|
||||
events: _.extend({}, Widget.prototype.events, {
|
||||
'click .o_facet_remove': '_onFacetRemove',
|
||||
'compositionend': '_onCompositionend',
|
||||
'compositionstart': '_onCompositionstart',
|
||||
'keydown': '_onKeydown',
|
||||
}),
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} facet
|
||||
*/
|
||||
init: function (parent, facet) {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
var self = this;
|
||||
this.facet = facet;
|
||||
this.facetValues = _.map(this.facet.filters, function (filter) {
|
||||
return self._getFilterDescription(filter);
|
||||
});
|
||||
this.separator = this._getSeparator();
|
||||
this.icon = this._getIcon();
|
||||
this._isComposing = false;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the correct description according to filter.
|
||||
*
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
_getFilterDescription: function (filter) {
|
||||
if (filter.type === 'field') {
|
||||
var values = _.pluck(filter.autoCompleteValues, 'label');
|
||||
return values.join(_t(' or '));
|
||||
}
|
||||
var description = filter.description;
|
||||
if (filter.hasOptions) {
|
||||
if (filter.type === 'filter') {
|
||||
const optionDescriptions = [];
|
||||
const sortFunction = (o1, o2) =>
|
||||
filter.options.findIndex(o => o.optionId === o1) - filter.options.findIndex(o => o.optionId === o2);
|
||||
const p = _.partition([...filter.currentOptionIds], optionId =>
|
||||
filter.options.find(o => o.optionId === optionId).groupId === 1);
|
||||
const yearIds = p[1].sort(sortFunction);
|
||||
const otherOptionIds = p[0].sort(sortFunction);
|
||||
// the following case corresponds to years selected only
|
||||
if (otherOptionIds.length === 0) {
|
||||
yearIds.forEach(yearId => {
|
||||
const d = filter.basicDomains[yearId];
|
||||
optionDescriptions.push(d.description);
|
||||
});
|
||||
} else {
|
||||
otherOptionIds.forEach(optionId => {
|
||||
yearIds.forEach(yearId => {
|
||||
const d = filter.basicDomains[yearId + '__' + optionId];
|
||||
optionDescriptions.push(d.description);
|
||||
});
|
||||
});
|
||||
}
|
||||
description += ': ' + optionDescriptions.join('/');
|
||||
} else {
|
||||
description = description += ': ' +
|
||||
filter.options.find(o => o.optionId === filter.optionId).description;
|
||||
}
|
||||
}
|
||||
if (filter.type === 'timeRange') {
|
||||
var timeRangeValue =_.findWhere(filter.timeRangeOptions, {
|
||||
optionId: filter.timeRangeId,
|
||||
});
|
||||
description += ': ' + timeRangeValue.description;
|
||||
if (filter.comparisonTimeRangeId) {
|
||||
var comparisonTimeRangeValue =_.findWhere(filter.comparisonTimeRangeOptions, {
|
||||
optionId: filter.comparisonTimeRangeId,
|
||||
});
|
||||
description += ' / ' + comparisonTimeRangeValue.description;
|
||||
}
|
||||
}
|
||||
return description;
|
||||
},
|
||||
/**
|
||||
* Get the correct icon according to facet type.
|
||||
*
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
_getIcon: function () {
|
||||
var icon;
|
||||
if (this.facet.type === 'filter') {
|
||||
icon = 'fa-filter';
|
||||
} else if (this.facet.type === 'groupBy') {
|
||||
icon = 'fa-bars';
|
||||
} else if (this.facet.type === 'favorite') {
|
||||
icon = 'fa-star';
|
||||
} else if (this.facet.type === 'timeRange') {
|
||||
icon = 'fa-calendar';
|
||||
}
|
||||
return icon;
|
||||
},
|
||||
/**
|
||||
* Get the correct separator according to facet type.
|
||||
*
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
_getSeparator: function () {
|
||||
var separator;
|
||||
if (this.facet.type === 'filter') {
|
||||
separator = _t('or');
|
||||
} else if (this.facet.type === 'field') {
|
||||
separator = _t('or');
|
||||
} else if (this.facet.type === 'groupBy') {
|
||||
separator = '>';
|
||||
}
|
||||
return separator;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {CompositionEvent} ev
|
||||
*/
|
||||
_onCompositionend: function (ev) {
|
||||
this._isComposing = false;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {CompositionEvent} ev
|
||||
*/
|
||||
_onCompositionstart: function (ev) {
|
||||
this._isComposing = true;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onFacetRemove: function () {
|
||||
this.trigger_up('facet_removed', {group: this.facet});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
_onKeydown: function (ev) {
|
||||
if (this._isComposing) {
|
||||
return;
|
||||
}
|
||||
switch (ev.which) {
|
||||
case $.ui.keyCode.BACKSPACE:
|
||||
this.trigger_up('facet_removed', {group: this.facet});
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return SearchFacet;
|
||||
|
||||
});
|
||||
@@ -1,418 +0,0 @@
|
||||
odoo.define('web.search_filters', function (require) {
|
||||
"use strict";
|
||||
|
||||
var core = require('web.core');
|
||||
var datepicker = require('web.datepicker');
|
||||
var field_utils = require('web.field_utils');
|
||||
var search_filters_registry = require('web.search_filters_registry');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
var _t = core._t;
|
||||
var _lt = core._lt;
|
||||
|
||||
var ExtendedSearchProposition = Widget.extend({
|
||||
template: 'SearchView.extended_search.proposition',
|
||||
events: {
|
||||
'change .o_searchview_extended_prop_field': 'changed',
|
||||
'change .o_searchview_extended_prop_op': 'operator_changed',
|
||||
'click .o_searchview_extended_delete_prop': function (e) {
|
||||
e.stopPropagation();
|
||||
this.trigger_up('remove_proposition');
|
||||
},
|
||||
'keyup .o_searchview_extended_prop_value': function (ev) {
|
||||
if (ev.which === $.ui.keyCode.ENTER) {
|
||||
this.trigger_up('confirm_proposition');
|
||||
}
|
||||
},
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @param fields
|
||||
*/
|
||||
init: function (parent, fields) {
|
||||
this._super(parent);
|
||||
this.fields = _(fields).chain()
|
||||
.map(function (val, key) { return _.extend({}, val, {'name': key}); })
|
||||
.filter(function (field) { return !field.deprecated && field.searchable; })
|
||||
.sortBy(function (field) {return field.string;})
|
||||
.value();
|
||||
this.attrs = {_: _, fields: this.fields, selected: null};
|
||||
this.value = null;
|
||||
},
|
||||
start: function () {
|
||||
var parent = this._super();
|
||||
parent.then(this.proxy('changed'));
|
||||
return parent;
|
||||
},
|
||||
changed: function () {
|
||||
var nval = this.$(".o_searchview_extended_prop_field").val();
|
||||
if(this.attrs.selected === null || this.attrs.selected === undefined || nval !== this.attrs.selected.name) {
|
||||
this.select_field(_.detect(this.fields, function (x) {return x.name === nval;}));
|
||||
}
|
||||
},
|
||||
operator_changed: function (e) {
|
||||
this.value.show_inputs($(e.target));
|
||||
},
|
||||
/**
|
||||
* Selects the provided field object
|
||||
*
|
||||
* @param field a field descriptor object (as returned by fields_get, augmented by the field name)
|
||||
*/
|
||||
select_field: function (field) {
|
||||
var self = this;
|
||||
if(this.attrs.selected !== null && this.attrs.selected !== undefined) {
|
||||
this.value.destroy();
|
||||
this.value = null;
|
||||
this.$('.o_searchview_extended_prop_op').html('');
|
||||
}
|
||||
this.attrs.selected = field;
|
||||
if(field === null || field === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
var type = field.type;
|
||||
var Field = search_filters_registry.getAny([type, "char"]);
|
||||
|
||||
this.value = new Field(this, field);
|
||||
_.each(this.value.operators, function (operator) {
|
||||
$('<option>', {value: operator.value})
|
||||
.text(String(operator.text))
|
||||
.appendTo(self.$('.o_searchview_extended_prop_op'));
|
||||
});
|
||||
var $value_loc = this.$('.o_searchview_extended_prop_value').show().empty();
|
||||
this.value.appendTo($value_loc);
|
||||
|
||||
},
|
||||
get_filter: function () {
|
||||
if (this.attrs.selected === null || this.attrs.selected === undefined) {
|
||||
return null;
|
||||
}
|
||||
var field = this.attrs.selected,
|
||||
op_select = this.$('.o_searchview_extended_prop_op')[0],
|
||||
operator = op_select.options[op_select.selectedIndex];
|
||||
|
||||
return {
|
||||
attrs: {
|
||||
domain: this.value.get_domain(field, operator),
|
||||
string: this.value.get_label(field, operator),
|
||||
},
|
||||
children: [],
|
||||
tag: 'filter',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
var Field = Widget.extend({
|
||||
init: function (parent, field) {
|
||||
this._super(parent);
|
||||
this.field = field;
|
||||
},
|
||||
get_label: function (field, operator) {
|
||||
var format;
|
||||
switch (operator.value) {
|
||||
case '∃': case '∄': format = _t('%(field)s %(operator)s'); break;
|
||||
default: format = _t('%(field)s %(operator)s "%(value)s"'); break;
|
||||
}
|
||||
return this.format_label(format, field, operator);
|
||||
},
|
||||
format_label: function (format, field, operator) {
|
||||
return _.str.sprintf(format, {
|
||||
field: field.string,
|
||||
// According to spec, HTMLOptionElement#label should return
|
||||
// HTMLOptionElement#text when not defined/empty, but it does
|
||||
// not in older Webkit (between Safari 5.1.5 and Chrome 17) and
|
||||
// Gecko (pre Firefox 7) browsers, so we need a manual fallback
|
||||
// for those
|
||||
operator: operator.label || operator.text,
|
||||
value: this
|
||||
});
|
||||
},
|
||||
get_domain: function (field, operator) {
|
||||
switch (operator.value) {
|
||||
case '∃': return [[field.name, '!=', false]];
|
||||
case '∄': return [[field.name, '=', false]];
|
||||
default: return [[field.name, operator.value, this.get_value()]];
|
||||
}
|
||||
},
|
||||
show_inputs: function ($operator) {
|
||||
var $value = this.$el.parent();
|
||||
switch ($operator.val()) {
|
||||
case '∃':
|
||||
case '∄':
|
||||
$value.hide();
|
||||
break;
|
||||
default:
|
||||
$value.show();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Returns a human-readable version of the value, in case the "logical"
|
||||
* and the "semantic" values of a field differ (as for selection fields,
|
||||
* for instance).
|
||||
*
|
||||
* The default implementation simply returns the value itself.
|
||||
*
|
||||
* @return {String} human-readable version of the value
|
||||
*/
|
||||
toString: function () {
|
||||
return this.get_value();
|
||||
}
|
||||
});
|
||||
|
||||
var Char = Field.extend({
|
||||
tagName: 'input',
|
||||
className: 'o_input',
|
||||
attributes: {
|
||||
type: 'text'
|
||||
},
|
||||
operators: [
|
||||
{value: "ilike", text: _lt("contains")},
|
||||
{value: "not ilike", text: _lt("doesn't contain")},
|
||||
{value: "=", text: _lt("is equal to")},
|
||||
{value: "!=", text: _lt("is not equal to")},
|
||||
{value: "∃", text: _lt("is set")},
|
||||
{value: "∄", text: _lt("is not set")}
|
||||
],
|
||||
get_value: function () {
|
||||
return this.$el.val();
|
||||
}
|
||||
});
|
||||
|
||||
var DateTime = Field.extend({
|
||||
tagName: 'span',
|
||||
serverFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
attributes: {
|
||||
type: 'datetime'
|
||||
},
|
||||
operators: [
|
||||
{value: "between", text: _lt("is between")},
|
||||
{value: "=", text: _lt("is equal to")},
|
||||
{value: "!=", text: _lt("is not equal to")},
|
||||
{value: ">", text: _lt("is after")},
|
||||
{value: "<", text: _lt("is before")},
|
||||
{value: ">=", text: _lt("is after or equal to")},
|
||||
{value: "<=", text: _lt("is before or equal to")},
|
||||
{value: "∃", text: _lt("is set")},
|
||||
{value: "∄", text: _lt("is not set")}
|
||||
],
|
||||
/**
|
||||
* Gets the value of the datepicker
|
||||
*
|
||||
* @public
|
||||
* @param {Integer} [index] The datepicker's index.
|
||||
* 0 for the lower boundary (default)
|
||||
* 1 for the higher boundary
|
||||
*
|
||||
* @return {Moment} The value in UTC
|
||||
*/
|
||||
get_value: function (index) {
|
||||
// retrieve the datepicker value
|
||||
var value = this["datewidget_" + (index || 0)].getValue().clone();
|
||||
// convert to utc
|
||||
return value.add(-this.getSession().getTZOffset(value), 'minutes');
|
||||
},
|
||||
get_domain: function (field, operator) {
|
||||
switch (operator.value) {
|
||||
case '∃':
|
||||
return [[field.name, '!=', false]];
|
||||
case '∄':
|
||||
return [[field.name, '=', false]];
|
||||
case 'between':
|
||||
return [
|
||||
[field.name, '>=', this._formatMomentToServer(this.get_value(0))],
|
||||
[field.name, '<=', this._formatMomentToServer(this.get_value(1))]
|
||||
];
|
||||
default:
|
||||
return [[field.name, operator.value, this._formatMomentToServer(this.get_value())]];
|
||||
}
|
||||
},
|
||||
show_inputs: function ($operator) {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
if ($operator.val() === "between") {
|
||||
this.datewidget_1.do_show();
|
||||
} else {
|
||||
this.datewidget_1.do_hide();
|
||||
}
|
||||
},
|
||||
toString: function () {
|
||||
var str = field_utils.format[this.attributes.type](this.get_value(), {type: this.attributes.type});
|
||||
// the second datewidget might have been hidden because the operator has changed
|
||||
var date_1_value = this.datewidget_1 && !this.datewidget_1.$el.hasClass('o_hidden') && this.get_value(1);
|
||||
if (date_1_value) {
|
||||
str += _lt(" and ") + field_utils.format[this.attributes.type](date_1_value, {type: this.attributes.type});
|
||||
}
|
||||
return str;
|
||||
},
|
||||
start: function () {
|
||||
return Promise.all([
|
||||
this._super.apply(this, arguments),
|
||||
this._create_new_widget("datewidget_0", '00:00:00', 'hh:mm:ss'),
|
||||
this._create_new_widget("datewidget_1", '23:59:59', 'hh:mm:ss'),
|
||||
]).then(() => {
|
||||
if (this.operators[0].value !== "between") {
|
||||
this.datewidget_1.do_hide();
|
||||
}
|
||||
});
|
||||
},
|
||||
_create_new_widget: function (name, ...time) {
|
||||
this[name] = new (this._get_widget_class())(this);
|
||||
return this[name].appendTo(this.$el).then((function () {
|
||||
this[name].setValue(moment(...time));
|
||||
}).bind(this));
|
||||
},
|
||||
_get_widget_class: function () {
|
||||
return datepicker.DateTimeWidget;
|
||||
},
|
||||
/**
|
||||
* Transform a Moment in a server acceptable format
|
||||
*
|
||||
* @private
|
||||
* @param {Moment} momentValue The moment to get the string for
|
||||
*
|
||||
* @return {String} Represents the value in UTC
|
||||
*/
|
||||
_formatMomentToServer: function (momentValue) {
|
||||
return momentValue.locale('en').format(this.serverFormat);
|
||||
},
|
||||
});
|
||||
|
||||
var Date = DateTime.extend({
|
||||
serverFormat: 'YYYY-MM-DD',
|
||||
attributes: {
|
||||
type: 'date'
|
||||
},
|
||||
operators: [
|
||||
{value: "=", text: _lt("is equal to")},
|
||||
{value: "!=", text: _lt("is not equal to")},
|
||||
{value: ">", text: _lt("is after")},
|
||||
{value: "<", text: _lt("is before")},
|
||||
{value: ">=", text: _lt("is after or equal to")},
|
||||
{value: "<=", text: _lt("is before or equal to")},
|
||||
{value: "between", text: _lt("is between")},
|
||||
{value: "∃", text: _lt("is set")},
|
||||
{value: "∄", text: _lt("is not set")}
|
||||
],
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
get_value: function (index) {
|
||||
// retrieve the datepicker value
|
||||
var value = this["datewidget_" + (index || 0)].getValue();
|
||||
return value && value.clone();
|
||||
},
|
||||
_get_widget_class: function () {
|
||||
return datepicker.DateWidget;
|
||||
},
|
||||
});
|
||||
|
||||
var Integer = Field.extend({
|
||||
tagName: 'input',
|
||||
className: 'o_input',
|
||||
attributes: {
|
||||
type: 'number',
|
||||
value: '0',
|
||||
},
|
||||
operators: [
|
||||
{value: "=", text: _lt("is equal to")},
|
||||
{value: "!=", text: _lt("is not equal to")},
|
||||
{value: ">", text: _lt("greater than")},
|
||||
{value: "<", text: _lt("less than")},
|
||||
{value: ">=", text: _lt("greater than or equal to")},
|
||||
{value: "<=", text: _lt("less than or equal to")},
|
||||
{value: "∃", text: _lt("is set")},
|
||||
{value: "∄", text: _lt("is not set")}
|
||||
],
|
||||
toString: function () {
|
||||
return this.$el.val();
|
||||
},
|
||||
get_value: function () {
|
||||
try {
|
||||
var val =this.$el.val();
|
||||
return field_utils.parse.integer(val === "" ? 0 : val);
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var Id = Integer.extend({
|
||||
operators: [{value: "=", text: _lt("is")}]
|
||||
});
|
||||
|
||||
var Float = Field.extend({
|
||||
template: 'SearchView.extended_search.proposition.float',
|
||||
operators: [
|
||||
{value: "=", text: _lt("is equal to")},
|
||||
{value: "!=", text: _lt("is not equal to")},
|
||||
{value: ">", text: _lt("greater than")},
|
||||
{value: "<", text: _lt("less than")},
|
||||
{value: ">=", text: _lt("greater than or equal to")},
|
||||
{value: "<=", text: _lt("less than or equal to")},
|
||||
{value: "∃", text: _lt("is set")},
|
||||
{value: "∄", text: _lt("is not set")}
|
||||
],
|
||||
init: function (parent) {
|
||||
this._super(parent);
|
||||
this.decimal_point = _t.database.parameters.decimal_point;
|
||||
},
|
||||
toString: function () {
|
||||
return this.$el.val();
|
||||
},
|
||||
get_value: function () {
|
||||
try {
|
||||
var val =this.$el.val();
|
||||
return field_utils.parse.float(val === "" ? 0.0 : val);
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var Selection = Field.extend({
|
||||
template: 'SearchView.extended_search.proposition.selection',
|
||||
operators: [
|
||||
{value: "=", text: _lt("is")},
|
||||
{value: "!=", text: _lt("is not")},
|
||||
{value: "∃", text: _lt("is set")},
|
||||
{value: "∄", text: _lt("is not set")}
|
||||
],
|
||||
toString: function () {
|
||||
var select = this.$el[0];
|
||||
var option = select.options[select.selectedIndex];
|
||||
return option.label || option.text;
|
||||
},
|
||||
get_value: function () {
|
||||
return this.$el.val();
|
||||
}
|
||||
});
|
||||
|
||||
var Boolean = Field.extend({
|
||||
tagName: 'span',
|
||||
operators: [
|
||||
{value: "=", text: _lt("is true")},
|
||||
{value: "!=", text: _lt("is false")}
|
||||
],
|
||||
get_label: function (field, operator) {
|
||||
return this.format_label(
|
||||
_t('%(field)s %(operator)s'), field, operator);
|
||||
},
|
||||
get_value: function () {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
Boolean: Boolean,
|
||||
Char: Char,
|
||||
Date: Date,
|
||||
DateTime: DateTime,
|
||||
ExtendedSearchProposition: ExtendedSearchProposition,
|
||||
Field: Field,
|
||||
Float: Float,
|
||||
Id: Id,
|
||||
Integer: Integer,
|
||||
Selection: Selection,
|
||||
};
|
||||
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
odoo.define('web.search_filters_registry', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Registry = require('web.Registry');
|
||||
|
||||
return new Registry();
|
||||
|
||||
});
|
||||
|
||||
odoo.define('web._search_filters_registry', function(require) {
|
||||
"use strict";
|
||||
|
||||
var registry = require('web.search_filters_registry');
|
||||
var search_filters = require('web.search_filters');
|
||||
|
||||
registry
|
||||
.add('boolean', search_filters.Boolean)
|
||||
.add('char', search_filters.Char)
|
||||
.add('date', search_filters.Date)
|
||||
.add('datetime', search_filters.DateTime)
|
||||
.add('float', search_filters.Float)
|
||||
.add('id', search_filters.Id)
|
||||
.add('integer', search_filters.Integer)
|
||||
.add('many2many', search_filters.Char)
|
||||
.add('many2one', search_filters.Char)
|
||||
.add('monetary', search_filters.Float)
|
||||
.add('one2many', search_filters.Char)
|
||||
.add('text', search_filters.Char)
|
||||
.add('selection', search_filters.Selection);
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
odoo.define('web.TimeRangeMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
var config = require('web.config');
|
||||
var controlPanelViewParameters = require('web.controlPanelViewParameters');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
var COMPARISON_TIME_RANGE_OPTIONS = controlPanelViewParameters.COMPARISON_TIME_RANGE_OPTIONS;
|
||||
var PERIOD_OPTIONS = controlPanelViewParameters.PERIOD_OPTIONS;
|
||||
|
||||
var TimeRangeMenu = Widget.extend({
|
||||
template: 'web.TimeRangeMenu',
|
||||
events: {
|
||||
'click .o_apply_range': '_onApplyButtonClick',
|
||||
'click .o_comparison_checkbox': '_onCheckBoxClick',
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @param {Widget} parent
|
||||
* @param {Object[]} timeRanges
|
||||
*
|
||||
*/
|
||||
init: function (parent, timeRanges) {
|
||||
this._super.apply(this, arguments);
|
||||
// determine header style
|
||||
this.isMobile = config.device.isMobile;
|
||||
this.symbol = this.isMobile ? 'fa fa-chevron-right float-right mt4' : 'caret';
|
||||
// fixed parameters
|
||||
this.periodOptions = PERIOD_OPTIONS;
|
||||
this.comparisonTimeRangeOptions = COMPARISON_TIME_RANGE_OPTIONS;
|
||||
this.periodGroups = _.uniq(PERIOD_OPTIONS.map(function (option) {
|
||||
return option.groupId;
|
||||
}));
|
||||
// variable parameters
|
||||
this.timeRanges = timeRanges;
|
||||
this.configuration = {
|
||||
comparisonIsSelected: false,
|
||||
comparisonTimeRangeId: false,
|
||||
id: false,
|
||||
timeRangeId: false,
|
||||
};
|
||||
this._configure();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Object[]} timeRanges
|
||||
*/
|
||||
update: function (timeRanges) {
|
||||
this.timeRanges = timeRanges;
|
||||
this._configure();
|
||||
this.renderElement();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_configure: function () {
|
||||
this.configuration = this.timeRanges.find(function (timeRange) {
|
||||
return timeRange.isActive;
|
||||
}) || this.configuration;
|
||||
this.configuration.comparisonIsSelected = !!this.configuration.comparisonTimeRangeId;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onApplyButtonClick: function () {
|
||||
var id = this.$('.o_date_field_selector').val();
|
||||
var timeRangeId = this.$('.o_time_range_selector').val();
|
||||
var comparisonTimeRangeId = false;
|
||||
if (this.configuration.comparisonIsSelected) {
|
||||
comparisonTimeRangeId = this.$('.o_comparison_time_range_selector').val();
|
||||
}
|
||||
this.trigger_up('activate_time_range', {
|
||||
id: id,
|
||||
timeRangeId: timeRangeId,
|
||||
comparisonTimeRangeId: comparisonTimeRangeId
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onCheckBoxClick: function (ev) {
|
||||
ev.stopPropagation();
|
||||
this.configuration.comparisonIsSelected = this.$('.o_comparison_checkbox').prop('checked');
|
||||
this.$('.o_comparison_time_range_selector').toggleClass('o_hidden');
|
||||
this.$el.addClass('open');
|
||||
}
|
||||
});
|
||||
|
||||
return TimeRangeMenu;
|
||||
|
||||
});
|
||||
@@ -1,268 +0,0 @@
|
||||
odoo.define('web.AutoComplete', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
return Widget.extend({
|
||||
template: "SearchView.autocomplete",
|
||||
|
||||
// Parameters for autocomplete constructor:
|
||||
//
|
||||
// parent: this is used to detect keyboard events
|
||||
//
|
||||
// options.source: function ({term:query}, callback). This function will be called to
|
||||
// obtain the search results corresponding to the query string. It is assumed that
|
||||
// options.source will call callback with the results.
|
||||
// options.select: function (ev, {item: {facet:facet}}). Autocomplete widget will call
|
||||
// that function when a selection is made by the user
|
||||
// options.get_search_string: function (). This function will be called by autocomplete
|
||||
// to obtain the current search string.
|
||||
init: function (parent, options) {
|
||||
this._super(parent);
|
||||
this.$input = options.$input;
|
||||
this.source = options.source;
|
||||
this.select = options.select;
|
||||
this.get_search_string = options.get_search_string;
|
||||
|
||||
this.current_result = null;
|
||||
|
||||
this.searching = true;
|
||||
this.search_string = '';
|
||||
this.current_search = null;
|
||||
this._isInputComposing = false;
|
||||
},
|
||||
start: function () {
|
||||
var self = this;
|
||||
this.$input.on('compositionend', function (ev) {
|
||||
self._isInputComposing = false;
|
||||
});
|
||||
this.$input.on('compositionstart', function (ev) {
|
||||
self._isInputComposing = true;
|
||||
});
|
||||
this.$input.on('keyup', function (ev) {
|
||||
if (ev.which === $.ui.keyCode.RIGHT && !self._isInputComposing) {
|
||||
self.searching = true;
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (ev.which === $.ui.keyCode.ENTER && !self._isInputComposing) {
|
||||
if (self.search_string.length) {
|
||||
self.select_item(ev);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self._updateSearch();
|
||||
});
|
||||
this.$input.on('input', function (ev) {
|
||||
if (ev.originalEvent.inputType === 'insertCompositionText') {
|
||||
// click inside keyboard IME suggestions menu
|
||||
self._updateSearch();
|
||||
}
|
||||
});
|
||||
this.$input.on('keypress', function (ev) {
|
||||
if (ev.which > 31 && ev.which !== 127) {
|
||||
// we filter control character out of the search string
|
||||
self.search_string = self.search_string + String.fromCharCode(ev.which);
|
||||
}
|
||||
if (self.search_string.length) {
|
||||
self.searching = true;
|
||||
var search_string = self.search_string;
|
||||
self.initiate_search(search_string);
|
||||
} else {
|
||||
self.close();
|
||||
}
|
||||
});
|
||||
this.$input.on('keydown', function (ev) {
|
||||
if (self._isInputComposing) {
|
||||
return;
|
||||
}
|
||||
switch (ev.which) {
|
||||
case $.ui.keyCode.ENTER:
|
||||
|
||||
// TAB and direction keys are handled at KeyDown because KeyUp
|
||||
// is not guaranteed to fire.
|
||||
// See e.g. https://github.com/aef-/jquery.masterblaster/issues/13
|
||||
case $.ui.keyCode.TAB:
|
||||
if (self.search_string.length) {
|
||||
self.select_item(ev);
|
||||
}
|
||||
break;
|
||||
case $.ui.keyCode.DOWN:
|
||||
self.move('down');
|
||||
self.searching = false;
|
||||
ev.preventDefault();
|
||||
break;
|
||||
case $.ui.keyCode.UP:
|
||||
self.move('up');
|
||||
self.searching = false;
|
||||
ev.preventDefault();
|
||||
break;
|
||||
case $.ui.keyCode.RIGHT:
|
||||
if(self.$input[0].selectionStart < self.search_string.length) {
|
||||
ev.stopPropagation();
|
||||
return;
|
||||
}
|
||||
self.searching = false;
|
||||
var current = self.current_result;
|
||||
if (current && current.expand && !current.expanded) {
|
||||
self.expand();
|
||||
self.searching = true;
|
||||
ev.stopPropagation();
|
||||
}
|
||||
ev.preventDefault();
|
||||
break;
|
||||
case $.ui.keyCode.LEFT:
|
||||
if(self.$input[0].selectionStart > 0) {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
break;
|
||||
case $.ui.keyCode.ESCAPE:
|
||||
self.close();
|
||||
self.searching = false;
|
||||
break;
|
||||
}
|
||||
});
|
||||
},
|
||||
initiate_search: function (query) {
|
||||
if (query === this.search_string && query !== this.current_search) {
|
||||
this.search(query);
|
||||
}
|
||||
},
|
||||
search: function (query) {
|
||||
var self = this;
|
||||
this.current_search = query;
|
||||
this.source({term:query}, function (results) {
|
||||
if (results.length) {
|
||||
self.render_search_results(results);
|
||||
self.focus_element(self.$('li:first-child'));
|
||||
} else {
|
||||
self.close();
|
||||
}
|
||||
});
|
||||
},
|
||||
render_search_results: function (results) {
|
||||
var self = this;
|
||||
var $list = this.$el;
|
||||
$list.empty();
|
||||
results.forEach(function (result) {
|
||||
var $item = self.make_list_item(result).appendTo($list);
|
||||
result.$el = $item;
|
||||
});
|
||||
// IE9 doesn't support addEventListener with option { once: true }
|
||||
this.el.onmousemove = function (ev) {
|
||||
self.$('li').each(function (index, li) {
|
||||
li.onmouseenter = self.focus_element.bind(self, $(li));
|
||||
});
|
||||
var targetFocus = ev.target.tagName === 'LI' ?
|
||||
ev.target :
|
||||
ev.target.closest('li');
|
||||
self.focus_element($(targetFocus));
|
||||
self.el.onmousemove = null;
|
||||
};
|
||||
this.show();
|
||||
},
|
||||
make_list_item: function (result) {
|
||||
var self = this;
|
||||
var $li = $('<li>')
|
||||
.mousedown(function (ev) {
|
||||
if (ev.button === 0) { // left button
|
||||
self.select(ev, {item: {facet: result.facet}});
|
||||
self.close();
|
||||
}
|
||||
})
|
||||
.data('result', result);
|
||||
if (result.expand) {
|
||||
var $expand = $('<a class="o-expand" href="#">').appendTo($li);
|
||||
$expand.mousedown(function (ev) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault(); // to prevent dropdown from closing
|
||||
if (result.expanded) {
|
||||
self.fold();
|
||||
} else {
|
||||
self.expand();
|
||||
}
|
||||
});
|
||||
$expand.click(function(ev) {
|
||||
ev.preventDefault(); // to prevent url from changing due to href="#"
|
||||
});
|
||||
result.expanded = false;
|
||||
}
|
||||
if (result.indent) $li.addClass('o-indent');
|
||||
$li.append($('<a href="#">').html(result.label));
|
||||
return $li;
|
||||
},
|
||||
expand: function () {
|
||||
var self = this;
|
||||
var current_result = this.current_result;
|
||||
current_result.expand(this.get_search_string()).then(function (results) {
|
||||
(results || [{label: '(no result)'}]).reverse().forEach(function (result) {
|
||||
result.indent = true;
|
||||
var $li = self.make_list_item(result);
|
||||
current_result.$el.after($li);
|
||||
});
|
||||
self.current_result.expanded = true;
|
||||
self.current_result.$el.find('a.o-expand').removeClass('o-expand').addClass('o-expanded');
|
||||
});
|
||||
},
|
||||
fold: function () {
|
||||
var $next = this.current_result.$el.next();
|
||||
while ($next.hasClass('o-indent')) {
|
||||
$next.remove();
|
||||
$next = this.current_result.$el.next();
|
||||
}
|
||||
this.current_result.expanded = false;
|
||||
this.current_result.$el.find('a.o-expanded').removeClass('o-expanded').addClass('o-expand');
|
||||
},
|
||||
focus_element: function ($li) {
|
||||
this.$('li').removeClass('o-selection-focus');
|
||||
$li.addClass('o-selection-focus');
|
||||
this.current_result = $li.data('result');
|
||||
},
|
||||
select_item: function (ev) {
|
||||
this.select(ev, {item: {facet: this.current_result.facet}});
|
||||
this.close();
|
||||
},
|
||||
show: function () {
|
||||
this.$el.show();
|
||||
},
|
||||
close: function () {
|
||||
this.current_search = null;
|
||||
this.search_string = '';
|
||||
this.searching = true;
|
||||
this.$el.hide();
|
||||
},
|
||||
move: function (direction) {
|
||||
var $next;
|
||||
if (direction === 'down') {
|
||||
$next = this.$('li.o-selection-focus').next();
|
||||
if (!$next.length) $next = this.$('li').first();
|
||||
} else {
|
||||
$next = this.$('li.o-selection-focus').prev();
|
||||
if (!$next.length) $next = this.$('li').last();
|
||||
}
|
||||
this.focus_element($next);
|
||||
},
|
||||
is_expandable: function () {
|
||||
return !!this.$('.o-selection-focus .o-expand').length;
|
||||
},
|
||||
is_expanded: function() {
|
||||
return this.$el[0].style.display === "block";
|
||||
},
|
||||
/**
|
||||
* Update search dropdown menu based on new input content.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_updateSearch: function () {
|
||||
var search_string = this.get_search_string();
|
||||
if (this.search_string !== search_string) {
|
||||
if (search_string.length) {
|
||||
this.search_string = search_string;
|
||||
this.initiate_search(search_string);
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
odoo.define('web.DropdownMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
var core = require('web.core');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
var QWeb = core.qweb;
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
var DropdownMenu = Widget.extend({
|
||||
template: 'web.DropdownMenu',
|
||||
|
||||
events: {
|
||||
'click .o_menu_item': '_onItemClick',
|
||||
'click .o_item_option': '_onOptionClick',
|
||||
'click span.o_trash_button': '_onTrashButtonClick',
|
||||
'hidden.bs.dropdown': '_onBootstrapClose',
|
||||
'click .dropdown-item-text': '_onDropDownItemTextClick',
|
||||
},
|
||||
|
||||
init: function (parent, items) {
|
||||
this._super(parent);
|
||||
// should be specified
|
||||
this.dropdownCategory = null;
|
||||
this.dropdownTitle = null;
|
||||
this.dropdownIcon = null;
|
||||
this.dropdownSymbol = false;
|
||||
// this parameter fixes the menu style. By default,
|
||||
// the style used is the one used in the search view
|
||||
this.dropdownStyle = {
|
||||
el: {class: 'btn-group o_dropdown', attrs: {}},
|
||||
mainButton: {
|
||||
class: 'o_dropdown_toggler_btn btn btn-secondary ' +
|
||||
'dropdown-toggle ' +
|
||||
(this.dropdownSymbol ? 'o-no-caret' : '')
|
||||
},
|
||||
};
|
||||
this.items = items;
|
||||
this.openItems = {};
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
this.$menu = this.$('.o_dropdown_menu');
|
||||
this.$dropdownReference = this.$('.o_dropdown_toggler_btn');
|
||||
|
||||
if (_t.database.parameters.direction === 'rtl') {
|
||||
this.$menu.addClass('dropdown-menu-right');
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Object} items
|
||||
*/
|
||||
update: function (items) {
|
||||
this.items = items;
|
||||
this._renderMenuItems();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_renderMenuItems: function () {
|
||||
var newMenuItems = QWeb.render('DropdownMenu.MenuItems', {widget: this});
|
||||
this.$el.find('.o_menu_item, .dropdown-divider[data-removable="1"]').remove();
|
||||
this.$('.o_dropdown_menu').prepend(newMenuItems);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* This method is called when Bootstrap trigger a 'close' event (for example
|
||||
* when the user clicks outside the dropdown menu).
|
||||
*
|
||||
* @private
|
||||
* @param {jQueryEvent} event
|
||||
*/
|
||||
_onBootstrapClose: function () {
|
||||
this.openItems = {};
|
||||
this._renderMenuItems();
|
||||
},
|
||||
/**
|
||||
* Reacts to click on bootstrap's dropdown-item-text
|
||||
* Protects against Bootstrap dropdown close from inside click
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onDropDownItemTextClick: function (ev) {
|
||||
ev.stopPropagation();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onItemClick: function (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
var id = $(event.currentTarget).data('id');
|
||||
var item = this.items.find(function (item) {
|
||||
return item.id === id;
|
||||
});
|
||||
if (item.hasOptions) {
|
||||
this.openItems[id] = !this.openItems[id];
|
||||
this._renderMenuItems();
|
||||
} else {
|
||||
this.trigger_up('menu_item_clicked', {id: id});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onOptionClick: function (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
var optionId = $(event.currentTarget).data('option_id');
|
||||
var id = $(event.currentTarget).data('item_id');
|
||||
this.trigger_up('item_option_clicked', {id: id, optionId: optionId});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*
|
||||
* To implement in child
|
||||
*/
|
||||
_onTrashButtonClick: function (event) {
|
||||
},
|
||||
});
|
||||
|
||||
return DropdownMenu;
|
||||
|
||||
});
|
||||
@@ -45,7 +45,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
> .o_cp_sidebar {
|
||||
> .o_cp_action_menus {
|
||||
padding-right: 10px;
|
||||
|
||||
.o_hidden_input_file {
|
||||
@@ -83,14 +83,14 @@
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
|
||||
> div {
|
||||
.o_pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.o_pager_counter {
|
||||
margin-right: 5px;
|
||||
min-width: $o-statbutton-height;
|
||||
.o_pager_counter {
|
||||
margin-right: 5px;
|
||||
min-width: $o-statbutton-height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,12 +123,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Pager input (to edit pager values)
|
||||
.o_pager_value > input {
|
||||
.o_pager_value {
|
||||
display: inline-block;
|
||||
width: 60px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
span.o_pager_value {
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
input.o_pager_value {
|
||||
text-align: right;
|
||||
width: 60px;
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -17,9 +17,6 @@
|
||||
color: darken($o-main-text-color, 10%);
|
||||
}
|
||||
}
|
||||
.dropdown-divider:first-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,93 +1,88 @@
|
||||
@mixin o-search-options-dropdown-custom-item {
|
||||
width: auto;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.o_searchview {
|
||||
align-items: flex-end;
|
||||
position: relative;
|
||||
padding: 0 20px 1px 0;
|
||||
position: relative;
|
||||
|
||||
.o_searchview_more {
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.o_searchview_input_container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
position: relative;
|
||||
|
||||
.o_searchview_facet {
|
||||
$o-searchview-facet-remove-width: 18px;
|
||||
flex: 0 0 auto;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
margin: 1px 3px 0 0;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
$o-searchview-facet-remove-width: 18px;
|
||||
|
||||
.o_searchview_facet_label {
|
||||
flex: 0 0 auto;
|
||||
@include o-text-overflow;
|
||||
padding: 0 3px;
|
||||
color: white;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: white;
|
||||
flex: 0 0 auto;
|
||||
padding: 0 3px;
|
||||
@include o-text-overflow($display: flex);
|
||||
}
|
||||
|
||||
.o_facet_values {
|
||||
direction: ltr#{"/*rtl:ignore*/"};
|
||||
padding: 0 $o-searchview-facet-remove-width 0 5px;
|
||||
|
||||
.o_facet_values_sep {
|
||||
font-style: italic;
|
||||
margin: 0 0.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.o_facet_remove {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@include o-position-absolute(0, 0, 0);
|
||||
width: $o-searchview-facet-remove-width;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
justify-content: center;
|
||||
width: $o-searchview-facet-remove-width;
|
||||
@include o-position-absolute(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.o_searchview_input {
|
||||
width: 100px;
|
||||
flex: 1 0 auto;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.o_searchview_more {
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.o_searchview_autocomplete {
|
||||
@include o-position-absolute(100%, $left: auto);
|
||||
width: 100%;
|
||||
@include o-position-absolute(100%, $left: auto);
|
||||
|
||||
li {
|
||||
.o_menu_item {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
padding-left: 25px;
|
||||
position: relative;
|
||||
&.o-indent {
|
||||
|
||||
&.o_indent {
|
||||
padding-left: 50px;
|
||||
}
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
&:hover {
|
||||
background-color: inherit;
|
||||
}
|
||||
&.o-expand, &.o-expanded { // caret
|
||||
@include o-position-absolute($left: 6px);
|
||||
}
|
||||
&.o-expand:before {
|
||||
@include o-caret-right;
|
||||
}
|
||||
&.o-expanded:before {
|
||||
@include o-caret-down;
|
||||
|
||||
&.o_expand {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 25px;
|
||||
@include o-position-absolute($left: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,127 +90,131 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Searchview and Sidebar dropdowns
|
||||
// FIXME: move me out of here
|
||||
.o_search_options .o_dropdown_toggler_btn:first-child {
|
||||
// apply the padding to the first element so that when the `favorites` menu goes under
|
||||
// the `filters` menu, they're still aligned
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
// Dropdowns
|
||||
.o_dropdown {
|
||||
color: $o-main-text-color;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
user-select: none; // Prevent text selection when toggling the dropdowns
|
||||
white-space: nowrap; // prevent the arrow from going under the dropdown when too little space
|
||||
.dropdown-toggle:first-child {
|
||||
// apply the padding to the first element so that when the `favorites` menu goes under
|
||||
// the `filters` menu, they're still aligned
|
||||
padding-right: 10px;
|
||||
|
||||
.o_dropdown_title {
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
user-select: none; // Prevent text selection when toggling the dropdowns
|
||||
> a { // dropdown togglers
|
||||
color: $o-main-text-color;
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
.o_dropdown_caret {
|
||||
font-size: 12px;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.show {
|
||||
|
||||
.o_dropdown_toggler_btn {
|
||||
color: darken($o-main-text-color, 10%);
|
||||
}
|
||||
}
|
||||
&.show > a {
|
||||
font-weight: bold;
|
||||
color: darken($o-main-text-color, 10%);
|
||||
|
||||
.o_dropdown_menu {
|
||||
|
||||
ul.o_embed_menu {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a.dropdown-item {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
position: relative;
|
||||
|
||||
.o_item_description {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.o_icon_right {
|
||||
@include o-position-absolute($right: $dropdown-item-padding-x * 0.5);
|
||||
}
|
||||
|
||||
.o_icon_right + .o_icon_right {
|
||||
@include o-position-absolute($right: $dropdown-item-padding-x * 1.25);
|
||||
}
|
||||
}
|
||||
|
||||
.o_menu_item_options {
|
||||
list-style: none;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Open and closed carets in dropdowns
|
||||
.o_closed_menu {
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
margin-top: (-$caret-width);
|
||||
@include o-caret-right;
|
||||
@include o-position-absolute($top: 50%, $left: 10px);
|
||||
margin-top: (-$caret-width);
|
||||
}
|
||||
}
|
||||
.o_open_menu {
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
margin-top: (-$caret-width);
|
||||
@include o-caret-down;
|
||||
@include o-position-absolute($top: 50%, $left: 10px);
|
||||
margin-top: (-$caret-width);
|
||||
}
|
||||
}
|
||||
|
||||
.o_filters_menu, .o_group_by_menu, .o_time_range_menu {
|
||||
max-height: calc(100vh - 125px);
|
||||
overflow: auto;
|
||||
}
|
||||
// Filter menu
|
||||
.o_filter_menu {
|
||||
|
||||
.o_filters_menu, .o_group_by_menu {
|
||||
.o_submenu_switcher {
|
||||
@include o-position-absolute($right: $dropdown-item-padding-x * 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// Filters
|
||||
.o_filters_menu {
|
||||
.o_filter_condition {
|
||||
@include o-search-options-dropdown-custom-item;
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
|
||||
.o_or_filter { // or between conditions
|
||||
display: none; // hidden for the first condition
|
||||
@include o-position-absolute($top: 3px, $left: 4px);
|
||||
}
|
||||
}
|
||||
.o_filter_condition + .o_filter_condition {
|
||||
.o_or_filter {
|
||||
display: block; // displayed from second condition
|
||||
|
||||
.o_generator_menu_operator {
|
||||
margin: 3px 0px; // vertical gap between selects
|
||||
}
|
||||
}
|
||||
.o_searchview_extended_prop_op {
|
||||
margin: 3px 0px; // vertical gap between selects
|
||||
}
|
||||
.o_searchview_extended_prop_value { // date fields: datepicker position
|
||||
.datepickerbutton {
|
||||
@include o-position-absolute(3px, -20px);
|
||||
|
||||
.o_generator_menu_value { // date fields: datepicker position
|
||||
.datepickerbutton {
|
||||
cursor: pointer;
|
||||
@include o-position-absolute(3px, -20px);
|
||||
}
|
||||
}
|
||||
|
||||
.o_generator_menu_delete { // delete condition button position
|
||||
cursor: pointer;
|
||||
@include o-position-absolute($top: 6px, $right: 4px);
|
||||
}
|
||||
}
|
||||
.o_searchview_extended_delete_prop { // delete condition button position
|
||||
@include o-position-absolute($top: 6px, $right: 4px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.o_add_filter_menu { // apply and add buttons
|
||||
@include o-search-options-dropdown-custom-item;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
// Favorites
|
||||
.o_favorites_menu {
|
||||
max-height: 75vh;
|
||||
overflow-y: auto;
|
||||
// Time range menu
|
||||
.o_time_range_editor {
|
||||
|
||||
max-height: calc(100vh - 125px);
|
||||
overflow: auto;
|
||||
|
||||
.o-searchview-custom-private, .o-searchview-custom-public {
|
||||
.o-remove-filter {
|
||||
@include o-position-absolute(50%, 12px);
|
||||
margin-top: -6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.o-searchview-custom-public a:after {
|
||||
font-family: FontAwesome;
|
||||
content: "\f0c0";
|
||||
margin-left: 5px;
|
||||
font-weight: normal;
|
||||
}
|
||||
.o_save_name { // save button
|
||||
display: none; // hidden by default
|
||||
@include o-search-options-dropdown-custom-item;
|
||||
}
|
||||
}
|
||||
|
||||
// Time Ranges
|
||||
.o_time_range_menu {
|
||||
.dropdown-item-text > label, .custom-control-label {
|
||||
label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.o_time_range_section:not(:first-child) {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
.o_searchview_more {
|
||||
@include o-position-absolute($top: 6px, $right: 5px);
|
||||
}
|
||||
.o_searchview_autocomplete li.o-selection-focus {
|
||||
.o_searchview_autocomplete li.o_selection_focus {
|
||||
background-color: $o-brand-odoo;
|
||||
> a {
|
||||
color: white;
|
||||
|
||||
@@ -19,7 +19,7 @@ html {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
> .o_cp_controller {
|
||||
> .o_control_panel {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
> .o_content {
|
||||
|
||||
+609
-384
File diff suppressed because it is too large
Load Diff
@@ -260,7 +260,6 @@
|
||||
<script type="text/javascript" src="/web/static/src/js/services/data_manager.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/services/report_service.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/services/session.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/widgets/auto_complete.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/widgets/change_password.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/tools/test_menus_loader.js"/>
|
||||
<script type="text/javascript" src="/web/static/src/js/tools/debug_manager_backend.js"></script>
|
||||
@@ -273,19 +272,21 @@
|
||||
<script type="text/javascript" src="/web/static/src/js/widgets/iframe_widget.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/chrome/loading.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/widgets/model_field_selector.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/chrome/sidebar.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/chrome/pager.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/chrome/systray_menu.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/widgets/switch_company_menu.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/chrome/user_menu.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/chrome/menu.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/chrome/apps_menu.js"></script>
|
||||
|
||||
<script type="text/javascript" src="/web/static/src/js/widgets/dropdown_menu.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/widgets/pie_chart.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/widgets/ribbon.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/widgets/signature.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/components/action_menus.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/components/dropdown_menu.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/components/dropdown_menu_item.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/components/custom_file_input.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/components/datepicker.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/components/pager.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/apps.js"></script>
|
||||
|
||||
<script type="text/javascript" src="/web/static/src/js/fields/abstract_field.js"></script>
|
||||
@@ -303,30 +304,25 @@
|
||||
<script type="text/javascript" src="/web/static/src/js/views/abstract_renderer.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/abstract_renderer_owl.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/abstract_model.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/model.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/abstract_controller.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/renderer_wrapper.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/basic/basic_model.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/basic/basic_view.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/basic/basic_controller.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/basic/basic_renderer.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/control_panel/control_panel_controller.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/control_panel/control_panel_model.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/control_panel/control_panel_renderer.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/control_panel/control_panel_view.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_bar_autocomplete_sources_registry.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_facet.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_bar.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/control_panel/search/filter_menu.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/control_panel/search/groupby_menu.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/control_panel/search/groupby_menu_mixin.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/control_panel/search/time_range_menu.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/control_panel/search/favorite_menu.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/control_panel/search/add_new_favorite_menu.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/control_panel/search/control_panel_view_parameters.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/control_panel/search/favorites_submenus_registry.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_filters.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/control_panel/search/search_filters_registry.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/control_panel/control_panel.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/control_panel/control_panel_model.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/control_panel/control_panel_x2many.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/control_panel/custom_favorite_item.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/control_panel/favorite_menu.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/control_panel/custom_filter_item.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/control_panel/filter_menu.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/control_panel/groupby_menu.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/control_panel/custom_group_by_item.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/control_panel/search_bar.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/control_panel/search_utils.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/control_panel/time_range_menu.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/search_panel.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/field_manager_mixin.js"></script>
|
||||
<script type="text/javascript" src="/web/static/src/js/views/file_upload_mixin.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user