summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/components
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web/static/src/js/components')
-rw-r--r--addons/web/static/src/js/components/action_menus.js197
-rw-r--r--addons/web/static/src/js/components/custom_checkbox.js58
-rw-r--r--addons/web/static/src/js/components/custom_file_input.js88
-rw-r--r--addons/web/static/src/js/components/datepicker.js263
-rw-r--r--addons/web/static/src/js/components/dropdown_menu.js174
-rw-r--r--addons/web/static/src/js/components/dropdown_menu_item.js102
-rw-r--r--addons/web/static/src/js/components/pager.js225
7 files changed, 1107 insertions, 0 deletions
diff --git a/addons/web/static/src/js/components/action_menus.js b/addons/web/static/src/js/components/action_menus.js
new file mode 100644
index 00000000..ed95e666
--- /dev/null
+++ b/addons/web/static/src/js/components/action_menus.js
@@ -0,0 +1,197 @@
+odoo.define('web.ActionMenus', function (require) {
+ "use strict";
+
+ const Context = require('web.Context');
+ const DropdownMenu = require('web.DropdownMenu');
+ const Registry = require('web.Registry');
+
+ const { Component } = owl;
+
+ let registryActionId = 1;
+
+ /**
+ * 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 {
+
+ async willStart() {
+ this.actionItems = await this._setActionItems(this.props);
+ this.printItems = await this._setPrintItems(this.props);
+ }
+
+ async willUpdateProps(nextProps) {
+ this.actionItems = await this._setActionItems(nextProps);
+ this.printItems = await this._setPrintItems(nextProps);
+ }
+
+ mounted() {
+ this._addTooltips();
+ }
+
+ patched() {
+ this._addTooltips();
+ }
+
+ //---------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------
+
+ /**
+ * Add the tooltips to the items
+ * @private
+ */
+ _addTooltips() {
+ $(this.el.querySelectorAll('[title]')).tooltip({
+ delay: { show: 500, hide: 0 }
+ });
+ }
+
+ /**
+ * @private
+ * @param {Object} props
+ * @returns {Promise<Object[]>}
+ */
+ async _setActionItems(props) {
+ // Callback based actions
+ const callbackActions = (props.items.other || []).map(
+ action => Object.assign({ key: `action-${action.description}` }, action)
+ );
+ // Action based actions
+ const actionActions = props.items.action || [];
+ const relateActions = props.items.relate || [];
+ const formattedActions = [...actionActions, ...relateActions].map(
+ action => ({ action, description: action.name, key: action.id })
+ );
+ // ActionMenus action registry components
+ const registryActions = [];
+ const rpc = this.rpc.bind(this);
+ for (const { Component, getProps } of this.constructor.registry.values()) {
+ const itemProps = await getProps(props, this.env, rpc);
+ if (itemProps) {
+ registryActions.push({
+ Component,
+ key: `registry-action-${registryActionId++}`,
+ props: itemProps,
+ });
+ }
+ }
+
+ return [...callbackActions, ...formattedActions, ...registryActions];
+ }
+
+ /**
+ * @private
+ * @param {Object} props
+ * @returns {Promise<Object[]>}
+ */
+ async _setPrintItems(props) {
+ const printActions = props.items.print || [];
+ const printItems = printActions.map(
+ action => ({ action, description: action.name, key: action.id })
+ );
+ return printItems;
+ }
+
+ //---------------------------------------------------------------------
+ // Handlers
+ //---------------------------------------------------------------------
+
+ /**
+ * Perform the action for the item clicked after getting the data
+ * necessary with a trigger.
+ * @private
+ * @param {OwlEvent} ev
+ */
+ async _executeAction(action) {
+ let activeIds = this.props.activeIds;
+ if (this.props.isDomainSelected) {
+ activeIds = await this.rpc({
+ model: this.env.action.res_model,
+ method: 'search',
+ args: [this.props.domain],
+ kwargs: {
+ limit: this.env.session.active_ids_limit,
+ },
+ });
+ }
+ const activeIdsContext = {
+ active_id: activeIds[0],
+ active_ids: activeIds,
+ active_model: this.env.action.res_model,
+ };
+ if (this.props.domain) {
+ // keep active_domain in context for backward compatibility
+ // reasons, and to allow actions to bypass the active_ids_limit
+ activeIdsContext.active_domain = this.props.domain;
+ }
+
+ const context = new Context(this.props.context, activeIdsContext).eval();
+ const result = await this.rpc({
+ route: '/web/action/load',
+ params: { action_id: action.id, 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 },
+ isDomainSelected: { type: Boolean, optional: 1 },
+ items: {
+ type: Object,
+ shape: {
+ action: { type: Array, optional: 1 },
+ print: { type: Array, optional: 1 },
+ other: { type: Array, optional: 1 },
+ },
+ },
+ };
+ ActionMenus.template = 'web.ActionMenus';
+
+ return ActionMenus;
+});
diff --git a/addons/web/static/src/js/components/custom_checkbox.js b/addons/web/static/src/js/components/custom_checkbox.js
new file mode 100644
index 00000000..bc98dd7b
--- /dev/null
+++ b/addons/web/static/src/js/components/custom_checkbox.js
@@ -0,0 +1,58 @@
+odoo.define('web.CustomCheckbox', function (require) {
+ "use strict";
+
+ const utils = require('web.utils');
+
+ const { Component } = owl;
+
+ /**
+ * Custom checkbox
+ *
+ * Component that can be used in templates to render the custom checkbox of Odoo.
+ *
+ * <CustomCheckbox
+ * value="boolean"
+ * disabled="boolean"
+ * text="'Change the label text'"
+ * t-on-change="_onValueChange"
+ * />
+ *
+ * @extends Component
+ */
+ class CustomCheckbox extends Component {
+ /**
+ * @param {Object} [props]
+ * @param {string | number | null} [props.id]
+ * @param {boolean} [props.value=false]
+ * @param {boolean} [props.disabled=false]
+ * @param {string} [props.text]
+ */
+ constructor() {
+ super(...arguments);
+ this._id = `checkbox-comp-${utils.generateID()}`;
+ }
+ }
+
+ CustomCheckbox.props = {
+ id: {
+ type: [String, Number],
+ optional: 1,
+ },
+ disabled: {
+ type: Boolean,
+ optional: 1,
+ },
+ value: {
+ type: Boolean,
+ optional: 1,
+ },
+ text: {
+ type: String,
+ optional: 1,
+ },
+ };
+
+ CustomCheckbox.template = 'web.CustomCheckbox';
+
+ return CustomCheckbox;
+});
diff --git a/addons/web/static/src/js/components/custom_file_input.js b/addons/web/static/src/js/components/custom_file_input.js
new file mode 100644
index 00000000..14f521a4
--- /dev/null
+++ b/addons/web/static/src/js/components/custom_file_input.js
@@ -0,0 +1,88 @@
+odoo.define('web.CustomFileInput', function (require) {
+ "use strict";
+
+ const { Component, hooks } = owl;
+ const { useRef } = hooks;
+
+ /**
+ * Custom file input
+ *
+ * Component representing a customized input of type file. It takes a sub-template
+ * in its default t-slot and uses it as the trigger to open the file upload
+ * prompt.
+ * @extends Component
+ */
+ class CustomFileInput extends Component {
+ /**
+ * @param {Object} [props]
+ * @param {string} [props.accepted_file_extensions='*'] Comma-separated
+ * list of authorized file extensions (default to all).
+ * @param {string} [props.action='/web/binary/upload'] Route called when
+ * a file is uploaded in the input.
+ * @param {string} [props.id]
+ * @param {string} [props.model]
+ * @param {string} [props.multi_upload=false] Whether the input should allow
+ * to upload multiple files at once.
+ */
+ constructor() {
+ super(...arguments);
+
+ this.fileInputRef = useRef('file-input');
+ }
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Upload an attachment to the given action with the given parameters:
+ * - ufile: list of files contained in the file input
+ * - csrf_token: CSRF token provided by the odoo global object
+ * - model: a specific model which will be given when creating the attachment
+ * - id: the id of the model target instance
+ * @private
+ */
+ async _onFileInputChange() {
+ const { action, model, id } = this.props;
+ const params = {
+ csrf_token: odoo.csrf_token,
+ ufile: [...this.fileInputRef.el.files],
+ };
+ if (model) {
+ params.model = model;
+ }
+ if (id) {
+ params.id = id;
+ }
+ const fileData = await this.env.services.httpRequest(action, params, 'text');
+ const parsedFileData = JSON.parse(fileData);
+ if (parsedFileData.error) {
+ throw new Error(parsedFileData.error);
+ }
+ this.trigger('uploaded', { files: parsedFileData });
+ }
+
+ /**
+ * Redirect clicks from the trigger element to the input.
+ * @private
+ */
+ _onTriggerClicked() {
+ this.fileInputRef.el.click();
+ }
+ }
+ CustomFileInput.defaultProps = {
+ accepted_file_extensions: '*',
+ action: '/web/binary/upload',
+ multi_upload: false,
+ };
+ CustomFileInput.props = {
+ accepted_file_extensions: { type: String, optional: 1 },
+ action: { type: String, optional: 1 },
+ id: { type: Number, optional: 1 },
+ model: { type: String, optional: 1 },
+ multi_upload: { type: Boolean, optional: 1 },
+ };
+ CustomFileInput.template = 'web.CustomFileInput';
+
+ return CustomFileInput;
+});
diff --git a/addons/web/static/src/js/components/datepicker.js b/addons/web/static/src/js/components/datepicker.js
new file mode 100644
index 00000000..94ce7ece
--- /dev/null
+++ b/addons/web/static/src/js/components/datepicker.js
@@ -0,0 +1,263 @@
+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);
+
+ const pickerOptions = Object.assign({ format: this.defaultFormat }, this.props);
+ this._datetimepicker(pickerOptions);
+ this.inputRef.el.value = this._formatDate(this.props.date);
+ }
+
+ willUnmount() {
+ this._datetimepicker('destroy');
+ }
+
+ willUpdateProps(nextProps) {
+ for (const prop in nextProps) {
+ this._datetimepicker(prop, nextProps[prop]);
+ }
+ if (nextProps.date) {
+ this.inputRef.el.value = this._formatDate(nextProps.date);
+ }
+ }
+
+ //---------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------
+
+ /**
+ * @returns {string}
+ */
+ get defaultFormat() {
+ return time.getLangDateFormat();
+ }
+
+ //---------------------------------------------------------------------
+ // 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,
+ 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',
+ },
+ get locale() {return moment.locale();},
+ maxDate: moment({ y: 9999, M: 11, d: 31 }),
+ minDate: moment({ y: 1000 }),
+ 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: { type: String, optional: 1 },
+ 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';
+ }
+
+ /**
+ * @override
+ */
+ get defaultFormat() {
+ return time.getLangDatetimeFormat();
+ }
+ }
+
+ DateTimePicker.defaultProps = Object.assign(Object.create(DatePicker.defaultProps), {
+ buttons: {
+ showClear: false,
+ showClose: true,
+ showToday: false,
+ },
+ });
+
+ return {
+ DatePicker,
+ DateTimePicker,
+ };
+});
diff --git a/addons/web/static/src/js/components/dropdown_menu.js b/addons/web/static/src/js/components/dropdown_menu.js
new file mode 100644
index 00000000..890d928b
--- /dev/null
+++ b/addons/web/static/src/js/components/dropdown_menu.js
@@ -0,0 +1,174 @@
+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 });
+
+ useExternalListener(window, 'click', this._onWindowClick, true);
+ useExternalListener(window, 'keydown', this._onWindowKeydown);
+ }
+
+ //---------------------------------------------------------------------
+ // Getters
+ //---------------------------------------------------------------------
+
+ /**
+ * In desktop, by default, we do not display a caret icon next to the
+ * dropdown.
+ * @returns {boolean}
+ */
+ get displayCaret() {
+ return false;
+ }
+
+ /**
+ * In mobile, by default, we display a chevron icon next to the dropdown
+ * button. Note that when 'displayCaret' is true, we display a caret
+ * instead of a chevron, no matter the value of 'displayChevron'.
+ * @returns {boolean}
+ */
+ get displayChevron() {
+ return this.env.device.isMobile;
+ }
+
+ /**
+ * Can be overriden to force an icon on an inheriting class.
+ * @returns {string} Font Awesome icon class
+ */
+ get icon() {
+ return this.props.icon;
+ }
+
+ /**
+ * 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 */) {
+ if (this.props.closeOnSelected) {
+ this.state.open = false;
+ }
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onWindowClick(ev) {
+ if (
+ this.state.open &&
+ !this.el.contains(ev.target) &&
+ !this.el.contains(document.activeElement)
+ ) {
+ if (document.body.classList.contains("modal-open")) {
+ // retrieve the active modal and check if the dropdown is a child of this modal
+ const modal = document.querySelector('body > .modal:not(.o_inactive_modal)');
+ if (modal && !modal.contains(this.el)) {
+ return;
+ }
+ const owlModal = document.querySelector('body > .o_dialog > .modal:not(.o_inactive_modal)');
+ if (owlModal && !owlModal.contains(this.el)) {
+ return;
+ }
+ }
+ // check for an active open bootstrap calendar like the filter dropdown inside the search panel)
+ if (document.querySelector('body > .bootstrap-datetimepicker-widget')) {
+ return;
+ }
+ 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 },
+ closeOnSelected: { type: Boolean, optional: 1 },
+ };
+ DropdownMenu.template = 'web.DropdownMenu';
+
+ return DropdownMenu;
+});
diff --git a/addons/web/static/src/js/components/dropdown_menu_item.js b/addons/web/static/src/js/components/dropdown_menu_item.js
new file mode 100644
index 00000000..bcc54ab4
--- /dev/null
+++ b/addons/web/static/src/js/components/dropdown_menu_item.js
@@ -0,0 +1,102 @@
+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.el.contains(document.activeElement)
+ ) {
+ this.state.open = false;
+ }
+ }
+ }
+
+ DropdownMenuItem.template = 'web.DropdownMenuItem';
+
+ return DropdownMenuItem;
+});
diff --git a/addons/web/static/src/js/components/pager.js b/addons/web/static/src/js/components/pager.js
new file mode 100644
index 00000000..2d8d402a
--- /dev/null
+++ b/addons/web/static/src/js/components/pager.js
@@ -0,0 +1,225 @@
+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() {
+ this.state.editing = false;
+ 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() {
+ return (1 === this.props.currentMinimum) && (this.maximum === this.props.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);
+ }
+ }
+
+ /**
+ * Commits the current input value. There are two scenarios:
+ * - the value is the same: the pager toggles back to readonly
+ * - the value changed: the pager is disabled to prevent furtherchanges
+ * Either way the "pager-changed" event is triggered to reload the
+ * view.
+ * @private
+ * @param {number} currentMinimum
+ * @param {number} limit
+ */
+ _updateAndDisable(currentMinimum, limit) {
+ if (
+ currentMinimum !== this.props.currentMinimum ||
+ limit !== this.props.limit
+ ) {
+ this.state.disabled = true;
+ } else {
+ // In this case we want to trigger an update, but since it will
+ // not re-render the pager (current props === next props) we
+ // have to disable the edition manually here.
+ this.state.editing = false;
+ }
+ 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;
+});