summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/fields/abstract_field_owl.js
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/fields/abstract_field_owl.js
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/fields/abstract_field_owl.js')
-rw-r--r--addons/web/static/src/js/fields/abstract_field_owl.js648
1 files changed, 648 insertions, 0 deletions
diff --git a/addons/web/static/src/js/fields/abstract_field_owl.js b/addons/web/static/src/js/fields/abstract_field_owl.js
new file mode 100644
index 00000000..43e3d468
--- /dev/null
+++ b/addons/web/static/src/js/fields/abstract_field_owl.js
@@ -0,0 +1,648 @@
+odoo.define('web.AbstractFieldOwl', function (require) {
+ "use strict";
+
+ const field_utils = require('web.field_utils');
+ const { useListener } = require('web.custom_hooks');
+
+ const { onMounted, onPatched } = owl.hooks;
+
+ /**
+ * This file defines the Owl version of the AbstractField. Specific fields
+ * written in Owl should override this component.
+ *
+ * =========================================================================
+ *
+ * /!\ This api works almost exactly like the legacy one but
+ * /!\ it still could change! There are already a few methods that will be
+ * /!\ removed like setIdForLabel, setInvalidClass, etc..
+ *
+ * =========================================================================
+ *
+ * This is the basic field component used by all the views to render a field in a view.
+ * These field components are mostly common to all views, in particular form and list
+ * views.
+ *
+ * The responsabilities of a field component are mainly:
+ * - render a visual representation of the current value of a field
+ * - that representation is either in 'readonly' or in 'edit' mode
+ * - notify the rest of the system when the field has been changed by
+ * the user (in edit mode)
+ *
+ * Notes
+ * - the component is not supposed to be able to switch between modes. If another
+ * mode is required, the view will take care of instantiating another component.
+ * - notify the system when its value has changed and its mode is changed to 'readonly'
+ * - notify the system when some action has to be taken, such as opening a record
+ * - the Field component should not, ever, under any circumstance, be aware of
+ * its parent. The way it communicates changes with the rest of the system is by
+ * triggering events. These events bubble up and are interpreted
+ * by the most appropriate parent.
+ *
+ * Also, in some cases, it may not be practical to have the same component for all
+ * views. In that situation, you can have a 'view specific component'. Just register
+ * the component in the registry prefixed by the view type and a dot. So, for example,
+ * a form specific many2one component should be registered as 'form.many2one'.
+ *
+ * @module web.AbstractFieldOwl
+ */
+ class AbstractField extends owl.Component {
+ /**
+ * Abstract field class
+ *
+ * @constructor
+ * @param {Component} parent
+ * @param {Object} props
+ * @param {string} props.fieldName The field name defined in the model
+ * @param {Object} props.record A record object (result of the get method
+ * of a basic model)
+ * @param {Object} [props.options]
+ * @param {string} [props.options.mode=readonly] should be 'readonly' or 'edit'
+ * @param {string} [props.options.viewType=default]
+ */
+ constructor() {
+ super(...arguments);
+
+ this._isValid = true;
+ // this is the last value that was set by the user, unparsed. This is
+ // used to avoid setting the value twice in a row with the exact value.
+ this._lastSetValue = undefined;
+
+ useListener('keydown', this._onKeydown);
+ useListener('navigation-move', this._onNavigationMove);
+ onMounted(() => this._applyDecorations());
+ onPatched(() => this._applyDecorations());
+ }
+ /**
+ * Hack: studio tries to find the field with a selector base on its
+ * name, before it is mounted into the DOM. Ideally, this should be
+ * done in the onMounted hook, but in this case we are too late, and
+ * Studio finds nothing. As a consequence, the field can't be edited
+ * by clicking on its label (or on the row formed by the pair label-field).
+ *
+ * TODO: move this to mounted at some point?
+ *
+ * @override
+ */
+ __patch() {
+ const res = super.__patch(...arguments);
+ this.el.setAttribute('name', this.name);
+ this.el.classList.add('o_field_widget');
+ return res;
+ }
+ /**
+ * @async
+ * @param {Object} [nextProps]
+ * @returns {Promise}
+ */
+ async willUpdateProps(nextProps) {
+ this._lastSetValue = undefined;
+ return super.willUpdateProps(nextProps);
+ }
+
+ //----------------------------------------------------------------------
+ // Getters
+ //----------------------------------------------------------------------
+
+ /**
+ * This contains the attributes to pass through the context.
+ *
+ * @returns {Object}
+ */
+ get additionalContext() {
+ return this.options.additionalContext || {};
+ }
+ /**
+ * This contains the attributes of the xml 'field' tag, the inner views...
+ *
+ * @returns {Object}
+ */
+ get attrs() {
+ const fieldsInfo = this.record.fieldsInfo[this.viewType];
+ return this.options.attrs || (fieldsInfo && fieldsInfo[this.name]) || {};
+ }
+ /**
+ * Id corresponding to the current record in the model.
+ * Its intended use is to be able to tag any messages going upstream,
+ * so the view knows which records was changed for example.
+ *
+ * @returns {string}
+ */
+ get dataPointId() {
+ return this.record.id;
+ }
+ /**
+ * This is a description of all the various field properties,
+ * such as the type, the comodel (relation), ...
+ *
+ * @returns {string}
+ */
+ get field() {
+ return this.record.fields[this.name];
+ }
+ /**
+ * Returns the main field's DOM element which can be focused by the browser.
+ *
+ * @returns {HTMLElement|null} main focusable element inside the component
+ */
+ get focusableElement() {
+ return null;
+ }
+ /**
+ * Returns the additional options pass to the format function.
+ * Override this getter to add options.
+ *
+ * @returns {Object}
+ */
+ get formatOptions() {
+ return {};
+ }
+ /**
+ * Used to determine which format (and parse) functions
+ * to call to format the field's value to insert into the DOM (typically
+ * put into a span or an input), and to parse the value from the input
+ * to send it to the server. These functions are chosen according to
+ * the 'widget' attrs if is is given, and if it is a valid key, with a
+ * fallback on the field type, ensuring that the value is formatted and
+ * displayed according to the chosen widget, if any.
+ *
+ * @returns {string}
+ */
+ get formatType() {
+ return this.attrs.widget in field_utils.format ?
+ this.attrs.widget : this.field.type;
+ }
+ /**
+ * Returns whether or not the field is empty and can thus be hidden. This
+ * method is typically called when the component is in readonly, to hide it
+ * (and its label) if it is empty.
+ *
+ * @returns {boolean}
+ */
+ get isEmpty() {
+ return !this.isSet;
+ }
+ /**
+ * Returns true if the component has a visible element that can take the focus
+ *
+ * @returns {boolean}
+ */
+ get isFocusable() {
+ const focusable = this.focusableElement;
+ // check if element is visible
+ return focusable && !!(focusable.offsetWidth ||
+ focusable.offsetHeight || focusable.getClientRects().length);
+ }
+ /**
+ * Determines if the field value is set to a meaningful
+ * value. This is useful to determine if a field should be displayed as empty
+ *
+ * @returns {boolean}
+ */
+ get isSet() {
+ return !!this.value;
+ }
+ /**
+ * Tracks if the component is in a valid state, meaning that the current
+ * value represented in the DOM is a value that can be parsed and saved.
+ * For example, a float field can only use a number and not a string.
+ *
+ * @returns {boolean}
+ */
+ get isValid() {
+ return this._isValid;
+ }
+ /**
+ * Fields can be in two modes: 'edit' or 'readonly'.
+ *
+ * @returns {string}
+ */
+ get mode() {
+ return this.options.mode || "readonly";
+ }
+ /**
+ * Useful mostly to trigger rpcs on the correct model.
+ *
+ * @returns {string}
+ */
+ get model() {
+ return this.record.model;
+ }
+ /**
+ * The field name displayed by this component.
+ *
+ * @returns {string}
+ */
+ get name() {
+ return this.props.fieldName;
+ }
+ /**
+ * Component can often be configured in the 'options' attribute in the
+ * xml 'field' tag. These options are saved (and evaled) in nodeOptions.
+ *
+ * @returns {Object}
+ */
+ get nodeOptions() {
+ return this.attrs.options || {};
+ }
+ /**
+ * @returns {Object}
+ */
+ get options() {
+ return this.props.options || {};
+ }
+ /**
+ * Returns the additional options passed to the parse function.
+ * Override this getter to add options.
+ *
+ * @returns {Object}
+ */
+ get parseOptions() {
+ return {};
+ }
+ /**
+ * The datapoint fetched from the model.
+ *
+ * @returns {Object}
+ */
+ get record() {
+ return this.props.record;
+ }
+ /**
+ * Tracks the values for the other fields for the same record.
+ * note that it is expected to be mostly a readonly property, you cannot
+ * use this to try to change other fields value, this is not how it is
+ * supposed to work. Also, do not use this.recordData[this.name] to get
+ * the current value, this could be out of sync after a _setValue.
+ *
+ * @returns {Object}
+ */
+ get recordData() {
+ return this.record.data;
+ }
+ /**
+ * If this flag is set to true, the field component will be reset on
+ * every change which is made in the view (if the view supports it).
+ * This is currently a form view feature.
+ *
+ * /!\ This getter could be removed when basic views (form, list, kanban)
+ * are converted.
+ *
+ * @returns {boolean}
+ */
+ get resetOnAnyFieldChange() {
+ return !!this.attrs.decorations;
+ }
+ /**
+ * The res_id of the record in database.
+ * When the user is creating a new record, there is no res_id.
+ * When the record will be created, the field component will
+ * be destroyed (when the form view switches to readonly mode) and a
+ * new component with a res_id in mode readonly will be created.
+ *
+ * @returns {Number}
+ */
+ get resId() {
+ return this.record.res_id;
+ }
+ /**
+ * Human readable (and translated) description of the field.
+ * Mostly useful to be displayed in various places in the
+ * UI, such as tooltips or create dialogs.
+ *
+ * @returns {string}
+ */
+ get string() {
+ return this.attrs.string || this.field.string || this.name;
+ }
+ /**
+ * Tracks the current (parsed if needed) value of the field.
+ *
+ * @returns {any}
+ */
+ get value() {
+ return this.record.data[this.name];
+ }
+ /**
+ * The type of the view in which the field component is instantiated.
+ * For standalone components, a 'default' viewType is set.
+ *
+ * @returns {string}
+ */
+ get viewType() {
+ return this.options.viewType || 'default';
+ }
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Activates the field component. By default, activation means focusing and
+ * selecting (if possible) the associated focusable element. The selecting
+ * part can be disabled. In that case, note that the focused input/textarea
+ * will have the cursor at the very end.
+ *
+ * @param {Object} [options]
+ * @param {boolean} [options.noselect=false] if false and the input
+ * is of type text or textarea, the content will also be selected
+ * @param {Event} [options.event] the event which fired this activation
+ * @returns {boolean} true if the component was activated, false if the
+ * focusable element was not found or invisible
+ */
+ activate(options) {
+ if (this.isFocusable) {
+ const focusable = this.focusableElement;
+ focusable.focus();
+ if (focusable.matches('input[type="text"], textarea')) {
+ focusable.selectionStart = focusable.selectionEnd = focusable.value.length;
+ if (options && !options.noselect) {
+ focusable.select();
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+ /**
+ * This function should be implemented by components that are not able to
+ * notify their environment when their value changes (maybe because their
+ * are not aware of the changes) or that may have a value in a temporary
+ * state (maybe because some action should be performed to validate it
+ * before notifying it). This is typically called before trying to save the
+ * component's value, so it should call _setValue() to notify the environment
+ * if the value changed but was not notified.
+ *
+ * @abstract
+ * @returns {Promise|undefined}
+ */
+ commitChanges() {}
+ /**
+ * Remove the invalid class on a field
+ *
+ * This function should be removed when BasicRenderer will be rewritten in owl
+ */
+ removeInvalidClass() {
+ this.el.classList.remove('o_field_invalid');
+ this.el.removeAttribute('aria-invalid');
+ }
+ /**
+ * Sets the given id on the focusable element of the field and as 'for'
+ * attribute of potential internal labels.
+ *
+ * This function should be removed when BasicRenderer will be rewritten in owl
+ *
+ * @param {string} id
+ */
+ setIdForLabel(id) {
+ if (this.focusableElement) {
+ this.focusableElement.setAttribute('id', id);
+ }
+ }
+ /**
+ * add the invalid class on a field
+ *
+ * This function should be removed when BasicRenderer will be rewritten in owl
+ */
+ setInvalidClass() {
+ this.el.classList.add('o_field_invalid');
+ this.el.setAttribute('aria-invalid', 'true');
+ }
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * Apply field decorations (only if field-specific decorations have been
+ * defined in an attribute).
+ *
+ * This function should be removed when BasicRenderer will be rewritten in owl
+ *
+ * @private
+ */
+ _applyDecorations() {
+ for (const dec of this.attrs.decorations || []) {
+ const isToggled = py.PY_isTrue(
+ py.evaluate(dec.expression, this.record.evalContext)
+ );
+ const className = this._getClassFromDecoration(dec.name);
+ this.el.classList.toggle(className, isToggled);
+ }
+ }
+ /**
+ * Converts the value from the field to a string representation.
+ *
+ * @private
+ * @param {any} value (from the field type)
+ * @returns {string}
+ */
+ _formatValue(value) {
+ const options = Object.assign({}, this.nodeOptions,
+ { data: this.recordData }, this.formatOptions);
+ return field_utils.format[this.formatType](value, this.field, options);
+ }
+ /**
+ * Returns the className corresponding to a given decoration. A
+ * decoration is of the form 'decoration-%s'. By default, replaces
+ * 'decoration' by 'text'.
+ *
+ * @private
+ * @param {string} decoration must be of the form 'decoration-%s'
+ * @returns {string}
+ */
+ _getClassFromDecoration(decoration) {
+ return `text-${decoration.split('-')[1]}`;
+ }
+ /**
+ * Compares the given value with the last value that has been set.
+ * Note that we compare unparsed values. Handles the special case where no
+ * value has been set yet, and the given value is the empty string.
+ *
+ * @private
+ * @param {any} value
+ * @returns {boolean} true iff values are the same
+ */
+ _isLastSetValue(value) {
+ return this._lastSetValue === value || (this.value === false && value === '');
+ }
+ /**
+ * This method check if a value is the same as the current value of the
+ * field. For example, a fieldDate component might want to use the moment
+ * specific value isSame instead of ===.
+ *
+ * This method is used by the _setValue method.
+ *
+ * @private
+ * @param {any} value
+ * @returns {boolean}
+ */
+ _isSameValue(value) {
+ return this.value === value;
+ }
+ /**
+ * Converts a string representation to a valid value.
+ *
+ * @private
+ * @param {string} value
+ * @returns {any}
+ */
+ _parseValue(value) {
+ return field_utils.parse[this.formatType](value, this.field, this.parseOptions);
+ }
+ /**
+ * This method is called by the component, to change its value and to notify
+ * the outside world of its new state. This method also validates the new
+ * value. Note that this method does not rerender the component, it should be
+ * handled by the component itself, if necessary.
+ *
+ * @private
+ * @param {any} value
+ * @param {Object} [options]
+ * @param {boolean} [options.doNotSetDirty=false] if true, the basic model
+ * will not consider that this field is dirty, even though it was changed.
+ * Please do not use this flag unless you really need it. Our only use
+ * case is currently the pad component, which does a _setValue in the
+ * renderEdit method.
+ * @param {boolean} [options.notifyChange=true] if false, the basic model
+ * will not notify and not trigger the onchange, even though it was changed.
+ * @param {boolean} [options.forceChange=false] if true, the change event will be
+ * triggered even if the new value is the same as the old one
+ * @returns {Promise}
+ */
+ _setValue(value, options) {
+ // we try to avoid doing useless work, if the value given has not changed.
+ if (this._isLastSetValue(value)) {
+ return Promise.resolve();
+ }
+ this._lastSetValue = value;
+ try {
+ value = this._parseValue(value);
+ this._isValid = true;
+ } catch (e) {
+ this._isValid = false;
+ this.trigger('set-dirty', {dataPointID: this.dataPointId});
+ return Promise.reject({message: "Value set is not valid"});
+ }
+ if (!(options && options.forceChange) && this._isSameValue(value)) {
+ return Promise.resolve();
+ }
+ return new Promise((resolve, reject) => {
+ const changes = {};
+ changes[this.name] = value;
+ this.trigger('field-changed', {
+ dataPointID: this.dataPointId,
+ changes: changes,
+ viewType: this.viewType,
+ doNotSetDirty: options && options.doNotSetDirty,
+ notifyChange: !options || options.notifyChange !== false,
+ allowWarning: options && options.allowWarning,
+ onSuccess: resolve,
+ onFailure: reject,
+ });
+ });
+ }
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
+
+ /**
+ * Intercepts navigation keyboard events to prevent their default behavior
+ * and notifies the view so that it can handle it its own way.
+ *
+ * @private
+ * @param {KeyEvent} ev
+ */
+ _onKeydown(ev) {
+ switch (ev.which) {
+ case $.ui.keyCode.TAB:
+ this.trigger('navigation-move', {
+ direction: ev.shiftKey ? 'previous' : 'next',
+ originalEvent: ev,
+ });
+ break;
+ case $.ui.keyCode.ENTER:
+ // We preventDefault the ENTER key because of two coexisting behaviours:
+ // - In HTML5, pressing ENTER on a <button> triggers two events: a 'keydown' AND a 'click'
+ // - When creating and opening a dialog, the focus is automatically given to the primary button
+ // The end result caused some issues where a modal opened by an ENTER keypress (e.g. saving
+ // changes in multiple edition) confirmed the modal without any intentionnal user input.
+ ev.preventDefault();
+ ev.stopPropagation();
+ this.trigger('navigation-move', {direction: 'next_line'});
+ break;
+ case $.ui.keyCode.ESCAPE:
+ this.trigger('navigation-move', {direction: 'cancel', originalEvent: ev});
+ break;
+ case $.ui.keyCode.UP:
+ ev.stopPropagation();
+ this.trigger('navigation-move', {direction: 'up'});
+ break;
+ case $.ui.keyCode.RIGHT:
+ ev.stopPropagation();
+ this.trigger('navigation-move', {direction: 'right'});
+ break;
+ case $.ui.keyCode.DOWN:
+ ev.stopPropagation();
+ this.trigger('navigation-move', {direction: 'down'});
+ break;
+ case $.ui.keyCode.LEFT:
+ ev.stopPropagation();
+ this.trigger('navigation-move', {direction: 'left'});
+ break;
+ }
+ }
+ /**
+ * Updates the target data value with the current AbstractField instance.
+ * This allows to consider the parent field in case of nested fields. The
+ * field which triggered the event is still accessible through ev.target.
+ *
+ * @private
+ * @param {CustomEvent} ev
+ */
+ _onNavigationMove(ev) {
+ ev.detail.target = this;
+ }
+ }
+
+ /**
+ * An object representing fields to be fetched by the model even though not
+ * present in the view.
+ * This object contains "field name" as key and an object as value.
+ * That value object must contain the key "type"
+ * @see FieldBinaryImage for an example.
+ */
+ AbstractField.fieldDependencies = {};
+ /**
+ * If this flag is given a string, the related BasicModel will be used to
+ * initialize specialData the field might need. This data will be available
+ * through this.record.specialData[this.name].
+ *
+ * @see BasicModel._fetchSpecialData
+ */
+ AbstractField.specialData = false;
+ /**
+ * to override to indicate which field types are supported by the component
+ *
+ * @type Array<string>
+ */
+ AbstractField.supportedFieldTypes = [];
+ /**
+ * To override to give a user friendly name to the component.
+ *
+ * @type string
+ */
+ AbstractField.description = "";
+ /**
+ * Currently only used in list view.
+ * If this flag is set to true, the list column name will be empty.
+ */
+ AbstractField.noLabel = false;
+ /**
+ * Currently only used in list view.
+ * If set, this value will be displayed as column name.
+ */
+ AbstractField.label = "";
+
+ return AbstractField;
+});