diff options
Diffstat (limited to 'addons/web/static/src/js/model.js')
| -rw-r--r-- | addons/web/static/src/js/model.js | 453 |
1 files changed, 397 insertions, 56 deletions
diff --git a/addons/web/static/src/js/model.js b/addons/web/static/src/js/model.js index 8e3842e5175..b86599eb5fc 100644 --- a/addons/web/static/src/js/model.js +++ b/addons/web/static/src/js/model.js @@ -1,53 +1,256 @@ -odoo.define('web.model', function () { +odoo.define("web/static/src/js/model.js", function (require) { "use strict"; + const { groupBy, partitionBy } = require("web.utils"); + const Registry = require("web.Registry"); + const { Component, core } = owl; const { EventBus, Observer } = core; + const isNotNull = (val) => val !== null && val !== undefined; - // 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; - } + /** + * Feature extension of the class Model. + * @see {Model} + */ + class ModelExtension { + /** + * @param {Object} config + * @param {Object} config.env + */ + constructor(config) { + this.config = config; + this.env = this.config.env; + this._shouldLoad = true; + this.state = {}; + } + + //--------------------------------------------------------------------- + // Public + //--------------------------------------------------------------------- + + /** + * Used by the parent model to initiate a load action. The actual + * loading of the extension is determined by the "_shouldLoad" property. + */ + async callLoad() { + if (this._shouldLoad) { + this._shouldLoad = false; + await this.load(); } - if (!lastGroup) { - lastGroup = [cur]; - acc.push(lastGroup); + } + + /** + * Exports the current state of the extension. + * @returns {Object} + */ + exportState() { + return this.state; + } + + /** + * Meant to return the result of the appropriate getter or do nothing + * if not concerned by the given property. + * @abstract + * @param {string} property + * @param {...any} args + * @returns {null} + */ + get() { + return null; + } + + /** + * Imports the given state after parsing it. If no state is given the + * extension will prepare a new state and will need to be loaded. + * @param {Object} [state] + */ + importState(state) { + this._shouldLoad = !state; + if (this._shouldLoad) { + this.prepareState(); + } else { + Object.assign(this.state, state); } - lastValue = curVal; - return acc; - }, []); + } + + /** + * Called and awaited on initial model load. + * @abstract + * @returns {Promise} + */ + async load() { + /* ... */ + } + + /** + * Called on initialization if no imported state for the extension is + * found. + * @abstract + */ + prepareState() { + /* ... */ + } + + /** + * Called and awaited after an action dispatch. + * @abstract + * @returns {Promise} + */ + async reloadAfterDispatch() { + /* ... */ + } } + /** + * The layer of an extension indicates with which other extensions this one + * will be loaded. This property must be overridden in case the model + * depends on other extensions to be loaded first. + */ + ModelExtension.layer = 0; /** * 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. + * 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 through the 'dispatch' method, a + * notification 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. + * + * The following snippets show a typical use case of the model system: a + * search model with a control panel extension feature. + * + *------------------------------------------------------------------------- + * MODEL AND EXTENSIONS DEFINITION + *------------------------------------------------------------------------- + * + * 1. Definition of the main model + * @see Model + * ``` + * class ActionModel extends Model { + * // ... + * } + * ``` + * + * 2. Definition of the model extension + * @see ModelExtension + * ``` + * class ControlPanelModelExtension extends ActionModel.Extension { + * // ... + * } + * ``` + * + * 3. Registration of the extension into the main model + * @see Registry() + * ``` + * ActionModel.registry.add("SearchPanel", ControlPanelModelExtension, 10); + * ``` + * + *------------------------------------------------------------------------- + * ON VIEW/ACTION INIT + *------------------------------------------------------------------------- + * + * 4. Creation of the core model and its extensions + * @see Model.prototype.constructor() + * ``` + * const extensions = { + * SearchPanel: { + * // ... + * } + * } + * const searchModelConfig = { + * // ... + * }; + * const actionModel = new ActionModel(extensions, searchModelConfig); + * ``` + * + * 5. Loading of all extensions' asynchronous data + * @see Model.prototype.load() + * ``` + * await actionModel.load(); + * ``` + * + * 6. Subscribing to the model changes + * @see useModel() + * ``` + * class ControlPanel extends Component { + * constructor() { + * super(...arguments); + * // env must contain the actionModel + * this.actionModel = useModel('actionModel'); + * } + * } + * ``` + * + *------------------------------------------------------------------------- + * MODEL USAGE ON RUNTIME + *------------------------------------------------------------------------- + * + * Case: dispatch an action + * @see Model.prototype.dispatch() + * ``` + * actionModel.dispatch("updateProperty", value); + * ``` + * + * Case: call a getter + * @see Model.prototype.get() + * ``` + * const result = actionModel.get("property"); + * ``` + * + * @abstract * @extends EventBus */ class Model extends EventBus { - - constructor() { + /** + * Instantiated extensions are determined by the `extensions` argument: + * - keys are the extensions names as added in the registry + * - values are the local configurations given to each extension + * The extensions are grouped by the sequence number they where + * registered with in the registry. Extensions being on the same level + * will be loaded in parallel; this means that all extensions belonging + * to the same group are awaited before loading the next group. + * @param {Object<string, any>} [extensions={}] + * @param {Object} [globalConfig={}] global configuration: can be + * accessed by itself and each of the added extensions. + * @param {Object} [globalConfig.env] + * @param {string} [globalConfig.importedState] + */ + constructor(extensions = {}, globalConfig = {}) { super(); + + this.config = globalConfig; + this.env = this.config.env; + + this.dispatching = false; + this.extensions = []; + this.externalState = {}; + this.mapping = {}; this.rev = 1; - this.mapping = {}; // could be a weak map + + const { name, registry } = this.constructor; + if (!registry || !(registry instanceof Registry)) { + throw new Error(`Unimplemented registry on model "${name}".`); + } + // Order, group and sequencially instantiate all extensions + const registryExtensions = Object.entries(registry.entries()); + const extensionNameLayers = registryExtensions.map( + ([name, { layer }]) => ({ name, layer }) + ); + const groupedNameLayers = groupBy(extensionNameLayers, "layer"); + for (const groupNameLayers of Object.values(groupedNameLayers)) { + for (const { name } of groupNameLayers) { + if (name in extensions) { + this.addExtension(name, extensions[name]); + } + } + } + this.importState(this.config.importedState); } //--------------------------------------------------------------------- @@ -55,23 +258,133 @@ odoo.define('web.model', function () { //--------------------------------------------------------------------- /** - * 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>} + * Method used internally to instantiate all extensions. Can also be + * called externally to add extensions after model instantiation. + * @param {string} extensionName + * @param {Object} extensionConfig + */ + addExtension(extensionName, extensionConfig) { + const { name, registry } = this.constructor; + const Extension = registry.get(extensionName); + if (!Extension) { + throw new Error(`Unknown model extension "${extensionName}" in model "${name}"`); + } + // Extension config = this.config ∪ extension.config + const get = this.__get.bind(this, Extension.name); + const trigger = this.trigger.bind(this); + const config = Object.assign({ get, trigger }, this.config, extensionConfig); + const extension = new Extension(config); + if (!(Extension.layer in this.extensions)) { + this.extensions[Extension.layer] = []; + } + this.extensions[Extension.layer].push(extension); + } + + /** + * Returns the result of the first related method on any instantiated + * extension. This method must be overridden if multiple extensions + * return a value with a common method (and dispatchAll does not + * suffice). After the dispatch of the action, all models are partially + * reloaded (@see ModelExtension.reloadAfterDispatch()) and components + * are notified afterwards. */ - 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(); + dispatch(method, ...args) { + const isInitialDispatch = !this.dispatching; + this.dispatching = true; + for (const extension of this.extensions.flat()) { + if (method in extension) { + extension[method](...args); + } + } + if (isInitialDispatch) { + this.dispatching = false; + (async () => { + // Calls 'after dispatch' hooks + // Purpose: fetch updated data from the server. This is + // considered a loading action and is thus performed by groups + // instead of loading all extensions at once. + for (let layer = 0; layer < this.extensions.length; layer++) { + const afterDispatchPromises = []; + for (const extension of this.extensions[layer]) { + afterDispatchPromises.push(extension.reloadAfterDispatch()); + } + await Promise.all(afterDispatchPromises); + } + // Notifies subscribed components + // Purpose: re-render components bound by 'useModel' + let rev = this.rev; + await Promise.resolve(); + if (rev === this.rev) { + this._notifyComponents(); + } + })(); + } + } + + /** + * Stringifies and exports an object holding the exported state of each + * active extension. + * @returns {string} + */ + exportState() { + const exported = {}; + for (const extension of this.extensions.flat()) { + exported[extension.constructor.name] = extension.exportState(); + } + const fullState = Object.assign({}, this.externalState, exported); + return JSON.stringify(fullState); + } + + /** + * Returns the result of the first related getter on any instantiated + * extension. This method must be overridden if multiple extensions + * share a common getter (and getAll does not make the job). + * @param {string} property + * @param {...any} args + * @returns {any} + */ + get(property, ...args) { + for (const extension of this.extensions.flat()) { + const result = extension.get(property, ...args); + if (isNotNull(result)) { + return result; + } + } + return null; + } + + /** + * Parses the given stringified state object and imports each state + * part to its related extension. + * @param {string} [stringifiedState="null"] + */ + importState(stringifiedState = "null") { + const state = JSON.parse(stringifiedState) || {}; + Object.assign(this.externalState, state); + for (const extension of this.extensions.flat()) { + extension.importState(state[extension.constructor.name]); + } + } + + /** + * Must be called after construction and state preparation/import. + * Waits for all asynchronous work needed by the model extensions to be + * ready. + * /!\ The current model extensions do not require a smarter system at + * the moment (therefore using layers instead of dependencies). It + * should be changed if at some point an extension needs another + * specific extension to be loaded instead of a whole batch (with the + * current system some promises will be waited needlessly). + * @returns {Promise} + */ + async load() { + for (let layer = 0; layer < this.extensions.length; layer++) { + const layerPromises = []; + for (const extension of this.extensions[layer]) { + layerPromises.push(extension.callLoad()); + } + await Promise.all(layerPromises); } - return result; } //--------------------------------------------------------------------- @@ -79,38 +392,66 @@ odoo.define('web.model', function () { //--------------------------------------------------------------------- /** + * Returns the list of the results of all extensions providing a getter + * for the given property returning a non-null value, excluding the + * extension whose name is equal to "excluded". This method is given to + * each extension in the "config" object bound to the model scope and + * having the extension name bound as the first argument. * @private - * @param {string} action - * @param {...any} args + * @param {string} excluded + * @param {string} property + * @param {...any} args + * @returns {any[]} */ - _dispatch(action, ...args) { } + __get(excluded, property, ...args) { + const results = []; + for (const extension of this.extensions.flat()) { + if (extension.constructor.name !== excluded) { + const result = extension.get(property, ...args); + if (isNotNull(result)) { + results.push(result); + } + } + } + return results; + } /** - * see Context method in owl.js for explanation + * @see Context.__notifyComponents() 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)); + 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)); + const proms = group.map((sub) => + sub.callback.call(sub.owner, rev) + ); Component.scheduler.flush(); await Promise.all(proms); } } } + Model.Extension = ModelExtension; + /** - * This is more or less the hook 'useContextWithCB' from owl only slightly simplified. + * This is more or less the hook 'useContextWithCB' from owl only slightly + * simplified. * * @param {string} modelName + * @returns {model} */ 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}'`); + throw new Error(`No Model found when connecting '${ + component.name + }'`); } const mapping = model.mapping; @@ -134,7 +475,7 @@ odoo.define('web.model', function () { return renderFn(comp, params); }; - model.on('update', component, async modelRev => { + model.on("update", component, async (modelRev) => { if (mapping[componentId] < modelRev) { mapping[componentId] = modelRev; await component.render(); @@ -142,7 +483,7 @@ odoo.define('web.model', function () { }); const __destroy = component.__destroy; - component.__destroy = parent => { + component.__destroy = (parent) => { model.off("update", component); __destroy.call(component, parent); }; |
