diff options
Diffstat (limited to 'addons/web/static/src/js/components')
| -rw-r--r-- | addons/web/static/src/js/components/action_menus.js | 197 | ||||
| -rw-r--r-- | addons/web/static/src/js/components/custom_checkbox.js | 58 | ||||
| -rw-r--r-- | addons/web/static/src/js/components/custom_file_input.js | 88 | ||||
| -rw-r--r-- | addons/web/static/src/js/components/datepicker.js | 263 | ||||
| -rw-r--r-- | addons/web/static/src/js/components/dropdown_menu.js | 174 | ||||
| -rw-r--r-- | addons/web/static/src/js/components/dropdown_menu_item.js | 102 | ||||
| -rw-r--r-- | addons/web/static/src/js/components/pager.js | 225 |
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; +}); |
